@charcuterie/ui 0.1.0 → 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.
- package/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +69 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +73 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +88 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +104 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +68 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +104 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +3 -3
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +74 -0
- package/src/Field/Field.stories.tsx +144 -0
- package/src/Field/Field.test.tsx +142 -0
- package/src/Field/Field.tsx +163 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +88 -0
- package/src/LogViewer/LogViewer.stories.tsx +135 -0
- package/src/LogViewer/LogViewer.test.tsx +173 -0
- package/src/LogViewer/LogViewer.tsx +195 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +169 -0
- package/src/index.ts +43 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/sourceRules.test.ts +221 -30
- package/CHANGELOG.md +0 -13
|
@@ -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
|
+
}
|