@charcuterie/ui 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +104 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +115 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/Field/FieldGroup.d.ts +58 -0
  15. package/dist/Field/FieldGroup.d.ts.map +1 -0
  16. package/dist/Field/FieldGroup.js +65 -0
  17. package/dist/Field/FieldGroup.js.map +1 -0
  18. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  19. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  20. package/dist/FileDropZone/FileDropZone.js +122 -0
  21. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  22. package/dist/LogViewer/LogViewer.d.ts +90 -0
  23. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  24. package/dist/LogViewer/LogViewer.js +165 -0
  25. package/dist/LogViewer/LogViewer.js.map +1 -0
  26. package/dist/Menu/Menu.d.ts +81 -0
  27. package/dist/Menu/Menu.d.ts.map +1 -0
  28. package/dist/Menu/Menu.js +199 -0
  29. package/dist/Menu/Menu.js.map +1 -0
  30. package/dist/Menu/MenuAction.d.ts +24 -0
  31. package/dist/Menu/MenuAction.d.ts.map +1 -0
  32. package/dist/Menu/MenuAction.js +37 -0
  33. package/dist/Menu/MenuAction.js.map +1 -0
  34. package/dist/Select/Select.d.ts +87 -0
  35. package/dist/Select/Select.d.ts.map +1 -0
  36. package/dist/Select/Select.js +51 -0
  37. package/dist/Select/Select.js.map +1 -0
  38. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  39. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  40. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  41. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  42. package/dist/Toast/Toast.d.ts +54 -0
  43. package/dist/Toast/Toast.d.ts.map +1 -0
  44. package/dist/Toast/Toast.js +121 -0
  45. package/dist/Toast/Toast.js.map +1 -0
  46. package/dist/Toast/ToastRegion.d.ts +57 -0
  47. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  48. package/dist/Toast/ToastRegion.js +41 -0
  49. package/dist/Toast/ToastRegion.js.map +1 -0
  50. package/dist/Toast/toastLifecycle.d.ts +34 -0
  51. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  52. package/dist/Toast/toastLifecycle.js +25 -0
  53. package/dist/Toast/toastLifecycle.js.map +1 -0
  54. package/dist/Tooltip/Tooltip.d.ts +82 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  56. package/dist/Tooltip/Tooltip.js +124 -0
  57. package/dist/Tooltip/Tooltip.js.map +1 -0
  58. package/dist/index.d.ts +26 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +13 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/mountStory.testHelpers.d.ts +0 -11
  63. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  64. package/dist/mountStory.testHelpers.js +27 -0
  65. package/dist/mountStory.testHelpers.js.map +1 -1
  66. package/dist/slotProps.d.ts +110 -0
  67. package/dist/slotProps.d.ts.map +1 -0
  68. package/dist/slotProps.js +39 -0
  69. package/dist/slotProps.js.map +1 -0
  70. package/package.json +3 -3
  71. package/src/Accordion/Accordion.mdx +83 -0
  72. package/src/Accordion/Accordion.stories.tsx +146 -0
  73. package/src/Accordion/Accordion.test.tsx +163 -0
  74. package/src/Accordion/Accordion.tsx +182 -0
  75. package/src/Accordion/AccordionSection.tsx +163 -0
  76. package/src/Field/Field.mdx +144 -0
  77. package/src/Field/Field.stories.tsx +280 -0
  78. package/src/Field/Field.test.tsx +344 -0
  79. package/src/Field/Field.tsx +212 -0
  80. package/src/Field/FieldGroup.tsx +138 -0
  81. package/src/FileDropZone/FileDropZone.mdx +78 -0
  82. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  83. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  84. package/src/FileDropZone/FileDropZone.tsx +234 -0
  85. package/src/LogViewer/LogViewer.mdx +123 -0
  86. package/src/LogViewer/LogViewer.stories.tsx +169 -0
  87. package/src/LogViewer/LogViewer.test.tsx +236 -0
  88. package/src/LogViewer/LogViewer.tsx +261 -0
  89. package/src/Menu/Menu.mdx +103 -0
  90. package/src/Menu/Menu.stories.tsx +161 -0
  91. package/src/Menu/Menu.test.tsx +202 -0
  92. package/src/Menu/Menu.tsx +300 -0
  93. package/src/Menu/MenuAction.tsx +95 -0
  94. package/src/Select/Select.mdx +74 -0
  95. package/src/Select/Select.stories.tsx +126 -0
  96. package/src/Select/Select.test.tsx +129 -0
  97. package/src/Select/Select.tsx +200 -0
  98. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  99. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  100. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  101. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  102. package/src/Toast/Toast.mdx +84 -0
  103. package/src/Toast/Toast.stories.tsx +192 -0
  104. package/src/Toast/Toast.test.tsx +217 -0
  105. package/src/Toast/Toast.tsx +224 -0
  106. package/src/Toast/ToastRegion.tsx +98 -0
  107. package/src/Toast/toastLifecycle.ts +45 -0
  108. package/src/Tooltip/Tooltip.mdx +69 -0
  109. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  110. package/src/Tooltip/Tooltip.test.tsx +138 -0
  111. package/src/Tooltip/Tooltip.tsx +193 -0
  112. package/src/index.ts +47 -0
  113. package/src/mountStory.testHelpers.ts +30 -0
  114. package/src/slotProps.ts +143 -0
  115. package/src/sourceRules.test.ts +221 -30
