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
9 changes: 6 additions & 3 deletions components/patterns/BetConfirmPattern.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,17 @@ export function BetConfirmPattern() {
<BetForm />
</div>
<DrawerFooter className="pt-4 flex flex-col gap-2">
<Button className="bg-[#69daff] text-[#004a5d] hover:bg-[#00cffc] w-full" onClick={handleConfirm}>
Confirm Prediction
</Button>
{/* Cancel is rendered BEFORE the primary action so DOM,
visual, and Tab order match (WCAG 2.4.3). On mobile the
primary action sits at the bottom for thumb reach. */}
<DrawerClose asChild>
<Button variant="outline" className="text-[#a3aac4] border-[#40485d] hover:bg-[#192540] w-full">
Cancel
</Button>
</DrawerClose>
<Button className="bg-[#69daff] text-[#004a5d] hover:bg-[#00cffc] w-full" onClick={handleConfirm}>
Confirm Prediction
</Button>
</DrawerFooter>
</>
) : (
Expand Down
10 changes: 7 additions & 3 deletions components/patterns/DisputeActionPattern.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,18 @@ export function DisputeActionPattern() {
</div>

<DrawerFooter className="pt-4 border-t border-[#40485d]/50 bg-[#060e20] flex-col gap-3">
<Button className="bg-red-500 text-white flex-1 hover:bg-red-600 w-full" onClick={() => setOpen(false)}>
Submit Evidence
</Button>
{/* Cancel is rendered BEFORE the destructive primary action so
DOM, visual, and Tab order match (WCAG 2.4.3). The destructive
`Submit Evidence` button keeps its red variant for visual
distinction. */}
<DrawerClose asChild>
<Button variant="outline" className="text-[#a3aac4] border-[#40485d] hover:bg-[#192540] w-full">
Cancel Dispute
</Button>
</DrawerClose>
<Button className="bg-red-500 text-white flex-1 hover:bg-red-600 w-full" onClick={() => setOpen(false)}>
Submit Evidence
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
Expand Down
176 changes: 176 additions & 0 deletions components/ui/__tests__/dialog-footer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Tests for the canonical Confirm/Cancel button order in our primitive
* `DialogFooter` and `AlertDialogFooter` (issue #474).
*
* The DOM order, the visual order, and keyboard focus order MUST all
* match — see `docs/BUTTON_ORDER.md`.
*/
import * as React from "react";
import { describe, expect, it } from "@jest/globals";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";

/**
* Helper: returns all <button> elements that live inside the given
* `role="alertdialog"` / `role="dialog"` node, in DOM (render) order.
*
* We use DOM order (not visual order — JSDOM has no layout engine) to
* verify that the markup matches the spec; that, in turn, is what drives
* keyboard Tab order and screen-reader announcement order.
*/
function getFooterButtons(
dialog: HTMLElement,
footerTestId: string,
): HTMLButtonElement[] {
const footer = within(dialog).getByTestId(footerTestId);
// Materialise the children in DOM order using `querySelectorAll`.
return Array.from(
footer.querySelectorAll<HTMLButtonElement>("button"),
);
}

function AlertDialogHarness() {
return (
<AlertDialog defaultOpen>
<AlertDialogTrigger asChild>
<Button>Open</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this event?</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter data-testid="alert-footer">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-red-600 text-white hover:bg-red-700">
Delete Event
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

function DialogHarness() {
return (
<Dialog defaultOpen>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Prediction</DialogTitle>
</DialogHeader>
<DialogFooter data-testid="dialog-footer">
<Button variant="outline">Cancel</Button>
<Button>Confirm Prediction</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

describe("Button order — Confirm/Cancel (issue #474)", () => {
describe("AlertDialogFooter", () => {
it("renders the Cancel-equivalent before the destructive Action", () => {
render(<AlertDialogHarness />);

const dialog = screen.getByRole("alertdialog");
const buttons = getFooterButtons(dialog, "alert-footer");

expect(buttons).toHaveLength(2);
expect(buttons[0]).toHaveTextContent(/cancel/i);
expect(buttons[1]).toHaveTextContent(/delete event/i);
});

it("focuses the Cancel-equivalent first when tabbing through the footer", async () => {
const user = userEvent.setup();
render(<AlertDialogHarness />);

const dialog = screen.getByRole("alertdialog");
const buttons = getFooterButtons(dialog, "alert-footer");

// Focus the Cancel button directly — verifying the spec rule.
buttons[0].focus();
expect(document.activeElement).toBe(buttons[0]);

// Tab forward once → next focusable inside the dialog must be the
// destructive Action, NOT somewhere earlier in the document.
await user.tab();
expect(document.activeElement).toBe(buttons[1]);
});

it("does NOT use `flex-col-reverse` in the footer base classes", () => {
render(<AlertDialogHarness />);

const dialog = screen.getByRole("alertdialog");
const footer = within(dialog).getByTestId("alert-footer");

// `flex-col-reverse` would invert visual order vs DOM/tab order and
// violate WCAG 2.4.3. We assert the absence of the className suffix.
expect(footer.className).not.toMatch(/flex-col-reverse/);
// The footer must still stack vertically on mobile.
expect(footer.className).toMatch(/\bflex-col\b/);
// And switch to a right-aligned row on `sm+`.
expect(footer.className).toMatch(/sm:flex-row/);
expect(footer.className).toMatch(/sm:justify-end/);
});
});

describe("DialogFooter", () => {
it("renders the Cancel equivalent before the primary action", () => {
render(<DialogHarness />);

const dialog = screen.getByRole("dialog");
const buttons = getFooterButtons(dialog, "dialog-footer");

expect(buttons).toHaveLength(2);
expect(buttons[0]).toHaveTextContent(/cancel/i);
expect(buttons[1]).toHaveTextContent(/confirm prediction/i);
});

it("focuses the Cancel first, then the primary action", async () => {
const user = userEvent.setup();
render(<DialogHarness />);

const dialog = screen.getByRole("dialog");
const buttons = getFooterButtons(dialog, "dialog-footer");

buttons[0].focus();
expect(document.activeElement).toBe(buttons[0]);

await user.tab();
expect(document.activeElement).toBe(buttons[1]);
});

it("does NOT use `flex-col-reverse` in the footer base classes", () => {
render(<DialogHarness />);

const dialog = screen.getByRole("dialog");
const footer = within(dialog).getByTestId("dialog-footer");

expect(footer.className).not.toMatch(/flex-col-reverse/);
expect(footer.className).toMatch(/\bflex-col\b/);
expect(footer.className).toMatch(/sm:flex-row/);
expect(footer.className).toMatch(/sm:justify-end/);
});
});
});
17 changes: 15 additions & 2 deletions components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,24 @@ const AlertDialogHeader = ({
)
AlertDialogHeader.displayName = "AlertDialogHeader"

/**
* Footer for `AlertDialog`. Stacks children vertically on small viewports
* (Cancel on top → primary at the bottom, thumb-friendly) and horizontally
* on `sm+` viewports (Cancel on the left → primary on the right, right-aligned).
*
* The DOM order matches both the visual and Tab orders, so keyboard
* navigation follows what the user sees (WCAG 2.4.3 Focus Order).
*
* Consumers MUST place the `Cancel`-equivalent element BEFORE the primary
* (potentially destructive) `Action` element. See `docs/BUTTON_ORDER.md`.
*/
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3",
className
)}
{...props}
Expand Down Expand Up @@ -118,7 +129,9 @@ const AlertDialogCancel = React.forwardRef<
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
// `mt-2` only makes sense when the footer is `flex-col-reverse`
// (Cancel visually below Action). With our new layout the cancel
// sits ABOVE the action, so no extra margin is needed.
className
)}
{...props}
Expand Down
13 changes: 12 additions & 1 deletion components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,24 @@ const DialogHeader = ({
)
DialogHeader.displayName = "DialogHeader"

/**
* Footer for `Dialog`. Stacks children vertically on small viewports
* (Cancel on top → primary at the bottom, thumb-friendly) and horizontally
* on `sm+` viewports (Cancel on the left → primary on the right, right-aligned).
*
* The DOM order matches both the visual and Tab orders, so keyboard
* navigation follows what the user sees (WCAG 2.4.3 Focus Order).
*
* Consumers MUST place the `Cancel`-equivalent element BEFORE the primary
* (potentially destructive) `Action` element. See `docs/BUTTON_ORDER.md`.
*/
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3",
className
)}
{...props}
Expand Down
Loading