🎨 Palette: Add Skip to Content Link #48
@@ -9,3 +9,7 @@
|
||||
## 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).
|
||||
**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 { Navbar, Footer, FancyCursor, ScrollToTop } from './components/layout';
|
||||
import { Home } from './pages/Home';
|
||||
import { PageLoader } from './components/ui';
|
||||
import { PageLoader, SkipLink } from './components/ui';
|
||||
import './styles/global.css';
|
||||
|
||||
// Lazy load pages to reduce initial bundle size.
|
||||
@@ -15,6 +15,7 @@ export function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<BrowserRouter>
|
||||
<SkipLink />
|
||||
<ScrollToTop />
|
||||
<FancyCursor />
|
||||
<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 { Input, Textarea } from './Input';
|
||||
export { PageLoader } from './PageLoader';
|
||||
export { SkipLink } from './SkipLink';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const de = {
|
||||
// Navigation
|
||||
nav: {
|
||||
skipToContent: 'Zum Inhalt springen',
|
||||
home: 'Startseite',
|
||||
about: 'Über uns',
|
||||
contact: 'Kontakt',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Translations } from './de';
|
||||
export const en: Translations = {
|
||||
// Navigation
|
||||
nav: {
|
||||
skipToContent: 'Skip to content',
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
contact: 'Contact',
|
||||
|
||||
@@ -46,7 +46,13 @@ export function About() {
|
||||
const location = useLocation();
|
||||
|
||||
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 */}
|
||||
<section className={styles.hero}>
|
||||
<div className="container">
|
||||
|
||||
@@ -133,7 +133,12 @@ export function Contact() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<section className={styles.hero}>
|
||||
<div className="container">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hero, Services } from '../components/sections';
|
||||
|
||||
export function Home() {
|
||||
return (
|
||||
<main>
|
||||
<main id="main-content" tabIndex={-1} style={{ outline: 'none' }}>
|
||||
<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 />
|
||||
</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.