Skip to content

Commit d222541

Browse files
committed
fix(a11y): make choice cards operable by keyboard
CardSelector's options were Cards with an onClick and nothing else — no role, no tab stop, no key handler — so the split method (and every other card choice built on it) could not be reached or chosen without a mouse. They are controls, so they now say so, and a disabled option is marked aria-disabled rather than merely faded. Found by the new story: the component had none, so it had never been scanned.
1 parent 37ee5a7 commit d222541

3 files changed

Lines changed: 161 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import CardSelector from "@app/components/shared/CardSelector";
3+
import {
4+
METHOD_OPTIONS,
5+
type MethodOption,
6+
type SplitMethod,
7+
} from "@app/constants/splitConstants";
8+
9+
/**
10+
* A stack of choice cards, each labelled from an i18n prefix + name pair.
11+
* Driven here by the Split tool's real method options rather than invented
12+
* keys, so the labels are the ones users actually see and the story does not
13+
* introduce translation keys that have to be maintained.
14+
*/
15+
const meta: Meta<typeof CardSelector<SplitMethod, MethodOption>> = {
16+
title: "Shared/CardSelector",
17+
component: CardSelector,
18+
parameters: { layout: "padded" },
19+
args: { onSelect: () => {} },
20+
};
21+
export default meta;
22+
23+
type Story = StoryObj<typeof CardSelector<SplitMethod, MethodOption>>;
24+
25+
/** Every split method. */
26+
export const Default: Story = { args: { options: METHOD_OPTIONS } };
27+
28+
/** A short list — two choices. */
29+
export const FewOptions: Story = {
30+
args: { options: METHOD_OPTIONS.slice(0, 2) },
31+
};
32+
33+
/** A single choice, where the selector is really just a confirmation. */
34+
export const SingleOption: Story = {
35+
args: { options: METHOD_OPTIONS.slice(0, 1) },
36+
};
37+
38+
/** Inert while the tool is busy or its endpoint is still resolving. */
39+
export const Disabled: Story = {
40+
args: { options: METHOD_OPTIONS, disabled: true },
41+
};
42+
43+
/** Nothing available — e.g. every method needs an endpoint that is switched
44+
* off. */
45+
export const Empty: Story = { args: { options: [] } };

frontend/editor/src/core/components/shared/CardSelector.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ const CardSelector = <T, K extends CardOption<T>>({
5757
radius="md"
5858
w="100%"
5959
h={"2.8rem"}
60+
// A choice card is a control: without these it is a div with an
61+
// onClick, so the option cannot be reached or chosen by keyboard.
62+
// aria-disabled rather than removal keeps the option visible and
63+
// explains why it is inert.
64+
role="button"
65+
tabIndex={disabled ? -1 : 0}
66+
aria-disabled={disabled || undefined}
67+
onKeyDown={(e) => {
68+
if (disabled) return;
69+
if (e.key === "Enter" || e.key === " ") {
70+
e.preventDefault();
71+
handleOptionClick(option.value);
72+
}
73+
}}
6074
style={{
6175
cursor: disabled ? "default" : "pointer",
6276
backgroundColor: "var(--mantine-color-gray-2)",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
3+
4+
/**
5+
* How a `.md` file is rendered in the non-PDF viewer. `renderMarkdown` returns
6+
* nodes rather than a component, so the stories render its output directly —
7+
* the same thing the viewer mounts.
8+
*/
9+
const meta: Meta = {
10+
title: "Viewer/NonPdf/MarkdownRenderer",
11+
parameters: { layout: "padded" },
12+
};
13+
export default meta;
14+
15+
const Doc = ({ md }: { md: string }) => <div>{renderMarkdown(md)}</div>;
16+
17+
/** Headings, emphasis, a list and a link — ordinary prose. */
18+
export const Prose: StoryObj = {
19+
render: () => (
20+
<Doc
21+
md={[
22+
"# Release notes",
23+
"",
24+
"The **August** build focuses on _throughput_.",
25+
"",
26+
"## Highlights",
27+
"",
28+
"- Batch processing is no longer serialised",
29+
"- Thumbnails render off the main thread",
30+
"- See the [changelog](https://example.com/changelog)",
31+
].join("\n")}
32+
/>
33+
),
34+
};
35+
36+
/** A fenced code block — the case with its own renderer override. */
37+
export const CodeBlock: StoryObj = {
38+
render: () => (
39+
<Doc
40+
md={[
41+
"Run the container:",
42+
"",
43+
"```bash",
44+
"docker run -p 8080:8080 stirlingtools/stirling-pdf:latest",
45+
"```",
46+
].join("\n")}
47+
/>
48+
),
49+
};
50+
51+
/** A GFM table, which also has its own overrides for header and cell. */
52+
export const Table: StoryObj = {
53+
render: () => (
54+
<Doc
55+
md={[
56+
"| Format | Input | Output |",
57+
"| --- | :-: | ---: |",
58+
"| PDF | yes | yes |",
59+
"| DOCX | yes | no |",
60+
"| PNG | yes | yes |",
61+
].join("\n")}
62+
/>
63+
),
64+
};
65+
66+
/** A wide table and a long code line — both must scroll inside the viewer
67+
* rather than widening it. */
68+
export const WideContent: StoryObj = {
69+
render: () => (
70+
<Doc
71+
md={[
72+
"```json",
73+
'{ "id": "doc_01HQ8ZK3", "source": "s3://bucket/very/long/object/key/contract-2026-final-signed.pdf", "status": "complete" }',
74+
"```",
75+
"",
76+
"| Field | Type | Description | Example | Notes |",
77+
"| --- | --- | --- | --- | --- |",
78+
"| id | string | Stable identifier | doc_01HQ8ZK3 | Opaque |",
79+
"| status | enum | queued / complete / failed | complete | Poll or subscribe |",
80+
].join("\n")}
81+
/>
82+
),
83+
};
84+
85+
/** Blockquote and nested list, the remaining common blocks. */
86+
export const QuotesAndNesting: StoryObj = {
87+
render: () => (
88+
<Doc
89+
md={[
90+
"> Redaction rewrites the page content stream.",
91+
"",
92+
"1. Select the region",
93+
" - Drag to extend",
94+
" - Shift-click to add another",
95+
"2. Apply",
96+
].join("\n")}
97+
/>
98+
),
99+
};
100+
101+
/** An empty document. */
102+
export const Empty: StoryObj = { render: () => <Doc md="" /> };

0 commit comments

Comments
 (0)