@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,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} />
@@ -0,0 +1,192 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+
5
+ import { intentArgType } from "../argTypes.storyHelpers.ts"
6
+ import { Button } from "../Button/Button.tsx"
7
+ import {
8
+ StoryCell,
9
+ StoryGrid,
10
+ } from "../board.storyHelpers.tsx"
11
+ import { Toast } from "./Toast.tsx"
12
+ import type { ToastRecord } from "./ToastRegion.tsx"
13
+ import { ToastRegion } from "./ToastRegion.tsx"
14
+
15
+ const noop = () => undefined
16
+
17
+ /**
18
+ * `duration={0}` throughout the boards. A toast that removes itself
19
+ * after five seconds makes a screenshot a race, and pinning it open
20
+ * is the documented way to hold one for an action anyway.
21
+ */
22
+ const meta = {
23
+ title: "Components/Toast",
24
+ component: Toast,
25
+ parameters: { layout: "padded" },
26
+ argTypes: { intent: intentArgType },
27
+ args: {
28
+ duration: 0,
29
+ intent: "neutral",
30
+ onRemove: noop,
31
+ title: "Rip finished",
32
+ },
33
+ } satisfies Meta<typeof Toast>
34
+
35
+ export default meta
36
+
37
+ type Story = StoryObj<typeof meta>
38
+
39
+ const ToastList = ({
40
+ children,
41
+ }: {
42
+ children: ReactNode
43
+ }): ReactNode => (
44
+ <ul className="flex w-full max-w-sm list-none flex-col gap-2 p-0">
45
+ {children}
46
+ </ul>
47
+ )
48
+
49
+ export const Default: Story = {
50
+ args: {
51
+ children: "Blade Runner (1982) · 9 titles.",
52
+ title: "Rip finished",
53
+ },
54
+ render: (args) => (
55
+ <ToastList>
56
+ <Toast {...args} />
57
+ </ToastList>
58
+ ),
59
+ }
60
+
61
+ export const AllVariants: Story = {
62
+ args: { title: "Rip finished" },
63
+ render: () => (
64
+ <StoryGrid columns={2}>
65
+ <StoryCell label="success">
66
+ <ToastList>
67
+ <Toast
68
+ duration={0}
69
+ intent="success"
70
+ onRemove={noop}
71
+ title="Rip finished"
72
+ >
73
+ Blade Runner (1982) · 9 titles.
74
+ </Toast>
75
+ </ToastList>
76
+ </StoryCell>
77
+
78
+ <StoryCell label="warning">
79
+ <ToastList>
80
+ <Toast
81
+ duration={0}
82
+ intent="warning"
83
+ onRemove={noop}
84
+ title="Retried title 4"
85
+ >
86
+ Two read errors, recovered.
87
+ </Toast>
88
+ </ToastList>
89
+ </StoryCell>
90
+
91
+ <StoryCell label="danger">
92
+ <ToastList>
93
+ <Toast
94
+ duration={0}
95
+ intent="danger"
96
+ onRemove={noop}
97
+ title="Bay 3 offline"
98
+ />
99
+ </ToastList>
100
+ </StoryCell>
101
+
102
+ <StoryCell label="no description">
103
+ <ToastList>
104
+ <Toast
105
+ duration={0}
106
+ intent="info"
107
+ onRemove={noop}
108
+ title="Order saved"
109
+ />
110
+ </ToastList>
111
+ </StoryCell>
112
+ </StoryGrid>
113
+ ),
114
+ }
115
+
116
+ /**
117
+ * The lifecycle, laid out. `entering` and `exiting` are both
118
+ * *painted* states — a toast that is leaving still occupies its
119
+ * slot — which is exactly what a boolean cannot express and why
120
+ * this is a `Status`.
121
+ */
122
+ export const AllStates: Story = {
123
+ args: { title: "Rip finished" },
124
+ render: () => (
125
+ <ToastList>
126
+ <Toast
127
+ duration={0}
128
+ intent="success"
129
+ onRemove={noop}
130
+ title="Pinned open"
131
+ >
132
+ `duration={0}` — stays until dismissed, which is
133
+ what a toast carrying an action has to do.
134
+ </Toast>
135
+
136
+ <Toast
137
+ duration={60_000}
138
+ intent="info"
139
+ onRemove={noop}
140
+ title="Timed"
141
+ >
142
+ Hovering pauses it, which is why `exiting → visible`
143
+ is a legal transition.
144
+ </Toast>
145
+ </ToastList>
146
+ ),
147
+ }
148
+
149
+ /**
150
+ * The region, doing its actual job. Add a few and watch them stack;
151
+ * each removes itself when its own machine reaches `removed`, and
152
+ * the list belongs to the app rather than to this library.
153
+ */
154
+ export const Interactive: Story = {
155
+ args: { title: "Rip finished" },
156
+ render: function InteractiveRegion() {
157
+ const [toasts, setToasts] = useState<ToastRecord[]>([])
158
+
159
+ const [count, setCount] = useState(0)
160
+
161
+ return (
162
+ <>
163
+ <Button
164
+ onClick={() => {
165
+ setCount((previous) => previous + 1)
166
+
167
+ setToasts((previous) => [
168
+ ...previous,
169
+ {
170
+ description: `Bay ${count + 1} finished.`,
171
+ intent: "success",
172
+ key: `rip-${count}`,
173
+ title: `Rip ${count + 1} finished`,
174
+ },
175
+ ])
176
+ }}
177
+ >
178
+ Finish a rip
179
+ </Button>
180
+
181
+ <ToastRegion
182
+ onDismiss={(key) => {
183
+ setToasts((previous) =>
184
+ previous.filter((one) => one.key !== key),
185
+ )
186
+ }}
187
+ toasts={toasts}
188
+ />
189
+ </>
190
+ )
191
+ },
192
+ }