@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.
- 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 +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.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 +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -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 +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- 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 +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -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 +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -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 +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { toClassName } from "../toClassName.ts"
|
|
5
|
+
import { Toast } from "./Toast.tsx"
|
|
6
|
+
|
|
7
|
+
export type ToastRecord = {
|
|
8
|
+
description?: ReactNode
|
|
9
|
+
/** `0` pins this one open. See `Toast`'s `duration`. */
|
|
10
|
+
duration?: number
|
|
11
|
+
intent?: IntentName
|
|
12
|
+
key: string
|
|
13
|
+
title: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ToastRegionProps = {
|
|
17
|
+
className?: string
|
|
18
|
+
/**
|
|
19
|
+
* The region's accessible name. `role="status"` takes **none from
|
|
20
|
+
* its content** — the trap `Spinner` walked into in M3 and
|
|
21
|
+
* `Popover` in M4.
|
|
22
|
+
*/
|
|
23
|
+
label?: string
|
|
24
|
+
onDismiss: (key: string) => void
|
|
25
|
+
toasts: ToastRecord[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The live region, and the list is the caller's.
|
|
30
|
+
*
|
|
31
|
+
* ### One region, and it is polite
|
|
32
|
+
*
|
|
33
|
+
* Not two. The usual arrangement — a polite region and an assertive
|
|
34
|
+
* one, so a danger toast interrupts — was rejected because the
|
|
35
|
+
* interrupting case does not belong in a toast at all. A message
|
|
36
|
+
* that must be heard *now* is one the user has to be able to go back
|
|
37
|
+
* and read, and a toast is gone in five seconds; if it matters that
|
|
38
|
+
* much it is an `Alert` on the page, which this library already
|
|
39
|
+
* ships and which stays put.
|
|
40
|
+
*
|
|
41
|
+
* That is the same line `Field` draws for its error text, and it is
|
|
42
|
+
* worth keeping the two consistent: **this library announces once,
|
|
43
|
+
* in the place the user can act on it.**
|
|
44
|
+
*
|
|
45
|
+
* ### The stack does not own the toasts
|
|
46
|
+
*
|
|
47
|
+
* `toasts` comes in and `onDismiss` goes out — there is no
|
|
48
|
+
* `toast()` function, no imperative queue, and no context provider.
|
|
49
|
+
* An app already has a store (mux-magic has Jotai, rip-deck has its
|
|
50
|
+
* own), and a component library that ships a second one is asking
|
|
51
|
+
* every consumer to keep two lists of the same notifications in
|
|
52
|
+
* sync. The lifecycle *inside* one toast is state a store cannot
|
|
53
|
+
* see, which is why `Toast` owns that and nothing more.
|
|
54
|
+
*
|
|
55
|
+
* ### `pointer-events-none` on the container, `auto` on each toast
|
|
56
|
+
*
|
|
57
|
+
* A fixed overlay across the bottom of the viewport swallows every
|
|
58
|
+
* click in that band, including on the app behind it, and it does so
|
|
59
|
+
* invisibly. The container passes clicks through and each toast
|
|
60
|
+
* takes them back.
|
|
61
|
+
*/
|
|
62
|
+
export const ToastRegion = ({
|
|
63
|
+
className,
|
|
64
|
+
label = "Notifications",
|
|
65
|
+
onDismiss,
|
|
66
|
+
toasts,
|
|
67
|
+
}: ToastRegionProps): ReactNode => (
|
|
68
|
+
<div
|
|
69
|
+
aria-label={label}
|
|
70
|
+
// `role="status"` is `aria-live="polite"` plus
|
|
71
|
+
// `aria-atomic="true"` by default — and atomic is wrong here,
|
|
72
|
+
// because it re-reads the entire stack every time one arrives.
|
|
73
|
+
// Spelling `aria-atomic="false"` next to it is what makes a
|
|
74
|
+
// second toast announce only itself.
|
|
75
|
+
aria-atomic="false"
|
|
76
|
+
className={toClassName(
|
|
77
|
+
"pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-2 p-4",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
role="status"
|
|
81
|
+
>
|
|
82
|
+
<ul className="pointer-events-none flex w-full max-w-sm list-none flex-col gap-2 p-0">
|
|
83
|
+
{toasts.map((toast) => (
|
|
84
|
+
<Toast
|
|
85
|
+
duration={toast.duration}
|
|
86
|
+
intent={toast.intent}
|
|
87
|
+
key={toast.key}
|
|
88
|
+
onRemove={() => {
|
|
89
|
+
onDismiss(toast.key)
|
|
90
|
+
}}
|
|
91
|
+
title={toast.title}
|
|
92
|
+
>
|
|
93
|
+
{toast.description}
|
|
94
|
+
</Toast>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StatusTransitions } from "@charcuterie/logic"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one machine the plan wrote down and M2 did not build:
|
|
5
|
+
*
|
|
6
|
+
* > | Toast lifecycle | entering → visible → exiting → removed |
|
|
7
|
+
*
|
|
8
|
+
* It is here rather than in `@charcuterie/logic/core` beside
|
|
9
|
+
* `asyncTransitions` and `connectionTransitions` on purpose. Those
|
|
10
|
+
* two describe *data* — a fetch, a socket — and a Preact app with no
|
|
11
|
+
* components at all still wants them. This one describes an
|
|
12
|
+
* **animation**, which only exists because something is being
|
|
13
|
+
* painted, so it belongs to the component layer.
|
|
14
|
+
*/
|
|
15
|
+
export type ToastStatus =
|
|
16
|
+
| "entering"
|
|
17
|
+
| "exiting"
|
|
18
|
+
| "removed"
|
|
19
|
+
| "visible"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `visible → removed` is deliberately **not** a legal transition,
|
|
23
|
+
* and neither is anything out of `removed`.
|
|
24
|
+
*
|
|
25
|
+
* The first means a toast cannot skip its exit — a dismissal that
|
|
26
|
+
* unmounts the node immediately is the flicker every hand-rolled
|
|
27
|
+
* toast stack has, and making it illegal turns that into a thrown
|
|
28
|
+
* error in a test rather than a jump nobody files.
|
|
29
|
+
*
|
|
30
|
+
* The second makes `removed` terminal, which is what lets the region
|
|
31
|
+
* call `onDismiss` exactly once. `createStatus` throws on an illegal
|
|
32
|
+
* transition, so a second timer firing after the first is a loud
|
|
33
|
+
* failure instead of a duplicate removal.
|
|
34
|
+
*
|
|
35
|
+
* `exiting → visible` **is** legal: pointing at a toast that is
|
|
36
|
+
* already sliding away has to bring it back, or the user is chasing
|
|
37
|
+
* a disappearing message with the cursor.
|
|
38
|
+
*/
|
|
39
|
+
export const toastTransitions: StatusTransitions<ToastStatus> =
|
|
40
|
+
{
|
|
41
|
+
entering: ["visible", "exiting"],
|
|
42
|
+
exiting: ["removed", "visible"],
|
|
43
|
+
removed: [],
|
|
44
|
+
visible: ["exiting"],
|
|
45
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as TooltipStories from "./Tooltip.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={TooltipStories} />
|
|
6
|
+
|
|
7
|
+
# Tooltip
|
|
8
|
+
|
|
9
|
+
Replaces mux-magic's `FieldTooltip` — 130 lines of hand-rolled positioning, including a
|
|
10
|
+
`computePosition` that reimplements `flip` and `shift` against `window.innerHeight`.
|
|
11
|
+
|
|
12
|
+
<Canvas of={TooltipStories.Default} />
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
<Controls />
|
|
17
|
+
|
|
18
|
+
## Three defects, all of which render perfectly
|
|
19
|
+
|
|
20
|
+
1. **Nothing references the tip.** It sets `role="tooltip"` and no `aria-describedby`
|
|
21
|
+
anywhere, so the control it belongs to never mentions it. `role="tooltip"` is **not** a
|
|
22
|
+
live region and does nothing on its own — the tip is a floating node nobody is pointed
|
|
23
|
+
at.
|
|
24
|
+
2. **Pointer only.** `onPointerEnter`/`onPointerLeave` with no `onFocus`/`onBlur`, so a
|
|
25
|
+
keyboard user cannot open it at all. WCAG 2.1.1.
|
|
26
|
+
3. **No dismiss.** Escape does nothing, which WCAG 1.4.13 requires of any content shown on
|
|
27
|
+
hover.
|
|
28
|
+
|
|
29
|
+
All three come from the same place: **the ARIA is written by hand beside the behaviour
|
|
30
|
+
instead of falling out of it.** Here `useRole` writes `aria-describedby` on the trigger and
|
|
31
|
+
`role` + `id` on the tip from one source, `useFocus` is what makes it keyboard-openable, and
|
|
32
|
+
`useDismiss` is Escape.
|
|
33
|
+
|
|
34
|
+
<Canvas of={TooltipStories.AllVariants} />
|
|
35
|
+
|
|
36
|
+
## A tooltip adds; it is never the name
|
|
37
|
+
|
|
38
|
+
| | may be the only place the text exists | why |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `aria-label` | **yes** | it *is* the name |
|
|
41
|
+
| `Field` `description` | **yes** | bound with `aria-describedby`, read on focus |
|
|
42
|
+
| `Badge`'s `title` | no | repeats text already in `textContent` |
|
|
43
|
+
| `Tooltip` | **no** | absent on touch, absent when nothing hovers |
|
|
44
|
+
|
|
45
|
+
An `IconButton` under a tooltip still carries its own `aria-label`. `AllVariants` proves the
|
|
46
|
+
button stays findable by the same query whether or not the tip is open.
|
|
47
|
+
|
|
48
|
+
## No `popover` and no focus manager
|
|
49
|
+
|
|
50
|
+
Unlike `Popover`, which is in the top layer and traps nothing. Focus never enters a tooltip
|
|
51
|
+
— moving it there is what makes one inescapable — so there is no `FloatingFocusManager` and
|
|
52
|
+
no `popover="manual"`.
|
|
53
|
+
|
|
54
|
+
The trade is real and worth stating: a tooltip is therefore **not** in the top layer and can
|
|
55
|
+
be clipped by an `overflow: hidden` ancestor. `strategy: "fixed"` keeps that to ancestors
|
|
56
|
+
which actually clip fixed descendants.
|
|
57
|
+
|
|
58
|
+
<Canvas of={TooltipStories.AllStates} />
|
|
59
|
+
|
|
60
|
+
## Touch has no hover, and this does not pretend otherwise
|
|
61
|
+
|
|
62
|
+
There is no long-press fallback. A tooltip is supplementary by construction, so on the kiosk
|
|
63
|
+
it simply never appears — and anything a touch user *must* read belongs in a `Field`'s
|
|
64
|
+
`description` instead.
|
|
65
|
+
|
|
66
|
+
Building a long-press would make the tip load-bearing on exactly the surface where it is
|
|
67
|
+
least reliable.
|
|
68
|
+
|
|
69
|
+
<Canvas of={TooltipStories.Interactive} />
|