@@ -0,0 +1,129 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./Select.stories.tsx"
9
+
10
+ const { AllStates, AllVariants, Default } =
11
+ composeStories(stories)
12
+
13
+ test("a select is named, and therefore findable", async () => {
14
+ const { canvas, canvasElement } =
15
+ await mountStory(Default)
16
+
17
+ // Twelve of the fleet's fourteen native selects have neither a
18
+ // `<label for>` nor an `aria-label`, so this query returns nothing
19
+ // for any of them. A `<select>` reports `role="combobox"`.
20
+ expectAgentDrivable(canvas, {
21
+ name: "Rip profile",
22
+ role: "combobox",
23
+ })
24
+
25
+ await expectNoAxeViolations(canvasElement)
26
+ })
27
+
28
+ test("choosing reports the value outward", async () => {
29
+ const { canvas } = await mountStory(Default)
30
+
31
+ const select = expectAgentDrivable(canvas, {
32
+ name: "Rip profile",
33
+ role: "combobox",
34
+ }) as HTMLSelectElement
35
+
36
+ await userEvent.selectOptions(select, "compressed")
37
+
38
+ await expect(select.value).toBe("compressed")
39
+ })
40
+
41
+ /**
42
+ * The platform owns the value, and this component owns none. Every
43
+ * other interactive component here holds its state in
44
+ * `@charcuterie/logic`; putting a `useSinglePicker` beside a
45
+ * `<select>` would be two things believing they hold one fact —
46
+ * the Radix argument from `Popover`, pointed at ourselves.
47
+ */
48
+ test("the initial value seeds the DOM and nothing more", async () => {
49
+ const { canvas } = await mountStory(AllVariants)
50
+
51
+ const select = expectAgentDrivable(canvas, {
52
+ name: "Profile with prompt",
53
+ role: "combobox",
54
+ }) as HTMLSelectElement
55
+
56
+ // A placeholder is a *disabled* option, so it shows and cannot be
57
+ // submitted. An empty-valued enabled option would look identical
58
+ // and silently post "".
59
+ await expect(select.value).toBe("")
60
+
61
+ const [placeholder] = Array.from(select.options)
62
+
63
+ await expect(placeholder).toBeDisabled()
64
+ })
65
+
66
+ test("a disabled option stays announced", async () => {
67
+ const { canvas } = await mountStory(Default)
68
+
69
+ const select = expectAgentDrivable(canvas, {
70
+ name: "Rip profile",
71
+ role: "combobox",
72
+ }) as HTMLSelectElement
73
+
74
+ const dolby = Array.from(select.options).find(
75
+ (option) => option.value === "dv",
76
+ )
77
+
78
+ // In the tree, and unselectable. Removing it instead would tell a
79
+ // screen-reader user the profile does not exist rather than that
80
+ // it is unavailable.
81
+ await expect(dolby).toBeDisabled()
82
+
83
+ await expect(dolby).toHaveTextContent("Dolby Vision")
84
+ })
85
+
86
+ test("option groups keep their group names", async () => {
87
+ const { canvas } = await mountStory(AllVariants)
88
+
89
+ const select = expectAgentDrivable(canvas, {
90
+ name: "Stream kind",
91
+ role: "combobox",
92
+ }) as HTMLSelectElement
93
+
94
+ const groups = Array.from(
95
+ select.querySelectorAll("optgroup"),
96
+ ).map((group) => group.label)
97
+
98
+ await expect(groups).toEqual(["Video", "Audio"])
99
+ })
100
+
101
+ test("a disabled select is disabled, not merely dimmed", async () => {
102
+ const { canvas, canvasElement } =
103
+ await mountStory(AllStates)
104
+
105
+ await expect(
106
+ expectAgentDrivable(canvas, {
107
+ name: "Profile disabled",
108
+ role: "combobox",
109
+ }),
110
+ ).toBeDisabled()
111
+
112
+ await expectNoAxeViolations(canvasElement)
113
+ })
114
+
115
+ test("inside a Field, the label does the naming", async () => {
116
+ const { canvas } = await mountStory(AllStates)
117
+
118
+ // No `label` prop on this one — `Field` clones an `id` in and its
119
+ // `<label for>` picks it up. Two naming mechanisms that must not
120
+ // both fire, or the accessible name is the wrong one.
121
+ const select = expectAgentDrivable(canvas, {
122
+ name: "Profile in a field",
123
+ role: "combobox",
124
+ })
125
+
126
+ await expect(select).not.toHaveAttribute("aria-label")
127
+
128
+ await expect(select).toHaveAttribute("aria-describedby")
129
+ })
@@ -0,0 +1,200 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type {
3
+ ChangeEvent,
4
+ ComponentPropsWithRef,
5
+ ReactNode,
6
+ } from "react"
7
+
8
+ import {
9
+ CONTROL_SIZE_CLASS,
10
+ MIN_TOUCH_TARGET_CLASS,
11
+ } from "../controlStyles.ts"
12
+ import {
13
+ DISABLED_CLASS,
14
+ FOCUS_RING_CLASS,
15
+ } from "../intentStyles.ts"
16
+ import { toClassName } from "../toClassName.ts"
17
+
18
+ export type SelectOption = {
19
+ isDisabled?: boolean
20
+ label: string
21
+ value: string
22
+ }
23
+
24
+ export type SelectOptionGroup = {
25
+ label: string
26
+ options: SelectOption[]
27
+ }
28
+
29
+ export type SelectItem = SelectOption | SelectOptionGroup
30
+
31
+ /**
32
+ * The HTML attributes are passed through, and that is not
33
+ * convenience — it is what makes the `Field` slot work at all.
34
+ *
35
+ * `Field` clones `id`, `aria-describedby`, `aria-invalid` and
36
+ * `required` onto whatever control it is given. A component with a
37
+ * closed prop list **silently drops all four**: the field renders,
38
+ * the label still points at nothing, and every assertion about the
39
+ * control's own props passes because they never arrived. Found by
40
+ * the one test that asked the control what it had been handed.
41
+ *
42
+ * Same arrangement as `Button`, for the same reason.
43
+ */
44
+ export type SelectProps = Omit<
45
+ ComponentPropsWithRef<"select">,
46
+ "disabled" | "onChange" | "size" | "value"
47
+ > & {
48
+ className?: string
49
+ isDisabled?: boolean
50
+ /**
51
+ * The accessible name, when this is **not** inside a `Field`. A
52
+ * `<select>` with neither a `<label for>` nor an `aria-label` is
53
+ * announced as "combobox" and is unfindable by
54
+ * `getByRole("combobox", { name })` — twelve of the fleet's
55
+ * fourteen native selects are in exactly that state.
56
+ */
57
+ label?: string
58
+ onChange?: (value: string) => void
59
+ options: SelectItem[]
60
+ /**
61
+ * A disabled first entry, not a real option. Reads as a prompt to
62
+ * a sighted user and cannot be submitted, which is what a
63
+ * placeholder should mean — unlike an empty-valued option, which
64
+ * is selectable and silently posts "".
65
+ */
66
+ placeholder?: string
67
+ size?: ControlSize
68
+ /** **Initial** only, like every other value prop in this library. */
69
+ value?: string
70
+ }
71
+
72
+ const getIsGroup = (
73
+ item: SelectItem,
74
+ ): item is SelectOptionGroup => "options" in item
75
+
76
+ /**
77
+ * A styled native `<select>`, and the fleet's fourteen hand-rolled
78
+ * ones collapse onto it.
79
+ *
80
+ * ### There is no state kind here, and that is the finding
81
+ *
82
+ * Every other interactive component in this package owns its state
83
+ * through `@charcuterie/logic`. This one owns none, because the
84
+ * platform already does: a `<select>` holds the chosen option in the
85
+ * DOM, and putting a `useSinglePicker` beside it would create the
86
+ * exact conflict the state layer exists to prevent — two things
87
+ * believing they hold `value`, which is the Radix argument from
88
+ * `Popover` pointed at ourselves. So `value` seeds `defaultValue`
89
+ * and changes are reported outward.
90
+ *
91
+ * The consequence worth knowing: **`Select` is uncontrolled and
92
+ * `Tabs` is not**, and a reader should be able to tell why without
93
+ * asking. A tab bar's selection has no DOM representation, so
94
+ * something has to hold it; a select's does.
95
+ *
96
+ * ### Native, and what that rules out
97
+ *
98
+ * A `<select>` cannot render a rich option — an icon, two lines, a
99
+ * badge — and cannot filter. mux-magic has six controls that need
100
+ * one of those (`CommandPicker`, `PathPicker`, `LinkPicker`,
101
+ * `EnumPicker`, `AssFieldPicker`, `RenameTargetPicker`), and every
102
+ * one of them is a **`Combobox`**: a text input that filters a
103
+ * listbox. That is a different pattern with a different ARIA
104
+ * contract, it is P2 in the plan, and building a bare `Listbox`
105
+ * here that none of the six could adopt would have shipped a
106
+ * component with no caller.
107
+ *
108
+ * What native buys, and what a hand-rolled listbox has to rebuild
109
+ * from nothing: type-ahead, Home/End, PageUp/PageDown, the mobile
110
+ * wheel picker, form submission, `:invalid`, and autofill.
111
+ */
112
+ export const Select = ({
113
+ className,
114
+ isDisabled = false,
115
+ label,
116
+ onChange,
117
+ options,
118
+ placeholder,
119
+ size = "md",
120
+ value,
121
+ ...selectProps
122
+ }: SelectProps): ReactNode => (
123
+ <div className="relative inline-grid w-full items-center">
124
+ <select
125
+ {...selectProps}
126
+ aria-label={label}
127
+ className={toClassName(
128
+ "w-full cursor-pointer appearance-none rounded-md border border-border-default bg-surface-raised text-content-primary transition-colors duration-(--duration-fast) ease-standard",
129
+ // Room for the chevron below, which sits in the same grid
130
+ // cell. A `<select>`'s own arrow is what `appearance-none`
131
+ // removes, and forgetting to put one back leaves a control
132
+ // that looks exactly like a text input.
133
+ "pe-9",
134
+ CONTROL_SIZE_CLASS[size],
135
+ MIN_TOUCH_TARGET_CLASS,
136
+ "hover:border-border-strong",
137
+ FOCUS_RING_CLASS,
138
+ DISABLED_CLASS,
139
+ className,
140
+ )}
141
+ defaultValue={
142
+ value ??
143
+ (placeholder === undefined ? undefined : "")
144
+ }
145
+ disabled={isDisabled}
146
+ onChange={(
147
+ changeEvent: ChangeEvent<HTMLSelectElement>,
148
+ ) => {
149
+ onChange?.(changeEvent.currentTarget.value)
150
+ }}
151
+ >
152
+ {placeholder === undefined ? null : (
153
+ <option disabled value="">
154
+ {placeholder}
155
+ </option>
156
+ )}
157
+
158
+ {options.map((item) =>
159
+ getIsGroup(item) ? (
160
+ <optgroup key={item.label} label={item.label}>
161
+ {item.options.map((option) => (
162
+ <option
163
+ disabled={option.isDisabled}
164
+ key={option.value}
165
+ value={option.value}
166
+ >
167
+ {option.label}
168
+ </option>
169
+ ))}
170
+ </optgroup>
171
+ ) : (
172
+ <option
173
+ disabled={item.isDisabled}
174
+ key={item.value}
175
+ value={item.value}
176
+ >
177
+ {item.label}
178
+ </option>
179
+ ),
180
+ )}
181
+ </select>
182
+
183
+ <svg
184
+ // Decoration. The control beside it already has a name, and an
185
+ // announced "chevron" is noise on every select in the fleet.
186
+ aria-hidden="true"
187
+ className="pointer-events-none absolute end-3 size-4 text-content-secondary"
188
+ fill="none"
189
+ focusable={false}
190
+ stroke="currentColor"
191
+ strokeLinecap="round"
192
+ strokeLinejoin="round"
193
+ strokeWidth={1.75}
194
+ viewBox="0 0 24 24"
195
+ xmlns="http://www.w3.org/2000/svg"
196
+ >
197
+ <path d="m6 9 6 6 6-6" />
198
+ </svg>
199
+ </div>
200
+ )
@@ -0,0 +1,67 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as SortableTableHeaderStories from "./SortableTableHeader.stories";
4
+
5
+ <Meta of={SortableTableHeaderStories} />
6
+
7
+ # SortableTableHeader
8
+
9
+ **The header cell, and nothing else.** No `DataTable`, no rows, no comparator — the plan is
10
+ explicit about that, and the reason is that sorting *data* is app logic that differs per
11
+ column type, while announcing *that* a column is sorted is markup every table gets wrong the
12
+ same way.
13
+
14
+ <Canvas of={SortableTableHeaderStories.Default} />
15
+
16
+ ## Props
17
+
18
+ <Controls />
19
+
20
+ ## `aria-sort` does not exist anywhere in the fleet
21
+
22
+ Not in mux-magic, not in rip-deck, not in castkit. mux-magic's `FileExplorerModal` — the one
23
+ sortable table there is — renders the state as a character:
24
+
25
+ ```tsx
26
+ {sortDirection === "asc" ? "▲" : "▼"}
27
+ ```
28
+
29
+ A screen reader announces that as "black up-pointing triangle", if the font has it at all —
30
+ and this sandbox's headless Chromium does not, so the same glyph measures **blank** in a
31
+ screenshot. Sighted users see a direction; everyone else gets nothing.
32
+
33
+ No automated check anywhere reports it. **axe has no rule for a missing `aria-sort`**,
34
+ because a table with none is a table that is simply not sorted as far as the accessibility
35
+ tree knows.
36
+
37
+ This is therefore the one component in M6 whose payoff is not duplication — there is a
38
+ single site — but a class of failure invisible to every gate the fleet has.
39
+
40
+ ## `none` is not the same as absent
41
+
42
+ | `aria-sort` | means |
43
+ | --- | --- |
44
+ | absent | this column **cannot** be sorted |
45
+ | `"none"` | sortable, not currently sorted |
46
+ | `"ascending"` / `"descending"` | this is the sorted column |
47
+
48
+ A table that omits the attribute on its unsorted columns tells a screen-reader user the other
49
+ four cannot be sorted at all. Exactly one column ever holds a direction.
50
+
51
+ <Canvas of={SortableTableHeaderStories.AllVariants} />
52
+
53
+ ## The cell announces, the button acts
54
+
55
+ `aria-sort` belongs on the `<th>`; the click target is a `<button>` inside it. Putting the
56
+ role on the button instead is the common mistake and it breaks the grid semantics — a `<th>`
57
+ that *is* a button is no longer a column header to anything reading the table structure.
58
+
59
+ ## The name stays the column's name
60
+
61
+ It is tempting to append the state — "Title, sorted ascending" — and that duplicates what
62
+ `aria-sort` already says, so the column is announced twice **and**
63
+ `getByRole("columnheader", { name: "Title" })` stops matching the moment somebody sorts it.
64
+
65
+ <Canvas of={SortableTableHeaderStories.AllStates} />
66
+
67
+ <Canvas of={SortableTableHeaderStories.Interactive} />
@@ -0,0 +1,207 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import { useState } from "react"
3
+
4
+ import {
5
+ StoryCell,
6
+ StoryGrid,
7
+ } from "../board.storyHelpers.tsx"
8
+ import type { SortDirection } from "./SortableTableHeader.tsx"
9
+ import { SortableTableHeader } from "./SortableTableHeader.tsx"
10
+
11
+ const noop = () => undefined
12
+
13
+ const ROWS = [
14
+ { size: "24.1 GB", title: "Blade Runner", year: 1982 },
15
+ { size: "31.7 GB", title: "Alien", year: 1979 },
16
+ { size: "18.2 GB", title: "Solaris", year: 1972 },
17
+ ]
18
+
19
+ const TABLE_CLASS =
20
+ "w-full border-collapse text-content-primary text-sm"
21
+
22
+ const CELL_CLASS =
23
+ "border-border-subtle border-b px-3 py-2 text-start"
24
+
25
+ /**
26
+ * The component owns **only** the header cell, so every story has to
27
+ * bring its own table. That is the point rather than an
28
+ * inconvenience: sorting the data is app logic that differs per
29
+ * column type, and announcing *that* a column is sorted is markup
30
+ * every table gets wrong the same way.
31
+ */
32
+ const meta = {
33
+ title: "Components/SortableTableHeader",
34
+ component: SortableTableHeader,
35
+ parameters: { layout: "padded" },
36
+ args: { onSort: noop },
37
+ } satisfies Meta<typeof SortableTableHeader>
38
+
39
+ export default meta
40
+
41
+ type Story = StoryObj<typeof meta>
42
+
43
+ export const Default: Story = {
44
+ args: { children: "Title", direction: "ascending" },
45
+ render: (args) => (
46
+ <table className={TABLE_CLASS}>
47
+ <thead>
48
+ <tr>
49
+ <SortableTableHeader {...args} />
50
+
51
+ <SortableTableHeader onSort={noop}>
52
+ Year
53
+ </SortableTableHeader>
54
+ </tr>
55
+ </thead>
56
+
57
+ <tbody>
58
+ {ROWS.map((row) => (
59
+ <tr key={row.title}>
60
+ <td className={CELL_CLASS}>{row.title}</td>
61
+
62
+ <td className={CELL_CLASS}>{row.year}</td>
63
+ </tr>
64
+ ))}
65
+ </tbody>
66
+ </table>
67
+ ),
68
+ }
69
+
70
+ /**
71
+ * `aria-sort="none"` on the unsorted columns, not an omitted
72
+ * attribute. Absent means "this column is not sortable"; `none`
73
+ * means "sortable, not currently sorted" — and a table mixing the
74
+ * two tells a screen-reader user the other columns cannot be sorted
75
+ * at all.
76
+ */
77
+ export const AllVariants: Story = {
78
+ args: { children: "Title" },
79
+ render: () => (
80
+ <StoryGrid columns={1}>
81
+ <StoryCell label="ascending · descending · none">
82
+ <table className={TABLE_CLASS}>
83
+ <thead>
84
+ <tr>
85
+ <SortableTableHeader
86
+ direction="ascending"
87
+ onSort={noop}
88
+ >
89
+ Title
90
+ </SortableTableHeader>
91
+
92
+ <SortableTableHeader
93
+ direction="descending"
94
+ onSort={noop}
95
+ >
96
+ Year
97
+ </SortableTableHeader>
98
+
99
+ <SortableTableHeader onSort={noop}>
100
+ Size
101
+ </SortableTableHeader>
102
+ </tr>
103
+ </thead>
104
+
105
+ <tbody>
106
+ {ROWS.map((row) => (
107
+ <tr key={row.title}>
108
+ <td className={CELL_CLASS}>{row.title}</td>
109
+
110
+ <td className={CELL_CLASS}>{row.year}</td>
111
+
112
+ <td className={CELL_CLASS}>{row.size}</td>
113
+ </tr>
114
+ ))}
115
+ </tbody>
116
+ </table>
117
+ </StoryCell>
118
+ </StoryGrid>
119
+ ),
120
+ }
121
+
122
+ export const AllStates: Story = {
123
+ args: { children: "Title", direction: "ascending" },
124
+ render: (args) => (
125
+ <table className={TABLE_CLASS}>
126
+ <thead>
127
+ <tr>
128
+ <SortableTableHeader {...args} />
129
+ </tr>
130
+ </thead>
131
+
132
+ <tbody>
133
+ <tr>
134
+ <td className={CELL_CLASS}>Blade Runner</td>
135
+ </tr>
136
+ </tbody>
137
+ </table>
138
+ ),
139
+ }
140
+
141
+ /**
142
+ * Click a header and watch `aria-sort` move — exactly one column
143
+ * ever holds a direction, which is the invariant a hand-rolled
144
+ * `▲`/`▼` cannot express at all.
145
+ */
146
+ export const Interactive: Story = {
147
+ args: { children: "Title" },
148
+ render: function SortableTable() {
149
+ const [sort, setSort] = useState<{
150
+ column: "title" | "year"
151
+ direction: SortDirection
152
+ }>({ column: "title", direction: "ascending" })
153
+
154
+ const sorted = [...ROWS].sort((first, second) => {
155
+ const order =
156
+ sort.column === "title"
157
+ ? first.title.localeCompare(second.title)
158
+ : first.year - second.year
159
+
160
+ return sort.direction === "ascending" ? order : -order
161
+ })
162
+
163
+ return (
164
+ <table className={TABLE_CLASS}>
165
+ <thead>
166
+ <tr>
167
+ <SortableTableHeader
168
+ direction={
169
+ sort.column === "title"
170
+ ? sort.direction
171
+ : undefined
172
+ }
173
+ onSort={(direction) => {
174
+ setSort({ column: "title", direction })
175
+ }}
176
+ >
177
+ Title
178
+ </SortableTableHeader>
179
+
180
+ <SortableTableHeader
181
+ direction={
182
+ sort.column === "year"
183
+ ? sort.direction
184
+ : undefined
185
+ }
186
+ onSort={(direction) => {
187
+ setSort({ column: "year", direction })
188
+ }}
189
+ >
190
+ Year
191
+ </SortableTableHeader>
192
+ </tr>
193
+ </thead>
194
+
195
+ <tbody>
196
+ {sorted.map((row) => (
197
+ <tr key={row.title}>
198
+ <td className={CELL_CLASS}>{row.title}</td>
199
+
200
+ <td className={CELL_CLASS}>{row.year}</td>
201
+ </tr>
202
+ ))}
203
+ </tbody>
204
+ </table>
205
+ )
206
+ },
207
+ }