@charcuterie/ui 0.1.1 → 0.2.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 (105) 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 +69 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +73 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  15. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  16. package/dist/FileDropZone/FileDropZone.js +122 -0
  17. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  18. package/dist/LogViewer/LogViewer.d.ts +88 -0
  19. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  20. package/dist/LogViewer/LogViewer.js +104 -0
  21. package/dist/LogViewer/LogViewer.js.map +1 -0
  22. package/dist/Menu/Menu.d.ts +81 -0
  23. package/dist/Menu/Menu.d.ts.map +1 -0
  24. package/dist/Menu/Menu.js +199 -0
  25. package/dist/Menu/Menu.js.map +1 -0
  26. package/dist/Menu/MenuAction.d.ts +24 -0
  27. package/dist/Menu/MenuAction.d.ts.map +1 -0
  28. package/dist/Menu/MenuAction.js +37 -0
  29. package/dist/Menu/MenuAction.js.map +1 -0
  30. package/dist/Select/Select.d.ts +87 -0
  31. package/dist/Select/Select.d.ts.map +1 -0
  32. package/dist/Select/Select.js +51 -0
  33. package/dist/Select/Select.js.map +1 -0
  34. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  36. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  38. package/dist/Toast/Toast.d.ts +54 -0
  39. package/dist/Toast/Toast.d.ts.map +1 -0
  40. package/dist/Toast/Toast.js +121 -0
  41. package/dist/Toast/Toast.js.map +1 -0
  42. package/dist/Toast/ToastRegion.d.ts +57 -0
  43. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  44. package/dist/Toast/ToastRegion.js +41 -0
  45. package/dist/Toast/ToastRegion.js.map +1 -0
  46. package/dist/Toast/toastLifecycle.d.ts +34 -0
  47. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  48. package/dist/Toast/toastLifecycle.js +25 -0
  49. package/dist/Toast/toastLifecycle.js.map +1 -0
  50. package/dist/Tooltip/Tooltip.d.ts +68 -0
  51. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  52. package/dist/Tooltip/Tooltip.js +104 -0
  53. package/dist/Tooltip/Tooltip.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/mountStory.testHelpers.d.ts +0 -11
  59. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  60. package/dist/mountStory.testHelpers.js +27 -0
  61. package/dist/mountStory.testHelpers.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/Accordion/Accordion.mdx +83 -0
  64. package/src/Accordion/Accordion.stories.tsx +146 -0
  65. package/src/Accordion/Accordion.test.tsx +163 -0
  66. package/src/Accordion/Accordion.tsx +182 -0
  67. package/src/Accordion/AccordionSection.tsx +163 -0
  68. package/src/Field/Field.mdx +74 -0
  69. package/src/Field/Field.stories.tsx +144 -0
  70. package/src/Field/Field.test.tsx +142 -0
  71. package/src/Field/Field.tsx +163 -0
  72. package/src/FileDropZone/FileDropZone.mdx +78 -0
  73. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  74. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  75. package/src/FileDropZone/FileDropZone.tsx +234 -0
  76. package/src/LogViewer/LogViewer.mdx +88 -0
  77. package/src/LogViewer/LogViewer.stories.tsx +135 -0
  78. package/src/LogViewer/LogViewer.test.tsx +173 -0
  79. package/src/LogViewer/LogViewer.tsx +195 -0
  80. package/src/Menu/Menu.mdx +103 -0
  81. package/src/Menu/Menu.stories.tsx +161 -0
  82. package/src/Menu/Menu.test.tsx +202 -0
  83. package/src/Menu/Menu.tsx +300 -0
  84. package/src/Menu/MenuAction.tsx +95 -0
  85. package/src/Select/Select.mdx +74 -0
  86. package/src/Select/Select.stories.tsx +126 -0
  87. package/src/Select/Select.test.tsx +129 -0
  88. package/src/Select/Select.tsx +200 -0
  89. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  90. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  91. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  92. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  93. package/src/Toast/Toast.mdx +84 -0
  94. package/src/Toast/Toast.stories.tsx +192 -0
  95. package/src/Toast/Toast.test.tsx +217 -0
  96. package/src/Toast/Toast.tsx +224 -0
  97. package/src/Toast/ToastRegion.tsx +98 -0
  98. package/src/Toast/toastLifecycle.ts +45 -0
  99. package/src/Tooltip/Tooltip.mdx +69 -0
  100. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  101. package/src/Tooltip/Tooltip.test.tsx +138 -0
  102. package/src/Tooltip/Tooltip.tsx +169 -0
  103. package/src/index.ts +43 -0
  104. package/src/mountStory.testHelpers.ts +30 -0
  105. package/src/sourceRules.test.ts +221 -30
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } 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 "./SortableTableHeader.stories.tsx"
9
+
10
+ const { AllVariants, Default, Interactive } =
11
+ composeStories(stories)
12
+
13
+ /**
14
+ * `aria-sort` exists **nowhere in the fleet** — not in mux-magic,
15
+ * not in rip-deck, not in castkit. The one sortable table renders
16
+ * the state as `{sortDirection === "asc" ? "▲" : "▼"}`, which a
17
+ * screen reader announces as "black up-pointing triangle" if the
18
+ * font has it at all, and this sandbox's headless Chromium does not.
19
+ */
20
+ test("the cell announces the sort, not the glyph", async () => {
21
+ const { canvas, canvasElement } =
22
+ await mountStory(Default)
23
+
24
+ const title = expectAgentDrivable(canvas, {
25
+ name: "Title",
26
+ role: "columnheader",
27
+ })
28
+
29
+ await expect(title).toHaveAttribute(
30
+ "aria-sort",
31
+ "ascending",
32
+ )
33
+
34
+ await expectNoAxeViolations(canvasElement)
35
+ })
36
+
37
+ /**
38
+ * `none`, not an omitted attribute. Absent means "this column is not
39
+ * sortable"; `none` means "sortable, not currently sorted" — and a
40
+ * table mixing the two tells a screen-reader user the other columns
41
+ * cannot be sorted at all.
42
+ */
43
+ test("an unsorted sortable column says `none`", async () => {
44
+ const { canvas, canvasElement } =
45
+ await mountStory(AllVariants)
46
+
47
+ await expect(
48
+ expectAgentDrivable(canvas, {
49
+ name: "Size",
50
+ role: "columnheader",
51
+ }),
52
+ ).toHaveAttribute("aria-sort", "none")
53
+
54
+ await expect(
55
+ expectAgentDrivable(canvas, {
56
+ name: "Year",
57
+ role: "columnheader",
58
+ }),
59
+ ).toHaveAttribute("aria-sort", "descending")
60
+
61
+ await expectNoAxeViolations(canvasElement)
62
+ })
63
+
64
+ /**
65
+ * The cell announces and the button acts. Putting the role on the
66
+ * button instead is the common mistake, and it breaks the grid
67
+ * semantics — a `<th>` that is a button is no longer a column header
68
+ * to anything reading the table's structure.
69
+ */
70
+ test("the header is a columnheader containing a button", async () => {
71
+ const { canvas } = await mountStory(Default)
72
+
73
+ const header = expectAgentDrivable(canvas, {
74
+ name: "Title",
75
+ role: "columnheader",
76
+ })
77
+
78
+ await expect(header.tagName).toBe("TH")
79
+
80
+ await expect(header).toHaveAttribute("scope", "col")
81
+
82
+ const button = expectAgentDrivable(canvas, {
83
+ name: "Title",
84
+ role: "button",
85
+ })
86
+
87
+ await expect(header).toContainElement(button)
88
+ })
89
+
90
+ test("clicking an unsorted column asks for ascending", async () => {
91
+ const { canvas } = await mountStory(Interactive)
92
+
93
+ await userEvent.click(
94
+ expectAgentDrivable(canvas, {
95
+ name: "Year",
96
+ role: "button",
97
+ }),
98
+ )
99
+
100
+ await waitFor(() => {
101
+ expect(
102
+ expectAgentDrivable(canvas, {
103
+ name: "Year",
104
+ role: "columnheader",
105
+ }),
106
+ ).toHaveAttribute("aria-sort", "ascending")
107
+ })
108
+
109
+ // Exactly one column ever holds a direction — the invariant a
110
+ // hand-rolled `▲`/`▼` cannot express at all.
111
+ await expect(
112
+ expectAgentDrivable(canvas, {
113
+ name: "Title",
114
+ role: "columnheader",
115
+ }),
116
+ ).toHaveAttribute("aria-sort", "none")
117
+ })
118
+
119
+ test("clicking the sorted column reverses it", async () => {
120
+ const { canvas } = await mountStory(Interactive)
121
+
122
+ const title = expectAgentDrivable(canvas, {
123
+ name: "Title",
124
+ role: "button",
125
+ })
126
+
127
+ await userEvent.click(title)
128
+
129
+ await waitFor(() => {
130
+ expect(
131
+ expectAgentDrivable(canvas, {
132
+ name: "Title",
133
+ role: "columnheader",
134
+ }),
135
+ ).toHaveAttribute("aria-sort", "descending")
136
+ })
137
+
138
+ await userEvent.click(title)
139
+
140
+ await waitFor(() => {
141
+ expect(
142
+ expectAgentDrivable(canvas, {
143
+ name: "Title",
144
+ role: "columnheader",
145
+ }),
146
+ ).toHaveAttribute("aria-sort", "ascending")
147
+ })
148
+ })
149
+
150
+ /**
151
+ * The name stays the column's name. Appending the state —
152
+ * "Title, sorted ascending" — duplicates what `aria-sort` already
153
+ * says *and* breaks `getByRole("columnheader", { name: "Title" })`
154
+ * the moment somebody sorts it.
155
+ */
156
+ test("sorting does not rename the column", async () => {
157
+ const { canvas } = await mountStory(Interactive)
158
+
159
+ await userEvent.click(
160
+ expectAgentDrivable(canvas, {
161
+ name: "Title",
162
+ role: "button",
163
+ }),
164
+ )
165
+
166
+ await waitFor(() => {
167
+ expect(
168
+ expectAgentDrivable(canvas, {
169
+ name: "Title",
170
+ role: "columnheader",
171
+ }),
172
+ ).toHaveAttribute("aria-sort", "descending")
173
+ })
174
+
175
+ // Still exactly one match, and still under the same name.
176
+ expectAgentDrivable(canvas, {
177
+ name: "Title",
178
+ role: "columnheader",
179
+ })
180
+ })
@@ -0,0 +1,131 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
4
+ import { toClassName } from "../toClassName.ts"
5
+
6
+ export type SortDirection = "ascending" | "descending"
7
+
8
+ export type SortableTableHeaderProps = {
9
+ children: ReactNode
10
+ className?: string
11
+ /**
12
+ * The direction **this column** is sorted in, or `undefined` when
13
+ * it is not the sorted one. Undefined is not "ascending by
14
+ * default": a table has exactly one sorted column and every other
15
+ * header has to say `aria-sort="none"`, which is what tells a
16
+ * screen reader which one is live.
17
+ */
18
+ direction?: SortDirection
19
+ /** Called with the direction the header is asking to move to. */
20
+ onSort: (direction: SortDirection) => void
21
+ }
22
+
23
+ const NEXT_DIRECTION: Record<SortDirection, SortDirection> =
24
+ {
25
+ ascending: "descending",
26
+ descending: "ascending",
27
+ }
28
+
29
+ /**
30
+ * **The header cell, and nothing else.** No `DataTable`, no rows, no
31
+ * comparator — the plan is explicit about that, and the reason is
32
+ * that sorting *data* is app logic that differs per column type,
33
+ * while announcing *that* a column is sorted is markup every table
34
+ * gets wrong the same way.
35
+ *
36
+ * ### `aria-sort` does not exist anywhere in the fleet
37
+ *
38
+ * Not in mux-magic, not in rip-deck, not in castkit. mux-magic's
39
+ * `FileExplorerModal` — the one sortable table there is — renders
40
+ * the state as a character:
41
+ *
42
+ * ```tsx
43
+ * {sortDirection === "asc" ? "▲" : "▼"}
44
+ * ```
45
+ *
46
+ * A screen reader announces that as "black up-pointing triangle", if
47
+ * the font has it at all, and the sandbox's headless Chromium does
48
+ * not have it — so the same glyph measures blank in a screenshot.
49
+ * Sighted users see a direction, everyone else gets nothing, and no
50
+ * automated check anywhere reports it: axe has no rule for a missing
51
+ * `aria-sort`, because a table with none is a table that is simply
52
+ * not sorted as far as the accessibility tree knows.
53
+ *
54
+ * This is therefore the one component in M6 whose payoff is not
55
+ * duplication — there is a single site — but a class of failure
56
+ * that is invisible to every gate the fleet has.
57
+ *
58
+ * ### The cell announces, the button acts
59
+ *
60
+ * `aria-sort` belongs on the `<th>`; the click target is a `<button>`
61
+ * inside it. Putting the role on the button instead is the common
62
+ * mistake and it breaks the grid semantics — a `<th>` that is a
63
+ * button is no longer a column header to anything reading the table
64
+ * structure.
65
+ *
66
+ * The name stays the column's name. It is tempting to append the
67
+ * state — "Name, sorted ascending" — and that duplicates what
68
+ * `aria-sort` already says, so the column is announced twice and
69
+ * `getByRole("columnheader", { name: "Name" })` stops matching the
70
+ * moment somebody sorts it.
71
+ */
72
+ export const SortableTableHeader = ({
73
+ children,
74
+ className,
75
+ direction,
76
+ onSort,
77
+ }: SortableTableHeaderProps): ReactNode => (
78
+ <th
79
+ // `none`, not omitted, on the unsorted columns. An absent
80
+ // `aria-sort` means "this column is not sortable"; `none` means
81
+ // "sortable, not currently sorted", and a table of the first
82
+ // kind with one of the second is how a screen-reader user is
83
+ // told the other four columns cannot be sorted at all.
84
+ aria-sort={direction ?? "none"}
85
+ className={toClassName(
86
+ "border-border-subtle border-b p-0 text-start",
87
+ className,
88
+ )}
89
+ scope="col"
90
+ >
91
+ <button
92
+ className={toClassName(
93
+ "flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-3 py-2 text-start font-medium text-content-secondary text-xs uppercase tracking-wide transition-colors duration-(--duration-fast) ease-standard",
94
+ "hover:text-content-primary",
95
+ FOCUS_RING_CLASS,
96
+ )}
97
+ onClick={() => {
98
+ onSort(
99
+ direction === undefined
100
+ ? "ascending"
101
+ : NEXT_DIRECTION[direction],
102
+ )
103
+ }}
104
+ type="button"
105
+ >
106
+ {children}
107
+
108
+ <svg
109
+ // Decoration. `aria-sort` on the cell above is the same fact
110
+ // said in a way assistive technology can read, and the fleet
111
+ // ships only this half.
112
+ aria-hidden="true"
113
+ className={toClassName(
114
+ "size-3.5 shrink-0 transition-[transform,opacity] duration-(--duration-fast) ease-standard",
115
+ direction === undefined && "opacity-0",
116
+ direction === "descending" && "rotate-180",
117
+ )}
118
+ fill="none"
119
+ focusable={false}
120
+ stroke="currentColor"
121
+ strokeLinecap="round"
122
+ strokeLinejoin="round"
123
+ strokeWidth={2}
124
+ viewBox="0 0 24 24"
125
+ xmlns="http://www.w3.org/2000/svg"
126
+ >
127
+ <path d="m6 15 6-6 6 6" />
128
+ </svg>
129
+ </button>
130
+ </th>
131
+ )
@@ -0,0 +1,84 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ToastStories from "./Toast.stories";
4
+
5
+ <Meta of={ToastStories} />
6
+
7
+ # Toast
8
+
9
+ The plan demoted this from P0 with the note *"its duplication evidence was almost entirely
10
+ those two"*, and the M6 survey confirmed it: **the React fleet has no toast at all.**
11
+ mux-magic's only hit is a comment about an undo-toast "rendered elsewhere"; the real ones
12
+ are in plex-channels' and darius-learns' vanilla JavaScript.
13
+
14
+ It ships anyway, on Kevin's call, and one thing here is genuinely new — the plan wrote a
15
+ machine down and M2 never built it:
16
+
17
+ > | Toast lifecycle | entering → visible → exiting → removed |
18
+
19
+ <Canvas of={ToastStories.Default} />
20
+
21
+ ## Props
22
+
23
+ <Controls />
24
+
25
+ ## Why a `Status`, when a boolean and a timeout would do
26
+
27
+ Because a boolean cannot express **why** the node is still mounted. A toast that is leaving
28
+ is still painted, still occupies its slot, and must not be announced again — and
29
+ `isVisible === false` says none of that.
30
+
31
+ | Transition | Legal | Why |
32
+ | --- | --- | --- |
33
+ | `entering → visible` | yes | after one frame, so the enter animation has something to animate *from* |
34
+ | `visible → exiting` | yes | the duration elapsed, or the user dismissed |
35
+ | `visible → removed` | **no** | skipping the exit is the flicker every hand-rolled stack has |
36
+ | `exiting → visible` | yes | hovering a toast that is sliding away has to bring it back |
37
+ | `removed → anything` | **no** | terminal, so `onDismiss` fires exactly once |
38
+
39
+ The last row is not hypothetical. Hover-to-pause plus a dismiss button means two independent
40
+ timers can be in flight, and the hand-rolled version reconciles them with a `clearTimeout`
41
+ and a ref. Here the second one **throws**.
42
+
43
+ <Canvas of={ToastStories.AllVariants} />
44
+
45
+ ## One region, and it is polite
46
+
47
+ Not two. The usual arrangement — a polite region and an assertive one, so a danger toast
48
+ interrupts — was rejected because **the interrupting case does not belong in a toast at
49
+ all.** A message that must be heard *now* is one the user has to be able to go back and
50
+ read, and a toast is gone in five seconds. If it matters that much it is an `Alert` on the
51
+ page, which this library already ships and which stays put.
52
+
53
+ `Field` draws the same line for its error text. This library announces **once**, in the
54
+ place the user can act on it.
55
+
56
+ `role="status"` is `aria-live="polite"` *plus `aria-atomic="true"`* by default, and atomic is
57
+ wrong here — it re-reads the entire stack every time one arrives. `aria-atomic="false"` sits
58
+ beside it for exactly that reason.
59
+
60
+ <Canvas of={ToastStories.AllStates} />
61
+
62
+ ## The stack does not own the toasts
63
+
64
+ `toasts` comes in and `onDismiss` goes out. There is no `toast()` function, no imperative
65
+ queue, and no context provider — an app already has a store (mux-magic has Jotai, rip-deck
66
+ has its own), and a component library that ships a second one is asking every consumer to
67
+ keep two lists of the same notifications in sync.
68
+
69
+ The lifecycle *inside* one toast is state a store cannot see, which is why `Toast` owns that
70
+ and nothing more.
71
+
72
+ ## `title` is a string
73
+
74
+ Not a `ReactNode`. It is what the region announces and what names each toast's own dismiss
75
+ button — a stack of three all offering "Dismiss" is three matches for one query, which
76
+ `expectAgentDrivable` treats as a failure.
77
+
78
+ ## `pointer-events-none` on the container
79
+
80
+ A fixed overlay across the bottom of the viewport swallows every click in that band,
81
+ including on the app behind it, and it does so invisibly. The container passes clicks through
82
+ and each toast takes them back.
83
+
84
+ <Canvas of={ToastStories.Interactive} />