@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,229 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asyncTransitions,
|
|
3
|
+
useStatus,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
6
|
+
import { intentArgType } from "../argTypes.storyHelpers.ts"
|
|
7
|
+
import { Button } from "../Button/Button.tsx"
|
|
8
|
+
import {
|
|
9
|
+
ContainerBoard,
|
|
10
|
+
StoryCell,
|
|
11
|
+
StoryGrid,
|
|
12
|
+
StoryRow,
|
|
13
|
+
StorySection,
|
|
14
|
+
} from "../board.storyHelpers.tsx"
|
|
15
|
+
import { DotIcon } from "../icons.storyHelpers.tsx"
|
|
16
|
+
import {
|
|
17
|
+
getAsyncIntent,
|
|
18
|
+
getAsyncLabel,
|
|
19
|
+
} from "../statusIntent.ts"
|
|
20
|
+
import { Badge } from "./Badge.tsx"
|
|
21
|
+
|
|
22
|
+
const INTENTS = [
|
|
23
|
+
"neutral",
|
|
24
|
+
"accent",
|
|
25
|
+
"success",
|
|
26
|
+
"warning",
|
|
27
|
+
"danger",
|
|
28
|
+
"info",
|
|
29
|
+
] as const
|
|
30
|
+
|
|
31
|
+
const meta = {
|
|
32
|
+
title: "Components/Badge",
|
|
33
|
+
component: Badge,
|
|
34
|
+
parameters: { layout: "padded" },
|
|
35
|
+
argTypes: { intent: intentArgType },
|
|
36
|
+
// The component's own defaults, restated — Storybook does not
|
|
37
|
+
// seed `args` from docgen, so an unstated default shows in the
|
|
38
|
+
// props table with nothing selected in its control.
|
|
39
|
+
args: {
|
|
40
|
+
appearance: "soft",
|
|
41
|
+
intent: "neutral",
|
|
42
|
+
overflow: "truncate",
|
|
43
|
+
size: "md",
|
|
44
|
+
},
|
|
45
|
+
} satisfies Meta<typeof Badge>
|
|
46
|
+
|
|
47
|
+
export default meta
|
|
48
|
+
|
|
49
|
+
type Story = StoryObj<typeof meta>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A badge has no role by design — it is a word about something
|
|
53
|
+
* else, not a live region — so the handle an agent reads off the row
|
|
54
|
+
* is its text.
|
|
55
|
+
*/
|
|
56
|
+
export const Default: Story = {
|
|
57
|
+
args: { children: "running", intent: "info" },
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const AllVariants: Story = {
|
|
61
|
+
args: { children: "running" },
|
|
62
|
+
render: (badgeProps) => (
|
|
63
|
+
<StorySection title="Six intents x three appearances. rip-deck declares this map twice, in two files, with hardcoded hexes.">
|
|
64
|
+
<StoryGrid columns={6}>
|
|
65
|
+
{(["soft", "solid", "outline"] as const).flatMap(
|
|
66
|
+
(appearance) =>
|
|
67
|
+
INTENTS.map((intent) => (
|
|
68
|
+
<StoryCell
|
|
69
|
+
key={`${appearance}-${intent}`}
|
|
70
|
+
label={`${appearance} · ${intent}`}
|
|
71
|
+
>
|
|
72
|
+
<Badge
|
|
73
|
+
{...badgeProps}
|
|
74
|
+
appearance={appearance}
|
|
75
|
+
intent={intent}
|
|
76
|
+
>
|
|
77
|
+
{intent}
|
|
78
|
+
</Badge>
|
|
79
|
+
</StoryCell>
|
|
80
|
+
)),
|
|
81
|
+
)}
|
|
82
|
+
</StoryGrid>
|
|
83
|
+
</StorySection>
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const AllStates: Story = {
|
|
88
|
+
args: { children: "running" },
|
|
89
|
+
render: (badgeProps) => (
|
|
90
|
+
<StorySection title="A badge is static — no hover, no focus, no disabled. What varies is size, an optional glyph, and how it sits in a real row.">
|
|
91
|
+
<StoryGrid columns={2}>
|
|
92
|
+
<StoryCell label="sm">
|
|
93
|
+
<Badge {...badgeProps} size="sm" />
|
|
94
|
+
</StoryCell>
|
|
95
|
+
|
|
96
|
+
<StoryCell label="md">
|
|
97
|
+
<Badge {...badgeProps} size="md" />
|
|
98
|
+
</StoryCell>
|
|
99
|
+
|
|
100
|
+
<StoryCell label="with a glyph">
|
|
101
|
+
<Badge
|
|
102
|
+
{...badgeProps}
|
|
103
|
+
icon={<DotIcon />}
|
|
104
|
+
intent="success"
|
|
105
|
+
>
|
|
106
|
+
verified
|
|
107
|
+
</Badge>
|
|
108
|
+
</StoryCell>
|
|
109
|
+
|
|
110
|
+
<StoryCell label="in a job row">
|
|
111
|
+
<div className="flex items-center gap-2 rounded-md border border-border-subtle bg-surface-raised p-2">
|
|
112
|
+
<span className="text-content-primary text-sm">
|
|
113
|
+
Blade Runner (1982)
|
|
114
|
+
</span>
|
|
115
|
+
|
|
116
|
+
<Badge intent="success" size="sm">
|
|
117
|
+
completed
|
|
118
|
+
</Badge>
|
|
119
|
+
</div>
|
|
120
|
+
</StoryCell>
|
|
121
|
+
</StoryGrid>
|
|
122
|
+
</StorySection>
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A label longer than the space it is given, which is the case that
|
|
128
|
+
* shipped broken: `shrink-0` plus `whitespace-nowrap` and no cap let
|
|
129
|
+
* the pill paint straight across the next column.
|
|
130
|
+
*
|
|
131
|
+
* `truncate` keeps the row height fixed and paints an ellipsis —
|
|
132
|
+
* and because that ellipsis is painted rather than inserted, a
|
|
133
|
+
* triple-click still selects the whole string and a screen reader
|
|
134
|
+
* still reads it. Hovering a clipped pill shows the rest.
|
|
135
|
+
*
|
|
136
|
+
* `wrap` is the answer where hover does not exist. The kiosk is a
|
|
137
|
+
* touch context, so a pill whose full text matters wraps there
|
|
138
|
+
* rather than hiding half of itself behind an affordance nobody can
|
|
139
|
+
* reach.
|
|
140
|
+
*/
|
|
141
|
+
export const Responsive: Story = {
|
|
142
|
+
args: {
|
|
143
|
+
children: "quarantined — checksum mismatch on title 4",
|
|
144
|
+
intent: "warning",
|
|
145
|
+
},
|
|
146
|
+
render: (badgeProps) => (
|
|
147
|
+
<div className="flex flex-col gap-8">
|
|
148
|
+
<StorySection title="overflow: truncate (default) — one line, capped, hover for the rest">
|
|
149
|
+
<ContainerBoard>
|
|
150
|
+
<Badge {...badgeProps} />
|
|
151
|
+
</ContainerBoard>
|
|
152
|
+
</StorySection>
|
|
153
|
+
|
|
154
|
+
<StorySection title="overflow: wrap — the pill grows instead">
|
|
155
|
+
<ContainerBoard>
|
|
156
|
+
<Badge {...badgeProps} overflow="wrap" />
|
|
157
|
+
</ContainerBoard>
|
|
158
|
+
</StorySection>
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const AsyncStatusBadge = () => {
|
|
164
|
+
const { reset, status, transitionTo } = useStatus({
|
|
165
|
+
initialState: "idle",
|
|
166
|
+
transitions: asyncTransitions,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<StoryRow>
|
|
171
|
+
<Badge intent={getAsyncIntent(status)}>
|
|
172
|
+
{getAsyncLabel(status)}
|
|
173
|
+
</Badge>
|
|
174
|
+
|
|
175
|
+
<Button
|
|
176
|
+
appearance="soft"
|
|
177
|
+
onClick={() => {
|
|
178
|
+
transitionTo("loading")
|
|
179
|
+
}}
|
|
180
|
+
size="sm"
|
|
181
|
+
>
|
|
182
|
+
Start
|
|
183
|
+
</Button>
|
|
184
|
+
|
|
185
|
+
<Button
|
|
186
|
+
appearance="soft"
|
|
187
|
+
intent="success"
|
|
188
|
+
onClick={() => {
|
|
189
|
+
transitionTo("success")
|
|
190
|
+
}}
|
|
191
|
+
size="sm"
|
|
192
|
+
>
|
|
193
|
+
Succeed
|
|
194
|
+
</Button>
|
|
195
|
+
|
|
196
|
+
<Button
|
|
197
|
+
appearance="soft"
|
|
198
|
+
intent="danger"
|
|
199
|
+
onClick={() => {
|
|
200
|
+
transitionTo("error")
|
|
201
|
+
}}
|
|
202
|
+
size="sm"
|
|
203
|
+
>
|
|
204
|
+
Fail
|
|
205
|
+
</Button>
|
|
206
|
+
|
|
207
|
+
<Button appearance="ghost" onClick={reset} size="sm">
|
|
208
|
+
Reset
|
|
209
|
+
</Button>
|
|
210
|
+
</StoryRow>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The M2 join, live: one `asyncTransitions` machine, one
|
|
216
|
+
* `getAsyncIntent`, and a badge that cannot render a state the
|
|
217
|
+
* machine does not have.
|
|
218
|
+
*
|
|
219
|
+
* Press *Succeed* before *Start* and the badge does not move:
|
|
220
|
+
* `idle → success` is not a legal edge, so the core throws (loudly,
|
|
221
|
+
* in the console) rather than painting a green pill over a job that
|
|
222
|
+
* never ran. That is the difference between this and mux-magic's
|
|
223
|
+
* `Record<string, string>`, which renders whatever string it is
|
|
224
|
+
* handed and colours an unknown one as nothing at all.
|
|
225
|
+
*/
|
|
226
|
+
export const Interactive: Story = {
|
|
227
|
+
args: { children: "running" },
|
|
228
|
+
render: () => <AsyncStatusBadge />,
|
|
229
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
composeStories,
|
|
3
|
+
composeStory,
|
|
4
|
+
} from "@storybook/react"
|
|
5
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
6
|
+
import { test } from "vitest"
|
|
7
|
+
|
|
8
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
9
|
+
import meta, * as stories from "./Badge.stories.tsx"
|
|
10
|
+
|
|
11
|
+
const { Default, Interactive, Responsive } =
|
|
12
|
+
composeStories(stories)
|
|
13
|
+
|
|
14
|
+
test("the text is queryable, which is what an agent matches on", async () => {
|
|
15
|
+
const { canvas } = await mountStory(Default)
|
|
16
|
+
|
|
17
|
+
// No role, deliberately — a badge is a word about something else,
|
|
18
|
+
// not a live region. So there is nothing to `getByRole` and the
|
|
19
|
+
// text is the whole contract.
|
|
20
|
+
await expect(canvas.getByText("running")).toBeVisible()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The M2 join, driven: one `asyncTransitions` machine, one
|
|
25
|
+
* `getAsyncIntent`, and a badge that cannot render a state the
|
|
26
|
+
* machine does not have.
|
|
27
|
+
*/
|
|
28
|
+
test("the badge follows the status machine through a full cycle", async () => {
|
|
29
|
+
const { canvas } = await mountStory(Interactive)
|
|
30
|
+
|
|
31
|
+
await expect(canvas.getByText("Idle")).toBeVisible()
|
|
32
|
+
|
|
33
|
+
await userEvent.click(
|
|
34
|
+
canvas.getByRole("button", { name: "Start" }),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
await expect(canvas.getByText("Loading…")).toBeVisible()
|
|
38
|
+
|
|
39
|
+
await userEvent.click(
|
|
40
|
+
canvas.getByRole("button", { name: "Succeed" }),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
await expect(canvas.getByText("Done")).toBeVisible()
|
|
44
|
+
|
|
45
|
+
await userEvent.click(
|
|
46
|
+
canvas.getByRole("button", { name: "Reset" }),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
await expect(canvas.getByText("Idle")).toBeVisible()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const LONG_LABEL =
|
|
53
|
+
"quarantined — checksum mismatch on title 4"
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A container narrower than the label needs, which is the whole
|
|
57
|
+
* point. The width goes on the canvas rather than on the badge —
|
|
58
|
+
* constraining the badge itself would prove nothing about
|
|
59
|
+
* `max-inline-size: 100%`, which is the fix under test.
|
|
60
|
+
*/
|
|
61
|
+
const mountInPanel = async (
|
|
62
|
+
overflow: "truncate" | "wrap",
|
|
63
|
+
) => {
|
|
64
|
+
const { canvasElement } = await mountStory(
|
|
65
|
+
composeStory(
|
|
66
|
+
{ args: { children: LONG_LABEL, overflow } },
|
|
67
|
+
meta,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
canvasElement.style.inlineSize = "10rem"
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
badge: canvasElement.firstElementChild as HTMLElement,
|
|
75
|
+
container: canvasElement,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("a truncated badge never paints outside its container", async () => {
|
|
80
|
+
const { badge, container } =
|
|
81
|
+
await mountInPanel("truncate")
|
|
82
|
+
|
|
83
|
+
const label = badge.firstElementChild as HTMLElement
|
|
84
|
+
|
|
85
|
+
await waitFor(() => {
|
|
86
|
+
expect(
|
|
87
|
+
badge.getBoundingClientRect().width,
|
|
88
|
+
).toBeLessThanOrEqual(
|
|
89
|
+
container.getBoundingClientRect().width,
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Clipped — so an ellipsis is being painted.
|
|
94
|
+
await expect(label.scrollWidth).toBeGreaterThan(
|
|
95
|
+
label.clientWidth,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// …and yet the whole string is still there. `text-overflow` paints
|
|
99
|
+
// the ellipsis, it does not insert one, so selection, copy, and
|
|
100
|
+
// every screen reader still get all of it. This is the assertion
|
|
101
|
+
// that would fail if anyone ever "fixed" truncation by slicing the
|
|
102
|
+
// string in JavaScript.
|
|
103
|
+
await expect(badge.textContent).toBe(LONG_LABEL)
|
|
104
|
+
|
|
105
|
+
// And the visual half of the readout, for the pointer users who
|
|
106
|
+
// cannot select it to find out.
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(badge).toHaveAttribute("title", LONG_LABEL)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("a short badge gets no tooltip", async () => {
|
|
113
|
+
const { canvasElement } = await mountStory(Default)
|
|
114
|
+
|
|
115
|
+
// The measurement has to be able to say *no*, or `title` is just
|
|
116
|
+
// unconditional and every pill in a bay list grows a tooltip.
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(
|
|
119
|
+
canvasElement.querySelector("[title]"),
|
|
120
|
+
).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("a wrapping badge grows taller rather than wider", async () => {
|
|
125
|
+
const truncated = await mountInPanel("truncate")
|
|
126
|
+
|
|
127
|
+
const truncatedHeight = truncated.badge.clientHeight
|
|
128
|
+
|
|
129
|
+
const wrapped = await mountInPanel("wrap")
|
|
130
|
+
|
|
131
|
+
await expect(wrapped.badge.clientHeight).toBeGreaterThan(
|
|
132
|
+
truncatedHeight,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
await expect(
|
|
136
|
+
wrapped.badge.getBoundingClientRect().width,
|
|
137
|
+
).toBeLessThanOrEqual(
|
|
138
|
+
wrapped.container.getBoundingClientRect().width,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
// Nothing is hidden in this mode, so nothing needs a tooltip.
|
|
142
|
+
await waitFor(() => {
|
|
143
|
+
expect(wrapped.badge).not.toHaveAttribute("title")
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test("the responsive board shows both modes at three widths", async () => {
|
|
148
|
+
const { canvas } = await mountStory(Responsive)
|
|
149
|
+
|
|
150
|
+
await expect(
|
|
151
|
+
canvas.getAllByText(LONG_LABEL),
|
|
152
|
+
).toHaveLength(6)
|
|
153
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
2
|
+
import type {
|
|
3
|
+
ComponentPropsWithRef,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from "react"
|
|
6
|
+
|
|
7
|
+
import type { BadgeSize } from "../controlStyles.ts"
|
|
8
|
+
import { BADGE_SIZE_CLASS } from "../controlStyles.ts"
|
|
9
|
+
import type { IntentAppearance } from "../intentStyles.ts"
|
|
10
|
+
import { INTENT_APPEARANCE_CLASS } from "../intentStyles.ts"
|
|
11
|
+
import { toClassName } from "../toClassName.ts"
|
|
12
|
+
import { useIsTextClipped } from "./useIsTextClipped.ts"
|
|
13
|
+
|
|
14
|
+
export type BadgeProps = ComponentPropsWithRef<"span"> & {
|
|
15
|
+
appearance?: Exclude<IntentAppearance, "ghost">
|
|
16
|
+
children: ReactNode
|
|
17
|
+
icon?: ReactNode
|
|
18
|
+
intent?: IntentName
|
|
19
|
+
/**
|
|
20
|
+
* What happens when the label is wider than the space it is given.
|
|
21
|
+
*
|
|
22
|
+
* - `truncate` — one line, capped at the container, ellipsis. The
|
|
23
|
+
* default, because a status pill that changes its row's height
|
|
24
|
+
* when a message gets longer breaks the table it sits in.
|
|
25
|
+
* - `wrap` — the pill grows taller and shows everything. For the
|
|
26
|
+
* kiosk and any touch context, where the hover readout that
|
|
27
|
+
* `truncate` relies on does not exist.
|
|
28
|
+
*/
|
|
29
|
+
overflow?: "truncate" | "wrap"
|
|
30
|
+
size?: BadgeSize
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Four repos, and rip-deck declares the **identical** `TONE_CLASS`
|
|
35
|
+
* map twice — `VerdictBadge.tsx` and `TowerAlerts.tsx`, same five
|
|
36
|
+
* hardcoded hexes, no relationship to each other beyond
|
|
37
|
+
* copy-paste. mux-magic's `statusClassMap` is the same idea spelled
|
|
38
|
+
* a third way, keyed by a bare `string` so an unrecognised status
|
|
39
|
+
* renders with no colour at all and no error.
|
|
40
|
+
*
|
|
41
|
+
* `intent` replaces all of it. The **status** half of the problem —
|
|
42
|
+
* which state maps to which intent, and what it is called in
|
|
43
|
+
* English — lives in `statusIntent.ts` as exhaustive switches over
|
|
44
|
+
* `@charcuterie/logic`'s shared machines, so
|
|
45
|
+
* `<Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>`
|
|
46
|
+
* is the whole of mux-magic's `StatusBadge`, and adding a state to
|
|
47
|
+
* the machine is a compile error rather than a blank pill.
|
|
48
|
+
*
|
|
49
|
+
* **No implicit role.** A badge is a word about something else; it
|
|
50
|
+
* is not a live region, and giving it `role="status"` would make
|
|
51
|
+
* every re-render announce itself. Where a status genuinely needs
|
|
52
|
+
* announcing that is `LiveStatusIndicator`'s job, and where a badge
|
|
53
|
+
* needs a name beyond its text, `aria-label` still works. The story
|
|
54
|
+
* asserts the text is queryable, which is what an agent actually
|
|
55
|
+
* matches on.
|
|
56
|
+
*
|
|
57
|
+
* **It is capped at its container.** `shrink-0` and
|
|
58
|
+
* `whitespace-nowrap` alone let a long label paint straight through
|
|
59
|
+
* the neighbouring column — no clipping, no error, just a pill lying
|
|
60
|
+
* across the next cell. `max-inline-size: 100%` is what stops it, and
|
|
61
|
+
* `overflow` is what happens next.
|
|
62
|
+
*/
|
|
63
|
+
export const Badge = ({
|
|
64
|
+
appearance = "soft",
|
|
65
|
+
children,
|
|
66
|
+
className,
|
|
67
|
+
icon,
|
|
68
|
+
intent = "neutral",
|
|
69
|
+
overflow = "truncate",
|
|
70
|
+
size = "md",
|
|
71
|
+
title,
|
|
72
|
+
...spanProps
|
|
73
|
+
}: BadgeProps): ReactNode => {
|
|
74
|
+
const [labelRef, clippedText] = useIsTextClipped()
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<span
|
|
78
|
+
{...spanProps}
|
|
79
|
+
className={toClassName(
|
|
80
|
+
"inline-flex shrink-0 items-center rounded-full border font-medium",
|
|
81
|
+
// The cap. Without it every other rule here is decoration.
|
|
82
|
+
"max-w-full",
|
|
83
|
+
overflow === "wrap"
|
|
84
|
+
? // A stadium end-cap on a three-line box reads as a
|
|
85
|
+
// rendering fault rather than a badge, so the wrapping
|
|
86
|
+
// pill relaxes to a rounded rectangle.
|
|
87
|
+
"rounded-2xl"
|
|
88
|
+
: "rounded-full",
|
|
89
|
+
BADGE_SIZE_CLASS[size],
|
|
90
|
+
INTENT_APPEARANCE_CLASS[intent][appearance],
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
// Only when something is actually hidden. A tooltip on every
|
|
94
|
+
// short pill in a bay list is noise, and a `title` that
|
|
95
|
+
// duplicates fully-visible text is a screen-reader duplicate
|
|
96
|
+
// for no gain.
|
|
97
|
+
title={title ?? clippedText}
|
|
98
|
+
>
|
|
99
|
+
{icon ? (
|
|
100
|
+
<span
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
className="contents shrink-0"
|
|
103
|
+
>
|
|
104
|
+
{icon}
|
|
105
|
+
</span>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
{/*
|
|
109
|
+
* The truncation happens here rather than on the pill so the
|
|
110
|
+
* border and the rounded end-cap stay outside the clip — an
|
|
111
|
+
* `overflow: hidden` on the pill itself squares off the end
|
|
112
|
+
* the ellipsis is nearest to.
|
|
113
|
+
*
|
|
114
|
+
* `min-w-0` because a flex item's automatic minimum size is
|
|
115
|
+
* its content, which would win against `max-w-full` on the
|
|
116
|
+
* parent and put the overflow back.
|
|
117
|
+
*/}
|
|
118
|
+
<span
|
|
119
|
+
className={
|
|
120
|
+
overflow === "wrap"
|
|
121
|
+
? "min-w-0"
|
|
122
|
+
: "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
|
123
|
+
}
|
|
124
|
+
ref={labelRef}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</span>
|
|
128
|
+
</span>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { RefObject } from "react"
|
|
2
|
+
import { useEffect, useRef, useState } from "react"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The element's full text, but only while that text is wider than
|
|
6
|
+
* the box it was given — i.e. only while an ellipsis is being
|
|
7
|
+
* painted. `undefined` the rest of the time.
|
|
8
|
+
*
|
|
9
|
+
* `text-overflow: ellipsis` leaves no trace in the DOM: the glyph is
|
|
10
|
+
* painted by the layout engine and the whole string stays in
|
|
11
|
+
* `textContent`. That is the good news — selection, copy, and every
|
|
12
|
+
* screen reader still get all of it — and the reason this hook has
|
|
13
|
+
* to exist, because nothing in the DOM says "this one is cut off",
|
|
14
|
+
* so a hover affordance cannot be attached declaratively.
|
|
15
|
+
*
|
|
16
|
+
* Measured rather than assumed, since the alternative — always
|
|
17
|
+
* setting a `title` — puts a tooltip on every short status pill in a
|
|
18
|
+
* bay list, which is worse than the problem it solves.
|
|
19
|
+
*
|
|
20
|
+
* The text is returned rather than a boolean so the caller never
|
|
21
|
+
* reads `ref.current` during render.
|
|
22
|
+
*/
|
|
23
|
+
export const useIsTextClipped = (): [
|
|
24
|
+
RefObject<HTMLSpanElement | null>,
|
|
25
|
+
string | undefined,
|
|
26
|
+
] => {
|
|
27
|
+
const elementRef = useRef<HTMLSpanElement>(null)
|
|
28
|
+
|
|
29
|
+
const [clippedText, setClippedText] = useState<
|
|
30
|
+
string | undefined
|
|
31
|
+
>(undefined)
|
|
32
|
+
|
|
33
|
+
// No dependency array: `children` is a `ReactNode`, so there is
|
|
34
|
+
// nothing stable to compare against, and a re-render is exactly
|
|
35
|
+
// when the text may have changed. `setState` bails out on an equal
|
|
36
|
+
// value, so the common case costs one `scrollWidth` read.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const element = elementRef.current
|
|
39
|
+
|
|
40
|
+
if (!element) {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setClippedText(
|
|
45
|
+
element.scrollWidth > element.clientWidth
|
|
46
|
+
? (element.textContent ?? undefined)
|
|
47
|
+
: undefined,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// And a resize of the *container* changes the answer without
|
|
52
|
+
// re-rendering anything — which is the container-query case this
|
|
53
|
+
// whole library is built around.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const element = elementRef.current
|
|
56
|
+
|
|
57
|
+
if (!element) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const observer = new ResizeObserver(() => {
|
|
62
|
+
setClippedText(
|
|
63
|
+
element.scrollWidth > element.clientWidth
|
|
64
|
+
? (element.textContent ?? undefined)
|
|
65
|
+
: undefined,
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
observer.observe(element)
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
observer.disconnect()
|
|
73
|
+
}
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
return [elementRef, clippedText]
|
|
77
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as ButtonStories from "./Button.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={ButtonStories} />
|
|
6
|
+
|
|
7
|
+
# Button
|
|
8
|
+
|
|
9
|
+
Hand-rolled in **seven repos**, each with its own primary/secondary/danger/ghost pair and
|
|
10
|
+
its own idea of what "disabled" looks like. This is the single largest duplication in the
|
|
11
|
+
fleet.
|
|
12
|
+
|
|
13
|
+
Six intents x four appearances x three sizes, all of it from tier-2 tokens — so a variant
|
|
14
|
+
swap or a density flip repaints every button on the page with no prop change and no
|
|
15
|
+
re-render.
|
|
16
|
+
|
|
17
|
+
<Canvas of={ButtonStories.Default} />
|
|
18
|
+
|
|
19
|
+
## Props
|
|
20
|
+
|
|
21
|
+
<Controls />
|
|
22
|
+
|
|
23
|
+
## What it fixes
|
|
24
|
+
|
|
25
|
+
| Fleet behaviour today | Here |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `<button>` with no `type`, submitting the form it happens to sit in | `type="button"` by default |
|
|
28
|
+
| `outline-none` with nothing put back, or a ring on mouse click | `focus-visible` only, at the variant's own ring width |
|
|
29
|
+
| A spinner that replaces the label, so you cannot tell what you pressed | Spinner replaces `iconStart`; the label stays, `aria-busy` goes on |
|
|
30
|
+
| A `size="kiosk"` prop, or a second component for touch | `data-density="kiosk"` on `<html>` |
|
|
31
|
+
|
|
32
|
+
## Accessibility contract
|
|
33
|
+
|
|
34
|
+
- The accessible name is the children. A glyph is not a name — use `IconButton`, which
|
|
35
|
+
requires one.
|
|
36
|
+
- `isLoading` disables the button and announces through the `Spinner`'s `role="status"`.
|
|
37
|
+
- `isDisabled` renders a real `disabled` attribute: skipped by Tab, ignored by click, and
|
|
38
|
+
reported to assistive technology. The `DisabledDoesNotFire` story asserts all three.
|
|
39
|
+
- Contrast on the disabled treatment leans on WCAG 1.4.3's exemption for *inactive*
|
|
40
|
+
controls, which is honest only on something actually disabled — see `DISABLED_CLASS`.
|
|
41
|
+
|
|
42
|
+
<Canvas of={ButtonStories.AllVariants} />
|
|
43
|
+
|
|
44
|
+
<Canvas of={ButtonStories.AllStates} />
|