diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2e51c0aa..73652759 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,6 +26,8 @@ - [ ] `bun run lint` passes (no ESLint errors) - [ ] `bunx tsc --noEmit` passes (no TypeScript errors) - [ ] New interactive elements have `aria-label` / accessible names +- [ ] Keyboard navigation: I have verified that all new/modified interactive elements can be focused using `Tab` and activated using `Space`/`Enter` +- [ ] Keyboard focus states: I have verified focus indicators are clearly visible and theme-aware (have sufficient color contrast in light, dark, and high-contrast modes) - [ ] No `console.log` statements left in - [ ] This PR is related to a valid issue - [ ] **Screen recording attached above** (required for UI/feature/design changes) \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 2f27ce26..202b2c28 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -158,11 +158,18 @@ textarea:focus { } :focus-visible { - outline: 0; - box-shadow: 0 0 0 3px var(--accent-muted); + outline: none; + box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent); border-radius: var(--radius); } +[data-theme="high-contrast"] :focus-visible { + outline: 2px solid var(--accent) !important; + outline-offset: 2px !important; + box-shadow: none !important; +} + + detail > summary { list-style: none; } diff --git a/src/components/DraggableTextOverlays.tsx b/src/components/DraggableTextOverlays.tsx index d6a088aa..288e8353 100644 --- a/src/components/DraggableTextOverlays.tsx +++ b/src/components/DraggableTextOverlays.tsx @@ -249,7 +249,7 @@ export default function DraggableTextOverlays({ editingId === overlay.id ? "cursor-text" : "cursor-move" } select-none transition-all ${ isDragging ? "scale-105" : "scale-100" - } ${ + } focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:outline-none focus-visible:ring-offset-black/50 ${ isSelected ? "ring-2 ring-film-500 ring-offset-1 ring-offset-black/50" : "" diff --git a/src/components/NativeShareButton.tsx b/src/components/NativeShareButton.tsx index 39eeb723..3970aa36 100644 --- a/src/components/NativeShareButton.tsx +++ b/src/components/NativeShareButton.tsx @@ -79,7 +79,7 @@ export function NativeShareButton({ disabled={isSharing} type="button" aria-label="Share video" - className={`inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white transition-colors duration-200 bg-blue-600 rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed ${className}`} + className={`inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white transition-colors duration-200 bg-[var(--accent)] rounded-md shadow-sm hover:bg-[var(--accent-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--bg)] disabled:opacity-50 disabled:cursor-not-allowed ${className}`} > { + beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("dark"); + }); + + it("can be focused using Tab and activated using Space/Enter", async () => { + const user = userEvent.setup(); + + render( + React.createElement( + ThemeProvider, + null, + React.createElement(ThemeToggle) + ) + ); + + const toggleButton = screen.getByRole("button", { name: /switch/i }); + expect(toggleButton).toBeTruthy(); + + // Verify it is not initially focused + expect(document.activeElement).not.toBe(toggleButton); + + // Tab to the button + await user.tab(); + + // Verify it is now focused + expect(document.activeElement).toBe(toggleButton); + + // Toggle theme using Space key + await user.keyboard(" "); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + // Toggle theme using Enter key + await user.keyboard("{Enter}"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); +}); diff --git a/src/components/ui/BaseButton.tsx b/src/components/ui/BaseButton.tsx index e889f045..7b8c479e 100644 --- a/src/components/ui/BaseButton.tsx +++ b/src/components/ui/BaseButton.tsx @@ -39,6 +39,7 @@ const BaseButton = forwardRef false, }), }) + +class LocalStorageMock implements Storage { + private store: Record = {}; + + clear() { + this.store = {}; + } + + getItem(key: string) { + return this.store[key] || null; + } + + setItem(key: string, value: string) { + this.store[key] = String(value); + } + + removeItem(key: string) { + delete this.store[key]; + } + + get length() { + return Object.keys(this.store).length; + } + + key(index: number) { + return Object.keys(this.store)[index] || null; + } +} + +const mockLocalStorage = new LocalStorageMock(); +Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, + writable: true, +}); +Object.defineProperty(global, 'localStorage', { + value: mockLocalStorage, + writable: true, +}); +