@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,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
+ }
@@ -0,0 +1,217 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test, expect as vitestExpect } 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 "./Toast.stories.tsx"
9
+ import { toastTransitions } from "./toastLifecycle.ts"
10
+
11
+ const { AllStates, AllVariants, Default, Interactive } =
12
+ composeStories(stories)
13
+
14
+ /**
15
+ * The illegal transitions are the specification, so they are
16
+ * asserted as a property of the table rather than by trying to drive
17
+ * a component into a state it refuses to enter.
18
+ */
19
+ test("a toast cannot skip its exit, and `removed` is terminal", () => {
20
+ // Skipping straight to `removed` is the flicker every hand-rolled
21
+ // toast stack has: the node disappears mid-animation.
22
+ vitestExpect(toastTransitions.visible).not.toContain(
23
+ "removed",
24
+ )
25
+
26
+ // Terminal, which is what lets the region call `onDismiss`
27
+ // exactly once. `createStatus` throws on an illegal transition, so
28
+ // a second timer firing after the first is a loud failure rather
29
+ // than a duplicate removal.
30
+ vitestExpect(toastTransitions.removed).toEqual([])
31
+
32
+ // And the one that looks like an oddity until you know why:
33
+ // pointing at a toast that is already sliding away has to bring
34
+ // it back, or the user is chasing a disappearing message.
35
+ vitestExpect(toastTransitions.exiting).toContain(
36
+ "visible",
37
+ )
38
+ })
39
+
40
+ test("the region names itself, and does not re-read the stack", async () => {
41
+ const { canvas, canvasElement } =
42
+ await mountStory(Interactive)
43
+
44
+ await userEvent.click(
45
+ expectAgentDrivable(canvas, {
46
+ name: "Finish a rip",
47
+ role: "button",
48
+ }),
49
+ )
50
+
51
+ const region = expectAgentDrivable(canvas, {
52
+ name: "Notifications",
53
+ role: "status",
54
+ })
55
+
56
+ // `role="status"` is polite *and atomic by default*, and atomic is
57
+ // wrong here — it re-reads the entire stack every time one
58
+ // arrives. Spelling `false` beside it is what makes a second toast
59
+ // announce only itself.
60
+ await expect(region).toHaveAttribute(
61
+ "aria-atomic",
62
+ "false",
63
+ )
64
+
65
+ await expectNoAxeViolations(canvasElement)
66
+ })
67
+
68
+ test("each toast's dismiss button is named for its own toast", async () => {
69
+ const { canvas, canvasElement } =
70
+ await mountStory(AllStates)
71
+
72
+ // A stack of three all offering "Dismiss" gives one query three
73
+ // matches, which `expectAgentDrivable` treats as a failure —
74
+ // correctly, since an agent would be picking one at random. This
75
+ // is why `title` is a string rather than a `ReactNode`.
76
+ expectAgentDrivable(canvas, {
77
+ name: "Dismiss Pinned open",
78
+ role: "button",
79
+ })
80
+
81
+ expectAgentDrivable(canvas, {
82
+ name: "Dismiss Timed",
83
+ role: "button",
84
+ })
85
+
86
+ await expectNoAxeViolations(canvasElement)
87
+ })
88
+
89
+ test("dismissing runs the exit and then removes, once", async () => {
90
+ const { canvas } = await mountStory(Interactive)
91
+
92
+ await userEvent.click(
93
+ expectAgentDrivable(canvas, {
94
+ name: "Finish a rip",
95
+ role: "button",
96
+ }),
97
+ )
98
+
99
+ const dismiss = await waitFor(() =>
100
+ expectAgentDrivable(canvas, {
101
+ name: "Dismiss Rip 1 finished",
102
+ role: "button",
103
+ }),
104
+ )
105
+
106
+ await userEvent.click(dismiss)
107
+
108
+ // It leaves through `exiting`, so it is still in the DOM for the
109
+ // exit duration — and then gone. `onDismiss` firing twice would
110
+ // throw inside `createStatus` rather than removing a second toast.
111
+ await waitFor(() => {
112
+ expect(
113
+ canvas.queryAllByRole("button", {
114
+ name: "Dismiss Rip 1 finished",
115
+ }),
116
+ ).toHaveLength(0)
117
+ })
118
+ })
119
+
120
+ /**
121
+ * The enter frame is scheduled once and never cancelled — `is` and
122
+ * `transitionTo` are stable, so the effect that schedules it does not
123
+ * re-run when the status changes. Dismissing before that frame lands
124
+ * is therefore a real race, and it lost: `exiting → visible` is legal
125
+ * (hover-to-pause needs it), so the frame put the dismissed toast
126
+ * back and restarted its five-second duration.
127
+ *
128
+ * Holding the frame is what makes that deterministic. Left to real
129
+ * timing it reproduces only under load, which is how it first
130
+ * appeared — as a different test failing, once, in a full run.
131
+ */
132
+ test("a dismiss during the enter frame is not undone by it", async () => {
133
+ const heldFrames: FrameRequestCallback[] = []
134
+
135
+ const realRequestAnimationFrame =
136
+ globalThis.requestAnimationFrame
137
+
138
+ globalThis.requestAnimationFrame = ((
139
+ callback: FrameRequestCallback,
140
+ ) => {
141
+ heldFrames.push(callback)
142
+
143
+ return 0
144
+ }) as typeof globalThis.requestAnimationFrame
145
+
146
+ const { canvas } = await mountStory(Interactive)
147
+
148
+ try {
149
+ await userEvent.click(
150
+ expectAgentDrivable(canvas, {
151
+ name: "Finish a rip",
152
+ role: "button",
153
+ }),
154
+ )
155
+
156
+ const dismiss = await waitFor(() =>
157
+ expectAgentDrivable(canvas, {
158
+ name: "Dismiss Rip 1 finished",
159
+ role: "button",
160
+ }),
161
+ )
162
+
163
+ await userEvent.click(dismiss)
164
+
165
+ for (const frame of heldFrames.splice(0)) {
166
+ frame(globalThis.performance.now())
167
+ }
168
+ } finally {
169
+ globalThis.requestAnimationFrame =
170
+ realRequestAnimationFrame
171
+ }
172
+
173
+ await waitFor(() => {
174
+ expect(
175
+ canvas.queryAllByRole("button", {
176
+ name: "Dismiss Rip 1 finished",
177
+ }),
178
+ ).toHaveLength(0)
179
+ })
180
+ })
181
+
182
+ test("a pinned toast stays put", async () => {
183
+ const { canvas } = await mountStory(Default)
184
+
185
+ const dismiss = expectAgentDrivable(canvas, {
186
+ name: "Dismiss Rip finished",
187
+ role: "button",
188
+ })
189
+
190
+ // `duration={0}` is WCAG 2.2.1's answer for a toast carrying an
191
+ // action: a moving deadline the user cannot meet is not a
192
+ // deadline, it is a disappearing message.
193
+ await new Promise((resolve) => {
194
+ globalThis.setTimeout(resolve, 300)
195
+ })
196
+
197
+ await expect(dismiss).toBeInTheDocument()
198
+ })
199
+
200
+ test("intent is a semantic role, not a colour", async () => {
201
+ const { canvas, canvasElement } =
202
+ await mountStory(AllVariants)
203
+
204
+ // Four toasts, four intents, four distinct names — the board is
205
+ // one proposition (every intent renders) rather than four
206
+ // assertions, which is the distinction M4's review settled.
207
+ for (const name of [
208
+ "Dismiss Rip finished",
209
+ "Dismiss Retried title 4",
210
+ "Dismiss Bay 3 offline",
211
+ "Dismiss Order saved",
212
+ ]) {
213
+ expectAgentDrivable(canvas, { name, role: "button" })
214
+ }
215
+
216
+ await expectNoAxeViolations(canvasElement)
217
+ })
@@ -0,0 +1,224 @@
1
+ import { useStatus } from "@charcuterie/logic"
2
+ import type { IntentName } from "@charcuterie/tokens"
3
+ import type { ReactNode } from "react"
4
+ import { useEffect, useRef } from "react"
5
+
6
+ import { IconButton } from "../IconButton/IconButton.tsx"
7
+ import {
8
+ INTENT_APPEARANCE_CLASS,
9
+ INTENT_CONTENT_CLASS,
10
+ } from "../intentStyles.ts"
11
+ import { toClassName } from "../toClassName.ts"
12
+ import type { ToastStatus } from "./toastLifecycle.ts"
13
+ import { toastTransitions } from "./toastLifecycle.ts"
14
+
15
+ export type ToastProps = {
16
+ /** Detail under the title. Optional — many toasts are one line. */
17
+ children?: ReactNode
18
+ className?: string
19
+ /**
20
+ * Milliseconds at `visible` before it starts leaving. `0` pins it
21
+ * open, which is what a toast carrying an action has to do — WCAG
22
+ * 2.2.1 gives a user the right to finish reading.
23
+ */
24
+ duration?: number
25
+ /** How long `exiting` lasts, matched to the transition below. */
26
+ exitDuration?: number
27
+ intent?: IntentName
28
+ /** Called once, when the machine reaches its terminal state. */
29
+ onRemove: () => void
30
+ /**
31
+ * A string, not a `ReactNode`. It is what the region announces and
32
+ * what names this toast's own dismiss button — a stack of three
33
+ * toasts all offering "Dismiss" is three matches for one query,
34
+ * which `expectAgentDrivable` treats as a failure.
35
+ */
36
+ title: string
37
+ }
38
+
39
+ const ENTER_CLASS: Record<ToastStatus, string> = {
40
+ entering: "translate-y-2 opacity-0",
41
+ exiting: "translate-y-1 opacity-0",
42
+ removed: "opacity-0",
43
+ visible: "translate-y-0 opacity-100",
44
+ }
45
+
46
+ /**
47
+ * One toast, and its whole job is the four-state machine the plan
48
+ * specified and M2 left unbuilt.
49
+ *
50
+ * ### Why a `Status`, when a boolean and a timeout would do
51
+ *
52
+ * Because a boolean cannot express *why* the node is still mounted.
53
+ * A toast that is leaving is still painted, still occupies its slot,
54
+ * and must not be announced again — and `isVisible === false` says
55
+ * none of that. The states are what a reader needs to know the
56
+ * difference between "arriving", "leaving", and "gone but not yet
57
+ * unmounted", and `createStatus` makes the illegal ones throw:
58
+ * `visible → removed` skips the exit and is rejected, and `removed`
59
+ * is terminal so a late timer cannot remove the same toast twice.
60
+ *
61
+ * That last one is not hypothetical. Hover-to-pause plus a dismiss
62
+ * button means two independent timers can be in flight, and the
63
+ * hand-rolled version of this component reconciles them with a
64
+ * `clearTimeout` and a ref. Here the second one throws.
65
+ *
66
+ * ### It does not announce itself
67
+ *
68
+ * No `role="status"` and no `aria-live` on this element — the
69
+ * `ToastRegion` around it owns that, and a live region inside a live
70
+ * region is a message announced twice. See that component for the
71
+ * reason there is only ever one, and it is polite.
72
+ */
73
+ export const Toast = ({
74
+ children,
75
+ className,
76
+ duration = 5000,
77
+ exitDuration = 200,
78
+ intent = "neutral",
79
+ onRemove,
80
+ title,
81
+ }: ToastProps): ReactNode => {
82
+ const { is, status, transitionTo } =
83
+ useStatus<ToastStatus>({
84
+ initialState: "entering",
85
+ transitions: toastTransitions,
86
+ })
87
+
88
+ const onRemoveRef = useRef(onRemove)
89
+
90
+ onRemoveRef.current = onRemove
91
+
92
+ useEffect(() => {
93
+ if (!is("entering")) {
94
+ return
95
+ }
96
+
97
+ // A frame, not zero. The element has to be painted in its
98
+ // `entering` position before the class changes, or the browser
99
+ // coalesces both into one style recalculation and there is no
100
+ // transition to see — the single most common reason a CSS
101
+ // enter-animation "does nothing".
102
+ const frame = globalThis.requestAnimationFrame(() => {
103
+ // Guarded, because `is` and `transitionTo` are stable — this
104
+ // effect runs once, so the frame is *not* cancelled when the
105
+ // status changes underneath it. A dismiss during the enter
106
+ // frame moves the toast to `exiting`, and `exiting → visible`
107
+ // is a legal transition (it is how hover-to-pause works), so
108
+ // an unguarded write here brings a dismissed toast straight
109
+ // back and restarts its duration timer.
110
+ if (is("entering")) {
111
+ transitionTo("visible")
112
+ }
113
+ })
114
+
115
+ return () => {
116
+ globalThis.cancelAnimationFrame(frame)
117
+ }
118
+ }, [is, transitionTo])
119
+
120
+ useEffect(() => {
121
+ if (status !== "visible" || duration === 0) {
122
+ return
123
+ }
124
+
125
+ const timer = globalThis.setTimeout(() => {
126
+ transitionTo("exiting")
127
+ }, duration)
128
+
129
+ return () => {
130
+ globalThis.clearTimeout(timer)
131
+ }
132
+ }, [duration, status, transitionTo])
133
+
134
+ useEffect(() => {
135
+ if (status !== "exiting") {
136
+ return
137
+ }
138
+
139
+ const timer = globalThis.setTimeout(() => {
140
+ transitionTo("removed")
141
+ }, exitDuration)
142
+
143
+ return () => {
144
+ globalThis.clearTimeout(timer)
145
+ }
146
+ }, [exitDuration, status, transitionTo])
147
+
148
+ useEffect(() => {
149
+ if (status === "removed") {
150
+ onRemoveRef.current()
151
+ }
152
+ }, [status])
153
+
154
+ return (
155
+ <li
156
+ className={toClassName(
157
+ "pointer-events-auto flex items-start gap-3 rounded-md border p-3 shadow-medium transition-[opacity,transform] duration-(--duration-normal) ease-standard",
158
+ INTENT_APPEARANCE_CLASS[intent].soft,
159
+ ENTER_CLASS[status],
160
+ className,
161
+ )}
162
+ // Pausing on hover is WCAG 2.2.1's "pause" for a moving
163
+ // deadline, and it is the reason `exiting → visible` is a
164
+ // legal transition rather than an oddity in the table.
165
+ onMouseEnter={() => {
166
+ if (status === "exiting") {
167
+ transitionTo("visible")
168
+ }
169
+ }}
170
+ >
171
+ <div className="flex min-w-0 flex-col gap-0.5">
172
+ <span
173
+ className={toClassName(
174
+ "font-medium text-sm",
175
+ INTENT_CONTENT_CLASS[intent],
176
+ )}
177
+ >
178
+ {title}
179
+ </span>
180
+
181
+ {children === undefined ? null : (
182
+ <span className="text-content-secondary text-xs">
183
+ {children}
184
+ </span>
185
+ )}
186
+ </div>
187
+
188
+ <IconButton
189
+ appearance="ghost"
190
+ className="ms-auto shrink-0"
191
+ // The name has to be distinct per toast, or a stack of three
192
+ // gives `getByRole("button", { name: "Dismiss" })` three
193
+ // matches and `expectAgentDrivable` fails the story — which
194
+ // is the point of that helper: an agent picking one of three
195
+ // at random is worse than finding none.
196
+ label={`Dismiss ${title}`}
197
+ onClick={() => {
198
+ if (
199
+ status !== "exiting" &&
200
+ status !== "removed"
201
+ ) {
202
+ transitionTo("exiting")
203
+ }
204
+ }}
205
+ size="sm"
206
+ >
207
+ <svg
208
+ aria-hidden="true"
209
+ className="size-[1.15em] shrink-0"
210
+ fill="none"
211
+ focusable={false}
212
+ stroke="currentColor"
213
+ strokeLinecap="round"
214
+ strokeLinejoin="round"
215
+ strokeWidth={1.75}
216
+ viewBox="0 0 24 24"
217
+ xmlns="http://www.w3.org/2000/svg"
218
+ >
219
+ <path d="m6 6 12 12M18 6 6 18" />
220
+ </svg>
221
+ </IconButton>
222
+ </li>
223
+ )
224
+ }