Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 9 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/DraggableTextOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
: ""
Expand Down
2 changes: 1 addition & 1 deletion src/components/NativeShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
>
<svg
className="w-5 h-5 mr-2 -ml-1"
Expand Down
7 changes: 7 additions & 0 deletions src/components/ThumbnailStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,13 @@ export default function ThumbnailStrip({
z-index: 3;
}

.thumb-btn:focus-visible {
transform: translateY(-3px) scale(1.04);
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent), var(--shadow);
outline: none;
z-index: 4;
}

.thumb-btn.active img {
filter: brightness(1.1);
}
Expand Down
46 changes: 46 additions & 0 deletions src/components/__tests__/KeyboardNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from "react";
import { describe, beforeEach, it, expect } from "vitest";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThemeProvider } from "../ThemeProvider";
import { ThemeToggle } from "../ThemeToggle";

describe("KeyboardNavigation - ThemeToggle", () => {
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);
});
});
1 change: 1 addition & 0 deletions src/components/ui/BaseButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const BaseButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, BaseButtonP
className={cn(
"flex items-center justify-center gap-2 rounded-lg transition-all duration-200",
"hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:scale-100",
"focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:outline-none focus-visible:ring-offset-[var(--bg)]",
variants[variant],
sizes[size],
className
Expand Down
39 changes: 39 additions & 0 deletions vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,42 @@ Object.defineProperty(window, 'matchMedia', {
dispatchEvent: () => false,
}),
})

class LocalStorageMock implements Storage {
private store: Record<string, string> = {};

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