🎨 Palette: Add Skip to Content Link #48

Closed
ragusa-it wants to merge 1 commits from palette-skip-link-12322634670387194045 into main
11 changed files with 80 additions and 4 deletions

View File

@@ -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.

View File

@@ -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 />

View 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);
}

View 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>
);
}

View 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');
});
});

View File

@@ -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';

View File

@@ -1,6 +1,7 @@
export const de = {
// Navigation
nav: {
skipToContent: 'Zum Inhalt springen',
home: 'Startseite',
about: 'Über uns',
contact: 'Kontakt',

View File

@@ -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',

View File

@@ -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}
copilot-pull-request-reviewer[bot] commented 2026-02-02 01:51:45 +00:00 (Migrated from github.com)
Review

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) defines outline: none in the CSS file for the :focus pseudo-class. Consider adding a class in your CSS module to handle this instead.


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) 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 ```
key={location.key}
>
{/* Hero Section */}
<section className={styles.hero}>
<div className="container">

View File

@@ -133,7 +133,12 @@ export function Contact() {
};
return (
<main className={styles.contact}>
<main
id="main-content"
tabIndex={-1}
style={{ outline: 'none' }}
className={styles.contact}
copilot-pull-request-reviewer[bot] commented 2026-02-02 01:51:46 +00:00 (Migrated from github.com)
Review

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.


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">

View File

@@ -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 />
copilot-pull-request-reviewer[bot] commented 2026-02-02 01:51:46 +00:00 (Migrated from github.com)
Review

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.

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>