Skip to content
Merged
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
5 changes: 5 additions & 0 deletions app/__tests__/focus-visible.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ describe("focus-visible CSS layer", () => {
expect(css).toContain("outline-offset:");
});

it("focus.css targets rows marked as actively focused", () => {
const css = fs.readFileSync(focusCssPath, "utf8");
expect(css).toContain('[data-focus-visible="true"]');
});

it("focus.css provides a dark-mode adjustment rule", () => {
const css = fs.readFileSync(focusCssPath, "utf8");
expect(css).toContain(".dark :focus-visible");
Expand Down
4 changes: 2 additions & 2 deletions app/styles/focus.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
* offset so it remains visible on both light and dark backgrounds.
*/
:focus-visible,
[data-focus-visible] {
[data-focus-visible="true"] {
outline: 3px solid hsl(var(--ring, 210 100% 56%));
outline-offset: 2px;
border-radius: var(--radius, 0.375rem);
Expand All @@ -44,7 +44,7 @@
* white inner shadow acts as a separator without adding DOM nodes.
*/
.dark :focus-visible,
.dark [data-focus-visible] {
.dark [data-focus-visible="true"] {
box-shadow: 0 0 0 2px hsl(var(--background, 222 47% 11%));
}

Expand Down
20 changes: 20 additions & 0 deletions app/styles/touch.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,24 @@
min-height: 44px;
min-width: 44px;
}

.touch-ripple {
position: relative;
overflow: hidden;
}

.touch-ripple:active::after {
content: "";
position: absolute;
inset: 0;
background: rgba(148, 163, 184, 0.18);
border-radius: inherit;
pointer-events: none;
animation: touch-ripple 220ms ease-out;
}

@keyframes touch-ripple {
from { opacity: 1; transform: scale(0.96); }
to { opacity: 0; transform: scale(1); }
}
}
4 changes: 2 additions & 2 deletions components/PredictionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const PredictionCard: React.FC<PredictionCardProps> = ({ prediction }) => {

return (
/* touch-target: enforces ≥44px hit area (WCAG 2.5.5 / Apple HIG). */
<button className="touch-target w-full text-left bg-card p-4 rounded-xl shadow-lg hover:bg-muted/50 transition duration-200 cursor-pointer border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<button className="touch-target touch-ripple w-full text-left bg-card p-4 rounded-xl shadow-lg hover:bg-muted/50 transition duration-200 cursor-pointer border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<div className="flex justify-between items-start mb-3">
<div className="flex flex-col items-start gap-1 pr-2">
<h3 className="text-lg font-semibold text-card-foreground line-clamp-2">{title}</h3>
Expand Down Expand Up @@ -157,7 +157,7 @@ const PredictionCard: React.FC<PredictionCardProps> = ({ prediction }) => {
<CollapsibleTrigger asChild>
{/* touch-target: guarantees ≥44px tap area on the Odds trigger (WCAG 2.5.5). */}
<button
className="touch-target flex w-full items-center justify-between px-2 rounded hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="touch-target touch-ripple flex w-full items-center justify-between px-2 rounded hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={isOddsExpanded}
aria-controls="odds-breakdown"
>
Expand Down
6 changes: 4 additions & 2 deletions components/__tests__/PredictionCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,19 +208,21 @@ describe('PredictionsList loading state', () => {
// --- Touch Target Tests (WCAG 2.5.5 / Apple HIG ≥44px) ---

describe('PredictionCard touch targets', () => {
it('outer card button has touch-target class', () => {
it('outer card button has touch-target and touch-ripple classes', () => {
render(<PredictionCard prediction={mockPrediction} />);
// The root interactive element must carry the touch-target utility class
// which enforces min-height: 44px and min-width: 44px.
const card = screen.getByRole('button', { name: /NBA Finals/i });
expect(card).toHaveClass('touch-target');
expect(card).toHaveClass('touch-ripple');
});

it('odds collapsible trigger has touch-target class', () => {
it('odds collapsible trigger has touch-target and touch-ripple classes', () => {
render(<PredictionCard prediction={mockPrediction} />);
// Query specifically by aria-controls to distinguish from the outer card button.
const oddsTrigger = document.querySelector('[aria-controls="odds-breakdown"]') as HTMLElement;
expect(oddsTrigger).toHaveClass('touch-target');
expect(oddsTrigger).toHaveClass('touch-ripple');
});

it('odds trigger uses aria-expanded to reflect collapsed state', () => {
Expand Down