@charcuterie/ui 0.1.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/CHANGELOG.md +13 -0
- package/README.md +161 -0
- package/dist/Alert/Alert.d.ts +88 -0
- package/dist/Alert/Alert.d.ts.map +1 -0
- package/dist/Alert/Alert.js +67 -0
- package/dist/Alert/Alert.js.map +1 -0
- package/dist/Badge/Badge.d.ts +54 -0
- package/dist/Badge/Badge.d.ts.map +1 -0
- package/dist/Badge/Badge.js +54 -0
- package/dist/Badge/Badge.js.map +1 -0
- package/dist/Badge/useIsTextClipped.d.ts +22 -0
- package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
- package/dist/Badge/useIsTextClipped.js +57 -0
- package/dist/Badge/useIsTextClipped.js.map +1 -0
- package/dist/Button/Button.d.ts +58 -0
- package/dist/Button/Button.d.ts.map +1 -0
- package/dist/Button/Button.js +37 -0
- package/dist/Button/Button.js.map +1 -0
- package/dist/Card/Card.d.ts +44 -0
- package/dist/Card/Card.d.ts.map +1 -0
- package/dist/Card/Card.js +59 -0
- package/dist/Card/Card.js.map +1 -0
- package/dist/EmptyState/EmptyState.d.ts +42 -0
- package/dist/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/EmptyState/EmptyState.js +37 -0
- package/dist/EmptyState/EmptyState.js.map +1 -0
- package/dist/IconButton/IconButton.d.ts +34 -0
- package/dist/IconButton/IconButton.d.ts.map +1 -0
- package/dist/IconButton/IconButton.js +23 -0
- package/dist/IconButton/IconButton.js.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
- package/dist/MediaTile/MediaTile.d.ts +46 -0
- package/dist/MediaTile/MediaTile.d.ts.map +1 -0
- package/dist/MediaTile/MediaTile.js +79 -0
- package/dist/MediaTile/MediaTile.js.map +1 -0
- package/dist/MediaTile/mediaStatus.d.ts +22 -0
- package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
- package/dist/MediaTile/mediaStatus.js +24 -0
- package/dist/MediaTile/mediaStatus.js.map +1 -0
- package/dist/Modal/Modal.d.ts +66 -0
- package/dist/Modal/Modal.d.ts.map +1 -0
- package/dist/Modal/Modal.js +111 -0
- package/dist/Modal/Modal.js.map +1 -0
- package/dist/Modal/lockScrollBehind.d.ts +22 -0
- package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
- package/dist/Modal/lockScrollBehind.js +48 -0
- package/dist/Modal/lockScrollBehind.js.map +1 -0
- package/dist/Popover/Popover.d.ts +66 -0
- package/dist/Popover/Popover.d.ts.map +1 -0
- package/dist/Popover/Popover.js +88 -0
- package/dist/Popover/Popover.js.map +1 -0
- package/dist/ProgressBar/ProgressBar.d.ts +51 -0
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBar.js +50 -0
- package/dist/ProgressBar/ProgressBar.js.map +1 -0
- package/dist/ProgressBar/progressValue.d.ts +44 -0
- package/dist/ProgressBar/progressValue.d.ts.map +1 -0
- package/dist/ProgressBar/progressValue.js +55 -0
- package/dist/ProgressBar/progressValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.js +129 -0
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.js +40 -0
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
- package/dist/Skeleton/Skeleton.d.ts +30 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/Skeleton/Skeleton.js +43 -0
- package/dist/Skeleton/Skeleton.js.map +1 -0
- package/dist/Spinner/Spinner.d.ts +37 -0
- package/dist/Spinner/Spinner.d.ts.map +1 -0
- package/dist/Spinner/Spinner.js +33 -0
- package/dist/Spinner/Spinner.js.map +1 -0
- package/dist/Tabs/TabTrigger.d.ts +32 -0
- package/dist/Tabs/TabTrigger.d.ts.map +1 -0
- package/dist/Tabs/TabTrigger.js +62 -0
- package/dist/Tabs/TabTrigger.js.map +1 -0
- package/dist/Tabs/Tabs.d.ts +95 -0
- package/dist/Tabs/Tabs.d.ts.map +1 -0
- package/dist/Tabs/Tabs.js +195 -0
- package/dist/Tabs/Tabs.js.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
- package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
- package/dist/controlStyles.d.ts +50 -0
- package/dist/controlStyles.d.ts.map +1 -0
- package/dist/controlStyles.js +60 -0
- package/dist/controlStyles.js.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.js +32 -0
- package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/intentStyles.d.ts +76 -0
- package/dist/intentStyles.d.ts.map +1 -0
- package/dist/intentStyles.js +150 -0
- package/dist/intentStyles.js.map +1 -0
- package/dist/mountStory.testHelpers.d.ts +30 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -0
- package/dist/mountStory.testHelpers.js +19 -0
- package/dist/mountStory.testHelpers.js.map +1 -0
- package/dist/statusIntent.d.ts +33 -0
- package/dist/statusIntent.d.ts.map +1 -0
- package/dist/statusIntent.js +99 -0
- package/dist/statusIntent.js.map +1 -0
- package/dist/styles.css +130 -0
- package/dist/testing/expectAgentDrivable.d.ts +53 -0
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
- package/dist/testing/expectAgentDrivable.js +163 -0
- package/dist/testing/expectAgentDrivable.js.map +1 -0
- package/dist/testing/index.d.ts +11 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/toClassName.d.ts +12 -0
- package/dist/toClassName.d.ts.map +1 -0
- package/dist/toClassName.js +12 -0
- package/dist/toClassName.js.map +1 -0
- package/dist/tokens.d.ts +16 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +16 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +70 -0
- package/src/Alert/Alert.mdx +70 -0
- package/src/Alert/Alert.stories.tsx +218 -0
- package/src/Alert/Alert.test.tsx +112 -0
- package/src/Alert/Alert.tsx +166 -0
- package/src/Badge/Badge.mdx +43 -0
- package/src/Badge/Badge.stories.tsx +229 -0
- package/src/Badge/Badge.test.tsx +153 -0
- package/src/Badge/Badge.tsx +130 -0
- package/src/Badge/useIsTextClipped.ts +77 -0
- package/src/Button/Button.mdx +44 -0
- package/src/Button/Button.stories.tsx +175 -0
- package/src/Button/Button.test.tsx +114 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.mdx +53 -0
- package/src/Card/Card.stories.tsx +207 -0
- package/src/Card/Card.test.tsx +56 -0
- package/src/Card/Card.tsx +142 -0
- package/src/EmptyState/EmptyState.mdx +40 -0
- package/src/EmptyState/EmptyState.stories.tsx +183 -0
- package/src/EmptyState/EmptyState.test.tsx +41 -0
- package/src/EmptyState/EmptyState.tsx +114 -0
- package/src/IconButton/IconButton.mdx +34 -0
- package/src/IconButton/IconButton.stories.tsx +191 -0
- package/src/IconButton/IconButton.test.tsx +70 -0
- package/src/IconButton/IconButton.tsx +54 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
- package/src/MediaTile/MediaTile.mdx +51 -0
- package/src/MediaTile/MediaTile.stories.tsx +211 -0
- package/src/MediaTile/MediaTile.test.tsx +41 -0
- package/src/MediaTile/MediaTile.tsx +227 -0
- package/src/MediaTile/mediaStatus.ts +28 -0
- package/src/Modal/Modal.mdx +113 -0
- package/src/Modal/Modal.stories.tsx +272 -0
- package/src/Modal/Modal.test.tsx +248 -0
- package/src/Modal/Modal.tsx +228 -0
- package/src/Modal/lockScrollBehind.ts +59 -0
- package/src/Popover/Popover.mdx +102 -0
- package/src/Popover/Popover.stories.tsx +247 -0
- package/src/Popover/Popover.test.tsx +191 -0
- package/src/Popover/Popover.tsx +164 -0
- package/src/ProgressBar/ProgressBar.mdx +48 -0
- package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
- package/src/ProgressBar/ProgressBar.test.tsx +74 -0
- package/src/ProgressBar/ProgressBar.tsx +172 -0
- package/src/ProgressBar/progressValue.test.ts +101 -0
- package/src/ProgressBar/progressValue.ts +87 -0
- package/src/SegmentedControl/SegmentedControl.mdx +71 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
- package/src/SegmentedControl/SegmentedControl.tsx +219 -0
- package/src/SegmentedControl/SegmentedOption.tsx +94 -0
- package/src/Skeleton/Skeleton.mdx +30 -0
- package/src/Skeleton/Skeleton.stories.tsx +130 -0
- package/src/Skeleton/Skeleton.test.tsx +47 -0
- package/src/Skeleton/Skeleton.tsx +106 -0
- package/src/Spinner/Spinner.mdx +36 -0
- package/src/Spinner/Spinner.stories.tsx +118 -0
- package/src/Spinner/Spinner.test.tsx +38 -0
- package/src/Spinner/Spinner.tsx +92 -0
- package/src/Tabs/TabTrigger.tsx +126 -0
- package/src/Tabs/Tabs.mdx +126 -0
- package/src/Tabs/Tabs.stories.tsx +187 -0
- package/src/Tabs/Tabs.test.tsx +283 -0
- package/src/Tabs/Tabs.tsx +333 -0
- package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
- package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
- package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
- package/src/argTypes.storyHelpers.ts +115 -0
- package/src/board.storyHelpers.tsx +150 -0
- package/src/controlStyles.ts +79 -0
- package/src/expectNoAxeViolations.testHelpers.ts +41 -0
- package/src/icons.storyHelpers.tsx +116 -0
- package/src/index.ts +132 -0
- package/src/intentStyles.ts +207 -0
- package/src/mdxReferences.test.ts +95 -0
- package/src/mountStory.testHelpers.ts +41 -0
- package/src/sourceRules.test.ts +303 -0
- package/src/statusIntent.test.ts +136 -0
- package/src/statusIntent.ts +137 -0
- package/src/storyControls.test.ts +128 -0
- package/src/styles.css +130 -0
- package/src/tailwindCandidates.test.ts +377 -0
- package/src/testing/expectAgentDrivable.test.tsx +71 -0
- package/src/testing/expectAgentDrivable.ts +273 -0
- package/src/testing/index.ts +17 -0
- package/src/toClassName.ts +13 -0
- package/src/tokens.ts +16 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
intentArgType,
|
|
5
|
+
toStoryChoice,
|
|
6
|
+
} from "../argTypes.storyHelpers.ts"
|
|
7
|
+
import { Button } from "../Button/Button.tsx"
|
|
8
|
+
import {
|
|
9
|
+
ContainerBoard,
|
|
10
|
+
StoryCell,
|
|
11
|
+
StoryGrid,
|
|
12
|
+
StorySection,
|
|
13
|
+
} from "../board.storyHelpers.tsx"
|
|
14
|
+
import { Alert } from "./Alert.tsx"
|
|
15
|
+
|
|
16
|
+
const INTENTS = [
|
|
17
|
+
"neutral",
|
|
18
|
+
"accent",
|
|
19
|
+
"success",
|
|
20
|
+
"warning",
|
|
21
|
+
"danger",
|
|
22
|
+
"info",
|
|
23
|
+
] as const
|
|
24
|
+
|
|
25
|
+
const meta = {
|
|
26
|
+
title: "Components/Alert",
|
|
27
|
+
component: Alert,
|
|
28
|
+
parameters: { layout: "padded" },
|
|
29
|
+
argTypes: {
|
|
30
|
+
intent: intentArgType,
|
|
31
|
+
size: toStoryChoice(["sm", "md"] as const),
|
|
32
|
+
},
|
|
33
|
+
// The component's own defaults, restated — Storybook does not seed
|
|
34
|
+
// `args` from docgen, so an unstated default shows in the props
|
|
35
|
+
// table with nothing selected in its control.
|
|
36
|
+
args: {
|
|
37
|
+
intent: "neutral",
|
|
38
|
+
size: "md",
|
|
39
|
+
},
|
|
40
|
+
} satisfies Meta<typeof Alert>
|
|
41
|
+
|
|
42
|
+
export default meta
|
|
43
|
+
|
|
44
|
+
type Story = StoryObj<typeof meta>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* rip-deck's grouped tower alert, verbatim: one trouble, the bays it
|
|
48
|
+
* touches, and whether the house speakers already said it out loud.
|
|
49
|
+
*/
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
description: "4 bays · 3, 4, 5, 6 · confirmed",
|
|
53
|
+
heading:
|
|
54
|
+
"Four bays stalled at once — check the USB hub's power",
|
|
55
|
+
intent: "danger",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const AllVariants: Story = {
|
|
60
|
+
args: { heading: "The disc may need cleaning" },
|
|
61
|
+
render: (alertProps) => (
|
|
62
|
+
<StorySection title="Six intents. rip-deck declares this map twice, in two files, with hardcoded hexes and no light mode.">
|
|
63
|
+
<StoryGrid columns={3}>
|
|
64
|
+
{INTENTS.map((intent) => (
|
|
65
|
+
<StoryCell
|
|
66
|
+
align="stretch"
|
|
67
|
+
key={intent}
|
|
68
|
+
label={intent}
|
|
69
|
+
>
|
|
70
|
+
<Alert
|
|
71
|
+
{...alertProps}
|
|
72
|
+
heading={`intent: ${intent}`}
|
|
73
|
+
intent={intent}
|
|
74
|
+
/>
|
|
75
|
+
</StoryCell>
|
|
76
|
+
))}
|
|
77
|
+
</StoryGrid>
|
|
78
|
+
</StorySection>
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Every part is optional except the sentence, and `size="sm"` is the
|
|
84
|
+
* in-card form — rip-deck's verdict chip, which sits under a bay's
|
|
85
|
+
* progress bar rather than at the top of the page.
|
|
86
|
+
*/
|
|
87
|
+
export const AllStates: Story = {
|
|
88
|
+
args: { heading: "The disc may need cleaning" },
|
|
89
|
+
render: (alertProps) => (
|
|
90
|
+
<StorySection title="An alert is static: no hover, no focus, no disabled. What varies is which parts are present.">
|
|
91
|
+
<StoryGrid columns={2}>
|
|
92
|
+
<StoryCell align="stretch" label="heading only">
|
|
93
|
+
<Alert {...alertProps} intent="warning" />
|
|
94
|
+
</StoryCell>
|
|
95
|
+
|
|
96
|
+
<StoryCell
|
|
97
|
+
align="stretch"
|
|
98
|
+
label="heading + description"
|
|
99
|
+
>
|
|
100
|
+
<Alert
|
|
101
|
+
{...alertProps}
|
|
102
|
+
description="Bay 4 · suspected"
|
|
103
|
+
intent="warning"
|
|
104
|
+
/>
|
|
105
|
+
</StoryCell>
|
|
106
|
+
|
|
107
|
+
<StoryCell align="stretch" label="with evidence">
|
|
108
|
+
<Alert
|
|
109
|
+
{...alertProps}
|
|
110
|
+
details={[
|
|
111
|
+
"3 read errors in 40 s",
|
|
112
|
+
"throughput 1.2x against a 6.0x baseline",
|
|
113
|
+
]}
|
|
114
|
+
intent="warning"
|
|
115
|
+
/>
|
|
116
|
+
</StoryCell>
|
|
117
|
+
|
|
118
|
+
<StoryCell align="stretch" label="with an action">
|
|
119
|
+
<Alert
|
|
120
|
+
{...alertProps}
|
|
121
|
+
actions={
|
|
122
|
+
<Button size="sm" appearance="outline">
|
|
123
|
+
Open trays
|
|
124
|
+
</Button>
|
|
125
|
+
}
|
|
126
|
+
description="2 discs · slots 4, 7"
|
|
127
|
+
heading="There are still discs in the tower"
|
|
128
|
+
intent="info"
|
|
129
|
+
/>
|
|
130
|
+
</StoryCell>
|
|
131
|
+
|
|
132
|
+
<StoryCell
|
|
133
|
+
align="stretch"
|
|
134
|
+
label='size="sm" — the in-card verdict'
|
|
135
|
+
>
|
|
136
|
+
<Alert
|
|
137
|
+
{...alertProps}
|
|
138
|
+
details={["seen on bays 3 and 7"]}
|
|
139
|
+
heading="Part of the tower-wide problem above."
|
|
140
|
+
intent="danger"
|
|
141
|
+
size="sm"
|
|
142
|
+
/>
|
|
143
|
+
</StoryCell>
|
|
144
|
+
|
|
145
|
+
<StoryCell
|
|
146
|
+
align="stretch"
|
|
147
|
+
label="label ⇒ a named landmark"
|
|
148
|
+
>
|
|
149
|
+
<Alert
|
|
150
|
+
{...alertProps}
|
|
151
|
+
heading="The USB bus is flapping"
|
|
152
|
+
intent="danger"
|
|
153
|
+
label="USB connection alert"
|
|
154
|
+
/>
|
|
155
|
+
</StoryCell>
|
|
156
|
+
</StoryGrid>
|
|
157
|
+
</StorySection>
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `@container` claim: actions sit beside the sentence when there
|
|
163
|
+
* is room and drop under it when there is not — and the width that
|
|
164
|
+
* decides is the **panel's**, not the window's. A banner in a
|
|
165
|
+
* sidebar and the same banner full-bleed is exactly the pair a media
|
|
166
|
+
* query cannot tell apart.
|
|
167
|
+
*/
|
|
168
|
+
export const Responsive: Story = {
|
|
169
|
+
args: {
|
|
170
|
+
description: "2 discs · slots 4, 7",
|
|
171
|
+
heading: "There are still discs in the tower",
|
|
172
|
+
intent: "info",
|
|
173
|
+
},
|
|
174
|
+
render: (alertProps) => (
|
|
175
|
+
<ContainerBoard>
|
|
176
|
+
{(width) => (
|
|
177
|
+
<Alert
|
|
178
|
+
{...alertProps}
|
|
179
|
+
actions={
|
|
180
|
+
<Button size="sm" appearance="outline">
|
|
181
|
+
Open trays
|
|
182
|
+
</Button>
|
|
183
|
+
}
|
|
184
|
+
label={`Discs still in the tower at ${width}`}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
</ContainerBoard>
|
|
188
|
+
),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* An alert is not interactive, so what there is to drive is what it
|
|
193
|
+
* *contains*. The action is an ordinary `Button` and stays reachable
|
|
194
|
+
* by role and name from inside the landmark — which is the whole
|
|
195
|
+
* reason `label` exists.
|
|
196
|
+
*/
|
|
197
|
+
export const Interactive: Story = {
|
|
198
|
+
args: {
|
|
199
|
+
details: [
|
|
200
|
+
"3 read errors in 40 s",
|
|
201
|
+
"throughput 1.2x against a 6.0x baseline",
|
|
202
|
+
],
|
|
203
|
+
heading:
|
|
204
|
+
"Bay 7 is reading with errors — clean the disc",
|
|
205
|
+
intent: "warning",
|
|
206
|
+
label: "Bay 7 verdict",
|
|
207
|
+
},
|
|
208
|
+
render: (alertProps) => (
|
|
209
|
+
<Alert
|
|
210
|
+
{...alertProps}
|
|
211
|
+
actions={
|
|
212
|
+
<Button size="sm" appearance="outline">
|
|
213
|
+
Retry in another bay
|
|
214
|
+
</Button>
|
|
215
|
+
}
|
|
216
|
+
/>
|
|
217
|
+
),
|
|
218
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect } 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 "./Alert.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, Default, Interactive, Responsive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("a labelled alert is a landmark an agent can scope to", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Interactive)
|
|
16
|
+
|
|
17
|
+
const alert = expectAgentDrivable(canvas, {
|
|
18
|
+
name: "Bay 7 verdict",
|
|
19
|
+
role: "region",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// The whole reason `label` exists: the action inside is reachable
|
|
23
|
+
// *through* the alert, which is what disambiguates one of nine
|
|
24
|
+
// identical "Retry" buttons on a nine-bay tower.
|
|
25
|
+
await expect(alert).toContainElement(
|
|
26
|
+
expectAgentDrivable(canvas, {
|
|
27
|
+
name: "Retry in another bay",
|
|
28
|
+
role: "button",
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
await expectNoAxeViolations(canvasElement)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("an unlabelled alert is not a landmark", async () => {
|
|
36
|
+
const { canvas, canvasElement } =
|
|
37
|
+
await mountStory(Default)
|
|
38
|
+
|
|
39
|
+
// A block, deliberately. Nine bay cards each holding a named
|
|
40
|
+
// region called "Part of the tower-wide problem above." is axe's
|
|
41
|
+
// `landmark-unique`, so the default has to be the quiet one.
|
|
42
|
+
await expect(
|
|
43
|
+
canvas.queryAllByRole("region"),
|
|
44
|
+
).toHaveLength(0)
|
|
45
|
+
|
|
46
|
+
await expect(
|
|
47
|
+
canvas.getByText(
|
|
48
|
+
"Four bays stalled at once — check the USB hub's power",
|
|
49
|
+
),
|
|
50
|
+
).toBeVisible()
|
|
51
|
+
|
|
52
|
+
await expectNoAxeViolations(canvasElement)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("the evidence is a list, and repeated evidence still renders", async () => {
|
|
56
|
+
const { canvas, canvasElement } =
|
|
57
|
+
await mountStory(Interactive)
|
|
58
|
+
|
|
59
|
+
const list = canvas.getByRole("list")
|
|
60
|
+
|
|
61
|
+
await expect(
|
|
62
|
+
canvas.getAllByRole("listitem"),
|
|
63
|
+
).toHaveLength(2)
|
|
64
|
+
|
|
65
|
+
await expect(list).toHaveTextContent(
|
|
66
|
+
"3 read errors in 40 s",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
await expectNoAxeViolations(canvasElement)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Six landmarks in one board, and every one of them differently
|
|
74
|
+
* named — because two `<section>`s sharing a name is a violation
|
|
75
|
+
* rather than a duplicate, which is exactly why the default is a
|
|
76
|
+
* plain block.
|
|
77
|
+
*/
|
|
78
|
+
test("the whole variants board is clean under axe", async () => {
|
|
79
|
+
const { canvasElement } = await mountStory(AllStates)
|
|
80
|
+
|
|
81
|
+
await expectNoAxeViolations(canvasElement)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("the actions drop below the sentence in a narrow container", async () => {
|
|
85
|
+
const { canvas } = await mountStory(Responsive)
|
|
86
|
+
|
|
87
|
+
const narrow = expectAgentDrivable(canvas, {
|
|
88
|
+
name: "Discs still in the tower at 15rem",
|
|
89
|
+
role: "region",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const wide = expectAgentDrivable(canvas, {
|
|
93
|
+
name: "Discs still in the tower at 34rem",
|
|
94
|
+
role: "region",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const getRowDirection = (alert: HTMLElement) =>
|
|
98
|
+
globalThis.getComputedStyle(
|
|
99
|
+
alert.firstElementChild as HTMLElement,
|
|
100
|
+
).flexDirection
|
|
101
|
+
|
|
102
|
+
// The **axis**, not the height. The first draft of this test
|
|
103
|
+
// compared the two rows' heights and passed with `cq-sm:flex-row`
|
|
104
|
+
// deleted — because at 15rem the sentence wraps onto more lines
|
|
105
|
+
// whatever the button does, so it was measuring text wrapping and
|
|
106
|
+
// calling it a container query. This is the thing itself: at 15rem
|
|
107
|
+
// the button sits under the text, at 34rem beside it, and the
|
|
108
|
+
// width that decided is the panel's rather than the window's.
|
|
109
|
+
await expect(getRowDirection(narrow)).toBe("column")
|
|
110
|
+
|
|
111
|
+
await expect(getRowDirection(wide)).toBe("row")
|
|
112
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
2
|
+
import type {
|
|
3
|
+
ComponentPropsWithRef,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from "react"
|
|
6
|
+
|
|
7
|
+
import { INTENT_APPEARANCE_CLASS } from "../intentStyles.ts"
|
|
8
|
+
import { toClassName } from "../toClassName.ts"
|
|
9
|
+
|
|
10
|
+
export type AlertSize = "md" | "sm"
|
|
11
|
+
|
|
12
|
+
export type AlertProps = Omit<
|
|
13
|
+
ComponentPropsWithRef<"section">,
|
|
14
|
+
"children"
|
|
15
|
+
> & {
|
|
16
|
+
/** Buttons. Laid out beside the text at `cq-sm` and up. */
|
|
17
|
+
actions?: ReactNode
|
|
18
|
+
/** The quiet second line — counts, timestamps, provenance. */
|
|
19
|
+
description?: ReactNode
|
|
20
|
+
/**
|
|
21
|
+
* The evidence, one line each. A list rather than a paragraph
|
|
22
|
+
* because that is what it is, and because a screen reader then
|
|
23
|
+
* announces "list, 3 items" instead of running three measurements
|
|
24
|
+
* together into one sentence.
|
|
25
|
+
*/
|
|
26
|
+
details?: readonly string[]
|
|
27
|
+
/**
|
|
28
|
+
* The sentence. A `ReactNode` rather than a `string` because
|
|
29
|
+
* rip-deck's verdicts qualify themselves inline — *"…(suspected —
|
|
30
|
+
* retry in another drive to confirm)"* — and splitting that across
|
|
31
|
+
* two props would let the qualifier drift away from the claim it
|
|
32
|
+
* qualifies.
|
|
33
|
+
*/
|
|
34
|
+
heading: ReactNode
|
|
35
|
+
intent?: IntentName
|
|
36
|
+
/**
|
|
37
|
+
* Present ⇒ this is a **named landmark** and
|
|
38
|
+
* `getByRole("region", { name })` finds it. Absent ⇒ a plain
|
|
39
|
+
* block, because a `<section>` with no accessible name is
|
|
40
|
+
* `generic` rather than `region`. See below; the default is
|
|
41
|
+
* deliberate.
|
|
42
|
+
*/
|
|
43
|
+
label?: string
|
|
44
|
+
size?: AlertSize
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SIZE_CLASS: Record<AlertSize, string> = {
|
|
48
|
+
sm: "gap-0.5 rounded-md px-2 py-1 text-sm",
|
|
49
|
+
md: "gap-1 rounded-xl px-3.5 py-2.5 text-md",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The thing four of rip-deck's components each spell differently,
|
|
54
|
+
* and the reason `TONE_CLASS` was declared twice in one package.
|
|
55
|
+
*
|
|
56
|
+
* `TowerAlerts`, `UsbAlertBanner`, `LoadedDiscsBanner` and
|
|
57
|
+
* `VerdictBadge` are one shape: an intent-coloured block holding a
|
|
58
|
+
* sentence, a quieter line under it, and sometimes a list of
|
|
59
|
+
* evidence. Two of them carried a byte-identical `TONE_CLASS` map of
|
|
60
|
+
* hardcoded hexes and `amber-`/`red-` utilities; mux-magic spells the
|
|
61
|
+
* same idea a third way in `statusClassMap`. Here it is
|
|
62
|
+
* `INTENT_APPEARANCE_CLASS`, which every other component in this
|
|
63
|
+
* package already reads.
|
|
64
|
+
*
|
|
65
|
+
* ### It is a block by default, and a landmark only when named
|
|
66
|
+
*
|
|
67
|
+
* `label` is what decides, because the evidence divides cleanly:
|
|
68
|
+
*
|
|
69
|
+
* - The **banners** are one alert at the top of a page and want to
|
|
70
|
+
* be addressable — `getByRole("region", { name: "USB connection
|
|
71
|
+
* alert" })`. They pass a `label`.
|
|
72
|
+
* - The **verdict** renders inside every bay card, and one of its
|
|
73
|
+
* two forms is the single sentence *"Part of the tower-wide
|
|
74
|
+
* problem above."* Nine landmarks sharing one name is axe's
|
|
75
|
+
* `landmark-unique`, so it passes none.
|
|
76
|
+
*
|
|
77
|
+
* A component that always took the landmark would have made the
|
|
78
|
+
* second case fail its own a11y gate, and one that never took it
|
|
79
|
+
* would leave three `<section aria-label>` wrappers hand-rolled in
|
|
80
|
+
* the consumer — which is what rip-deck has today.
|
|
81
|
+
*
|
|
82
|
+
* ### The heading is not a heading element
|
|
83
|
+
*
|
|
84
|
+
* An alert's headline is a *sentence* — "Bay 7 is reading with
|
|
85
|
+
* errors; clean the disc and try again" — not a node in the document
|
|
86
|
+
* outline. Nine bays' worth of those as `<h3>`s builds a table of
|
|
87
|
+
* contents out of nine complaints, which is worse for a screen
|
|
88
|
+
* reader than no outline entry at all. `Card` and `EmptyState` take
|
|
89
|
+
* a real `headingLevel` precisely because their headings *are* short
|
|
90
|
+
* outline nodes; this one is styled text, and `label` is how it
|
|
91
|
+
* becomes findable.
|
|
92
|
+
*
|
|
93
|
+
* ### It does not announce, and that is a component boundary
|
|
94
|
+
*
|
|
95
|
+
* No `role="alert"`, no `aria-live`. A live region takes **no
|
|
96
|
+
* accessible name from its content**
|
|
97
|
+
* ([decision](../../../docs/decisions/2026-07-29-status-regions-carry-an-aria-label.md)),
|
|
98
|
+
* so an announcing variant would need a name that duplicates the
|
|
99
|
+
* sentence it is announcing — and `role="alert"` on a `<section>`
|
|
100
|
+
* overrides the landmark, so it cannot be both. A thing that
|
|
101
|
+
* interrupts you is a `Toast` (M6). This is a thing that is *on the
|
|
102
|
+
* page*.
|
|
103
|
+
*/
|
|
104
|
+
export const Alert = ({
|
|
105
|
+
actions,
|
|
106
|
+
className,
|
|
107
|
+
description,
|
|
108
|
+
details,
|
|
109
|
+
heading,
|
|
110
|
+
intent = "neutral",
|
|
111
|
+
label,
|
|
112
|
+
size = "md",
|
|
113
|
+
...sectionProps
|
|
114
|
+
}: AlertProps): ReactNode => {
|
|
115
|
+
return (
|
|
116
|
+
// Always a `<section>`, and the landmark still depends on
|
|
117
|
+
// `label` — because HTML already says so. A `<section>` maps to
|
|
118
|
+
// `region` **only when it has an accessible name**; without one
|
|
119
|
+
// it is `generic`, which is exactly the "plain block" the
|
|
120
|
+
// in-card verdict wants. Branching the element type here would
|
|
121
|
+
// have been a second, weaker statement of a rule the platform
|
|
122
|
+
// already enforces.
|
|
123
|
+
<section
|
|
124
|
+
{...sectionProps}
|
|
125
|
+
aria-label={label}
|
|
126
|
+
className={toClassName(
|
|
127
|
+
// `@container` so the actions drop below the text in a
|
|
128
|
+
// sidebar and sit beside it full-bleed — the case a media
|
|
129
|
+
// query cannot tell apart.
|
|
130
|
+
"@container border",
|
|
131
|
+
INTENT_APPEARANCE_CLASS[intent].soft,
|
|
132
|
+
SIZE_CLASS[size],
|
|
133
|
+
className,
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
<div className="flex flex-col gap-2 cq-sm:flex-row cq-sm:items-start cq-sm:justify-between">
|
|
137
|
+
<div className="flex min-w-0 flex-col">
|
|
138
|
+
<p className="m-0 font-semibold">{heading}</p>
|
|
139
|
+
|
|
140
|
+
{description ? (
|
|
141
|
+
<p className="m-0 text-sm opacity-80">
|
|
142
|
+
{description}
|
|
143
|
+
</p>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{actions ? (
|
|
148
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
149
|
+
{actions}
|
|
150
|
+
</div>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{details && details.length > 0 ? (
|
|
155
|
+
<ul className="m-0 list-none p-0 text-sm opacity-80">
|
|
156
|
+
{details.map((line, index) => (
|
|
157
|
+
// The line's own text is not a key: evidence repeats.
|
|
158
|
+
// Two drives reporting "3 read errors" is the normal
|
|
159
|
+
// case, not an edge one.
|
|
160
|
+
<li key={`${String(index)}-${line}`}>{line}</li>
|
|
161
|
+
))}
|
|
162
|
+
</ul>
|
|
163
|
+
) : null}
|
|
164
|
+
</section>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as BadgeStories from "./Badge.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={BadgeStories} />
|
|
6
|
+
|
|
7
|
+
# Badge
|
|
8
|
+
|
|
9
|
+
Four repos, and rip-deck declares the **identical** `TONE_CLASS` map twice —
|
|
10
|
+
`VerdictBadge.tsx` and `TowerAlerts.tsx`, same five hardcoded hexes, related only by
|
|
11
|
+
copy-paste. mux-magic's `statusClassMap` is the same idea spelled a third way, keyed by a
|
|
12
|
+
bare `string`, so an unrecognised status renders with no colour and no error.
|
|
13
|
+
|
|
14
|
+
<Canvas of={BadgeStories.Default} />
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## Where the status half lives
|
|
21
|
+
|
|
22
|
+
`intent` covers the colour. Which *state* maps to which intent — and what it is called in
|
|
23
|
+
English — lives in `statusIntent.ts` as exhaustive switches over the machines
|
|
24
|
+
`@charcuterie/logic` ships:
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
<Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That is the whole of mux-magic's `StatusBadge`, and adding a state to the machine is a
|
|
31
|
+
compile error here rather than a blank pill in production.
|
|
32
|
+
|
|
33
|
+
<Canvas of={BadgeStories.Interactive} />
|
|
34
|
+
|
|
35
|
+
## No implicit role
|
|
36
|
+
|
|
37
|
+
A badge is a word about something else. It is not a live region, and `role="status"` would
|
|
38
|
+
make every re-render announce itself. Where a status genuinely needs announcing, that is
|
|
39
|
+
`LiveStatusIndicator`; where a badge needs a name beyond its text, `aria-label` still
|
|
40
|
+
works. Hover styling is deliberately absent too — `INTENT_HOVER_CLASS` is opt-in, so a
|
|
41
|
+
verdict pill cannot imply it is clickable.
|
|
42
|
+
|
|
43
|
+
<Canvas of={BadgeStories.AllVariants} />
|