🎨 Palette: Add Skip to Content Link #48
@@ -9,3 +9,7 @@
|
|||||||
## 2024-05-24 - Enhancing Inputs Safely
|
## 2024-05-24 - Enhancing Inputs Safely
|
||||||
**Learning:** Adding features like character counters to generic input components must handle both controlled and uncontrolled states. Assuming a component is controlled (using `props.value`) can break uncontrolled usage by showing stale data (e.g., sticking at 0/100).
|
**Learning:** Adding features like character counters to generic input components must handle both controlled and uncontrolled states. Assuming a component is controlled (using `props.value`) can break uncontrolled usage by showing stale data (e.g., sticking at 0/100).
|
||||||
**Action:** When enhancing generic components, detect uncontrolled state (e.g., `props.value === undefined`) and either implement internal state tracking or gracefully degrade (hide the feature) to avoid misleading UX.
|
**Action:** When enhancing generic components, detect uncontrolled state (e.g., `props.value === undefined`) and either implement internal state tracking or gracefully degrade (hide the feature) to avoid misleading UX.
|
||||||
|
|
||||||
|
## 2025-02-14 - Skip to Content Implementation
|
||||||
|
**Learning:** Implementing a "Skip to Content" link requires not just the link component, but ensuring the target `<main>` element has `tabIndex={-1}` and `outline: 'none'` (or CSS equivalent) to ensure programmatic focus works correctly without creating an ugly focus ring on the container.
|
||||||
|
**Action:** When adding skip links, always verify the target container's focusability and visual focus style.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|||||||
import { LanguageProvider } from './i18n';
|
import { LanguageProvider } from './i18n';
|
||||||
import { Navbar, Footer, FancyCursor, ScrollToTop } from './components/layout';
|
import { Navbar, Footer, FancyCursor, ScrollToTop } from './components/layout';
|
||||||
import { Home } from './pages/Home';
|
import { Home } from './pages/Home';
|
||||||
import { PageLoader } from './components/ui';
|
import { PageLoader, SkipLink } from './components/ui';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
|
|
||||||
// Lazy load pages to reduce initial bundle size.
|
// Lazy load pages to reduce initial bundle size.
|
||||||
@@ -15,6 +15,7 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<SkipLink />
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<FancyCursor />
|
<FancyCursor />
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|||||||
20
src/components/ui/SkipLink.module.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.skipLink {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
z-index: 1000;
|
||||||
|
transform: translateY(-200%);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skipLink:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
12
src/components/ui/SkipLink.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import styles from './SkipLink.module.css';
|
||||||
|
|
||||||
|
export function SkipLink() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href="#main-content" className={styles.skipLink}>
|
||||||
|
{t.nav.skipToContent}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/ui/__tests__/SkipLink.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { SkipLink } from '../SkipLink';
|
||||||
|
|
||||||
|
vi.mock('../../../i18n', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: {
|
||||||
|
nav: {
|
||||||
|
skipToContent: 'Skip to content',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SkipLink', () => {
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
it('renders with correct href and text', () => {
|
||||||
|
render(<SkipLink />);
|
||||||
|
const link = screen.getByRole('link', { name: /skip to content/i });
|
||||||
|
expect(link).toBeTruthy();
|
||||||
|
expect(link.getAttribute('href')).toBe('#main-content');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,3 +2,4 @@ export { Button } from './Button';
|
|||||||
export { Card } from './Card';
|
export { Card } from './Card';
|
||||||
export { Input, Textarea } from './Input';
|
export { Input, Textarea } from './Input';
|
||||||
export { PageLoader } from './PageLoader';
|
export { PageLoader } from './PageLoader';
|
||||||
|
export { SkipLink } from './SkipLink';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const de = {
|
export const de = {
|
||||||
// Navigation
|
// Navigation
|
||||||
nav: {
|
nav: {
|
||||||
|
skipToContent: 'Zum Inhalt springen',
|
||||||
home: 'Startseite',
|
home: 'Startseite',
|
||||||
about: 'Über uns',
|
about: 'Über uns',
|
||||||
contact: 'Kontakt',
|
contact: 'Kontakt',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Translations } from './de';
|
|||||||
export const en: Translations = {
|
export const en: Translations = {
|
||||||
// Navigation
|
// Navigation
|
||||||
nav: {
|
nav: {
|
||||||
|
skipToContent: 'Skip to content',
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
contact: 'Contact',
|
contact: 'Contact',
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ export function About() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.about} key={location.key}>
|
<main
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
className={styles.about}
|
||||||
|
|
|||||||
|
key={location.key}
|
||||||
|
>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className={styles.hero}>
|
<section className={styles.hero}>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
|||||||
@@ -133,7 +133,12 @@ export function Contact() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.contact}>
|
<main
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
className={styles.contact}
|
||||||
|
The inline style The inline style `style={{ outline: 'none' }}` should be moved to the CSS module (Contact.module.css) for consistency with the codebase conventions. In this codebase, inline styles are primarily used for dynamic values (e.g., blend modes, transforms), not for static styles like outline removal. For example, Input.module.css (line 42) defines `outline: none` in the CSS file for the :focus pseudo-class. Consider adding a class in your CSS module to handle this instead.
```suggestion
```
|
|||||||
|
>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className={styles.hero}>
|
<section className={styles.hero}>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hero, Services } from '../components/sections';
|
|||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main id="main-content" tabIndex={-1} style={{ outline: 'none' }}>
|
||||||
<Hero />
|
<Hero />
|
||||||
|
The inline style The inline style `style={{ outline: 'none' }}` should be moved to the CSS module (Home.module.css) for consistency with the codebase conventions. In this codebase, inline styles are primarily used for dynamic values (e.g., blend modes, transforms), not for static styles like outline removal. For example, Input.module.css (line 42) defines `outline: none` in the CSS file for the :focus pseudo-class. Consider adding a class in your CSS module to handle this instead.
|
|||||||
<Services />
|
<Services />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
The inline style
style={{ outline: 'none' }}should be moved to the CSS module (About.module.css) for consistency with the codebase conventions. In this codebase, inline styles are primarily used for dynamic values (e.g., blend modes, transforms), not for static styles like outline removal. For example, Input.module.css (line 42) definesoutline: nonein the CSS file for the :focus pseudo-class. Consider adding a class in your CSS module to handle this instead.