@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,208 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } 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 "./SegmentedControl.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, Default, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `querySelectorAll` rather than `getAllByRole("radio")`, and not
|
|
15
|
+
* for typing convenience: a board renders several groups, and an
|
|
16
|
+
* assertion about "exactly one tab stop" is only the roving rule
|
|
17
|
+
* when it is scoped to **one** group.
|
|
18
|
+
*/
|
|
19
|
+
const getOptions = (group: HTMLElement) =>
|
|
20
|
+
Array.from(
|
|
21
|
+
group.querySelectorAll<HTMLButtonElement>(
|
|
22
|
+
'[role="radio"]',
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
test("it is a radio group with exactly one option checked", async () => {
|
|
27
|
+
const { canvas, canvasElement } =
|
|
28
|
+
await mountStory(Default)
|
|
29
|
+
|
|
30
|
+
const group = expectAgentDrivable(canvas, {
|
|
31
|
+
name: "Columns",
|
|
32
|
+
role: "radiogroup",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const options = getOptions(group)
|
|
36
|
+
|
|
37
|
+
await expect(options).toHaveLength(5)
|
|
38
|
+
|
|
39
|
+
await waitFor(() => {
|
|
40
|
+
expect(
|
|
41
|
+
options.filter(
|
|
42
|
+
(one) =>
|
|
43
|
+
one.getAttribute("aria-checked") === "true",
|
|
44
|
+
),
|
|
45
|
+
).toHaveLength(1)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// The state kind's invariant, visible in the DOM: `SinglePicker`
|
|
49
|
+
// holds one intent field, so "at most one checked" is true by
|
|
50
|
+
// representation rather than by bookkeeping.
|
|
51
|
+
await expect(group).toContainElement(options[0] ?? null)
|
|
52
|
+
|
|
53
|
+
await expectNoAxeViolations(canvasElement)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("Tab enters the group once, and the arrows move within it", async () => {
|
|
57
|
+
const { canvas } = await mountStory(Interactive)
|
|
58
|
+
|
|
59
|
+
const options = getOptions(
|
|
60
|
+
expectAgentDrivable(canvas, {
|
|
61
|
+
name: "Columns",
|
|
62
|
+
role: "radiogroup",
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Zero tab stops strands the widget; several mean the roving
|
|
67
|
+
// pattern was never implemented. Exactly one is the rule, read
|
|
68
|
+
// straight out of `selectTabIndex`.
|
|
69
|
+
await expect(
|
|
70
|
+
options.filter((one) => one.tabIndex === 0),
|
|
71
|
+
).toHaveLength(1)
|
|
72
|
+
|
|
73
|
+
await userEvent.tab()
|
|
74
|
+
|
|
75
|
+
await waitFor(() => {
|
|
76
|
+
expect(options[0]).toHaveFocus()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await userEvent.keyboard("{ArrowRight}")
|
|
80
|
+
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(options[1]).toHaveFocus()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The radio-group rule, and the thing that makes this component a
|
|
88
|
+
* different composition from `Tabs` rather than a restyle of it:
|
|
89
|
+
* moving focus **checks**. `Tabs` has two activation modes because a
|
|
90
|
+
* panel can cost a network request; there is nothing behind an
|
|
91
|
+
* option here to be expensive.
|
|
92
|
+
*/
|
|
93
|
+
test("selection follows focus, and wraps", async () => {
|
|
94
|
+
const { canvas } = await mountStory(Interactive)
|
|
95
|
+
|
|
96
|
+
const options = getOptions(
|
|
97
|
+
expectAgentDrivable(canvas, {
|
|
98
|
+
name: "Columns",
|
|
99
|
+
role: "radiogroup",
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
options[0]?.focus()
|
|
104
|
+
|
|
105
|
+
await userEvent.keyboard("{ArrowRight}")
|
|
106
|
+
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(options[1]).toHaveAttribute(
|
|
109
|
+
"aria-checked",
|
|
110
|
+
"true",
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await expect(options[0]).toHaveAttribute(
|
|
115
|
+
"aria-checked",
|
|
116
|
+
"false",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Home and End, then wrapping — all three from `RovingFocus`,
|
|
120
|
+
// none of them written here.
|
|
121
|
+
await userEvent.keyboard("{End}")
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(options[4]).toHaveAttribute(
|
|
125
|
+
"aria-checked",
|
|
126
|
+
"true",
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await userEvent.keyboard("{ArrowRight}")
|
|
131
|
+
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(options[0]).toHaveAttribute(
|
|
134
|
+
"aria-checked",
|
|
135
|
+
"true",
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("a pointer press checks the option it landed on", async () => {
|
|
141
|
+
const { canvas } = await mountStory(Interactive)
|
|
142
|
+
|
|
143
|
+
const four = expectAgentDrivable(canvas, {
|
|
144
|
+
name: "4",
|
|
145
|
+
role: "radio",
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
await userEvent.click(four)
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(four).toHaveAttribute("aria-checked", "true")
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("the arrow keys skip a disabled option", async () => {
|
|
156
|
+
const { canvas, canvasElement } =
|
|
157
|
+
await mountStory(AllStates)
|
|
158
|
+
|
|
159
|
+
const group = expectAgentDrivable(canvas, {
|
|
160
|
+
name: "Columns with 2 unavailable",
|
|
161
|
+
role: "radiogroup",
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const options = getOptions(group)
|
|
165
|
+
|
|
166
|
+
const [first, , two, three] = options as [
|
|
167
|
+
HTMLButtonElement,
|
|
168
|
+
HTMLButtonElement,
|
|
169
|
+
HTMLButtonElement,
|
|
170
|
+
HTMLButtonElement,
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
await expect(two).toBeDisabled()
|
|
174
|
+
|
|
175
|
+
first.focus()
|
|
176
|
+
|
|
177
|
+
await userEvent.keyboard("{ArrowRight}{ArrowRight}")
|
|
178
|
+
|
|
179
|
+
// Two presses from `auto` lands on `3`, not on the disabled `2` —
|
|
180
|
+
// registration is membership, and a disabled option never joined
|
|
181
|
+
// the focus group.
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(three).toHaveFocus()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
await expect(two).not.toHaveFocus()
|
|
187
|
+
|
|
188
|
+
await expectNoAxeViolations(canvasElement)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test("selectedValue decides the first render and nothing after", async () => {
|
|
192
|
+
const { canvas } = await mountStory(AllStates)
|
|
193
|
+
|
|
194
|
+
const group = expectAgentDrivable(canvas, {
|
|
195
|
+
name: "Columns starting on 3",
|
|
196
|
+
role: "radiogroup",
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// A controlled prop is the thing this library refuses to have —
|
|
200
|
+
// and reading it on the first paint is why the component falls
|
|
201
|
+
// back to `pendingValue`: members register from an effect, so
|
|
202
|
+
// `selectedValue` is still null when the group first paints.
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(
|
|
205
|
+
group.querySelector('[aria-checked="true"]'),
|
|
206
|
+
).toHaveTextContent("3")
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
selectTabIndex,
|
|
3
|
+
useRovingFocus,
|
|
4
|
+
useSinglePicker,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
7
|
+
import type { ReactNode } from "react"
|
|
8
|
+
import { useCallback, useEffect, useRef } from "react"
|
|
9
|
+
|
|
10
|
+
import { toClassName } from "../toClassName.ts"
|
|
11
|
+
import { SegmentedOption } from "./SegmentedOption.tsx"
|
|
12
|
+
|
|
13
|
+
export type SegmentedItem = {
|
|
14
|
+
/**
|
|
15
|
+
* A disabled option is simply **not registered** with the roving
|
|
16
|
+
* group, so the arrow keys skip it without any command in
|
|
17
|
+
* `RovingFocus` having to know what "disabled" means. It stays
|
|
18
|
+
* registered with the picker: it is still one of the options.
|
|
19
|
+
*/
|
|
20
|
+
isDisabled?: boolean
|
|
21
|
+
label: ReactNode
|
|
22
|
+
value: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SegmentedControlProps = {
|
|
26
|
+
className?: string
|
|
27
|
+
items: readonly SegmentedItem[]
|
|
28
|
+
/** The group's accessible name. Required. */
|
|
29
|
+
label: string
|
|
30
|
+
onChange?: (selectedValue: string | null) => void
|
|
31
|
+
/** **Initial** only. Charcuterie owns it from then on. */
|
|
32
|
+
selectedValue?: string
|
|
33
|
+
size?: ControlSize
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GAP_CLASS: Record<ControlSize, string> = {
|
|
37
|
+
sm: "gap-0.5",
|
|
38
|
+
md: "gap-1",
|
|
39
|
+
lg: "gap-1",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One choice out of a handful, all of them on screen at once.
|
|
44
|
+
*
|
|
45
|
+
* This is `Tabs`' composition — `SinglePicker` + `RovingFocus` —
|
|
46
|
+
* with the panels taken away, and it exists because taking the
|
|
47
|
+
* panels away used to be impossible. Under M4's model a tab bar's
|
|
48
|
+
* selection *was* a group of visibilities, so a control that chooses
|
|
49
|
+
* something without revealing anything had nothing to reuse and
|
|
50
|
+
* hand-rolled its own state. rip-deck's `ColumnPicker` is that
|
|
51
|
+
* hand-roll
|
|
52
|
+
* ([decision](../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md)).
|
|
53
|
+
*
|
|
54
|
+
* ### `radiogroup`, not a row of pressed buttons
|
|
55
|
+
*
|
|
56
|
+
* rip-deck's version is five `<button aria-pressed>` in a
|
|
57
|
+
* `<fieldset>`, which is the **toolbar of independent toggles**
|
|
58
|
+
* pattern: nothing in it says the five are mutually exclusive, so a
|
|
59
|
+
* screen reader announces "auto, pressed" with no "1 of 5" and no
|
|
60
|
+
* clue that pressing another un-presses this one. A single choice
|
|
61
|
+
* from a named set is a radio group, and ARIA has a pattern for it.
|
|
62
|
+
*
|
|
63
|
+
* ### Selection follows focus, and here that is correct
|
|
64
|
+
*
|
|
65
|
+
* `Tabs` has two activation modes because a tab panel can cost a
|
|
66
|
+
* network request. A radio group has one: APG says the arrow keys
|
|
67
|
+
* both move focus and check, and there is nothing behind an option
|
|
68
|
+
* here to be expensive. So the mode is `automatic` and there is no
|
|
69
|
+
* prop — a `manual` segmented control would be a control the arrow
|
|
70
|
+
* keys appear to do nothing in.
|
|
71
|
+
*
|
|
72
|
+
* The two kinds are still separate underneath, which is what makes
|
|
73
|
+
* Space's behaviour trivial rather than a special case: it checks
|
|
74
|
+
* whatever focus is on, whether or not focus moved there by arrow.
|
|
75
|
+
*/
|
|
76
|
+
export const SegmentedControl = ({
|
|
77
|
+
className,
|
|
78
|
+
items,
|
|
79
|
+
label,
|
|
80
|
+
onChange,
|
|
81
|
+
selectedValue,
|
|
82
|
+
size = "md",
|
|
83
|
+
}: SegmentedControlProps): ReactNode => {
|
|
84
|
+
const groupRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
|
|
86
|
+
const optionElements = useRef(
|
|
87
|
+
new Map<string, HTMLButtonElement>(),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const initialValue =
|
|
91
|
+
selectedValue ??
|
|
92
|
+
items.find((one) => !one.isDisabled)?.value ??
|
|
93
|
+
null
|
|
94
|
+
|
|
95
|
+
const selection = useSinglePicker<string>({
|
|
96
|
+
onChange,
|
|
97
|
+
selectedValue: initialValue,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const focus = useRovingFocus<string>({
|
|
101
|
+
activeValue: initialValue,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const { select } = selection
|
|
105
|
+
|
|
106
|
+
const { activeValue, setActiveValue } = focus
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Selected **or** pending. Members register from an effect, so on
|
|
110
|
+
* the first paint `selectedValue` is still null and the intent
|
|
111
|
+
* lives in `pendingValue` — reading only the former renders a
|
|
112
|
+
* group with nothing checked, then corrects itself a frame later.
|
|
113
|
+
* A flash on a desktop; a visible blank on a kiosk Pi.
|
|
114
|
+
*/
|
|
115
|
+
const checkedValue =
|
|
116
|
+
selection.selectedValue ?? selection.pendingValue
|
|
117
|
+
|
|
118
|
+
const chooseOption = useCallback(
|
|
119
|
+
(value: string) => {
|
|
120
|
+
setActiveValue(value)
|
|
121
|
+
|
|
122
|
+
select(value)
|
|
123
|
+
},
|
|
124
|
+
[select, setActiveValue],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
// Selection follows focus — the radio-group rule, and the one
|
|
129
|
+
// line that would be absent in a `manual` mode this component
|
|
130
|
+
// deliberately does not have.
|
|
131
|
+
if (activeValue !== null) {
|
|
132
|
+
select(activeValue)
|
|
133
|
+
}
|
|
134
|
+
}, [activeValue, select])
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const group = groupRef.current
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
!group ||
|
|
141
|
+
activeValue === null ||
|
|
142
|
+
!group.contains(document.activeElement)
|
|
143
|
+
) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
optionElements.current.get(activeValue)?.focus()
|
|
148
|
+
}, [activeValue])
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
aria-label={label}
|
|
153
|
+
className={toClassName(
|
|
154
|
+
"inline-flex items-center rounded-md border border-border-subtle bg-surface-sunken p-0.5",
|
|
155
|
+
GAP_CLASS[size],
|
|
156
|
+
className,
|
|
157
|
+
)}
|
|
158
|
+
onKeyDown={(keyEvent) => {
|
|
159
|
+
const commands: Record<string, () => void> = {
|
|
160
|
+
ArrowDown: focus.next,
|
|
161
|
+
ArrowLeft: focus.previous,
|
|
162
|
+
ArrowRight: focus.next,
|
|
163
|
+
ArrowUp: focus.previous,
|
|
164
|
+
End: focus.last,
|
|
165
|
+
Home: focus.first,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const command = commands[keyEvent.key]
|
|
169
|
+
|
|
170
|
+
if (command) {
|
|
171
|
+
// Arrow keys scroll the page by default, and a control
|
|
172
|
+
// that scrolls the window while it moves focus is the
|
|
173
|
+
// hand-rolled version of this component.
|
|
174
|
+
keyEvent.preventDefault()
|
|
175
|
+
|
|
176
|
+
command()
|
|
177
|
+
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Space checks the focused option. In a group where
|
|
182
|
+
// selection already follows focus this is almost always a
|
|
183
|
+
// no-op — which is the point: it is one command, not a
|
|
184
|
+
// second code path, because `select` is idempotent.
|
|
185
|
+
if (keyEvent.key === " " && activeValue !== null) {
|
|
186
|
+
keyEvent.preventDefault()
|
|
187
|
+
|
|
188
|
+
select(activeValue)
|
|
189
|
+
}
|
|
190
|
+
}}
|
|
191
|
+
ref={groupRef}
|
|
192
|
+
role="radiogroup"
|
|
193
|
+
>
|
|
194
|
+
{items.map((item) => (
|
|
195
|
+
<SegmentedOption
|
|
196
|
+
isChecked={checkedValue === item.value}
|
|
197
|
+
item={item}
|
|
198
|
+
key={item.value}
|
|
199
|
+
onChoose={chooseOption}
|
|
200
|
+
registerFocus={focus.register}
|
|
201
|
+
registerSelection={selection.register}
|
|
202
|
+
size={size}
|
|
203
|
+
// The roving-tabindex rule read from the core rather than
|
|
204
|
+
// restated here: exactly one option is in the tab order,
|
|
205
|
+
// so Tab enters and leaves the group once while the arrow
|
|
206
|
+
// keys move inside it.
|
|
207
|
+
tabIndex={selectTabIndex(focus, item.value)}
|
|
208
|
+
trackElement={(value, element) => {
|
|
209
|
+
if (element) {
|
|
210
|
+
optionElements.current.set(value, element)
|
|
211
|
+
} else {
|
|
212
|
+
optionElements.current.delete(value)
|
|
213
|
+
}
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
|
|
5
|
+
import { CONTROL_SIZE_CLASS } from "../controlStyles.ts"
|
|
6
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
7
|
+
import { toClassName } from "../toClassName.ts"
|
|
8
|
+
import type { SegmentedItem } from "./SegmentedControl.tsx"
|
|
9
|
+
|
|
10
|
+
export type SegmentedOptionProps = {
|
|
11
|
+
isChecked: boolean
|
|
12
|
+
item: SegmentedItem
|
|
13
|
+
onChoose: (value: string) => void
|
|
14
|
+
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
15
|
+
registerFocus: (value: string) => () => void
|
|
16
|
+
/** `SinglePicker.register` — membership of the choice. */
|
|
17
|
+
registerSelection: (value: string) => () => void
|
|
18
|
+
size: ControlSize
|
|
19
|
+
tabIndex: number
|
|
20
|
+
trackElement: (
|
|
21
|
+
value: string,
|
|
22
|
+
element: HTMLButtonElement | null,
|
|
23
|
+
) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Its own file for the same reason `TabTrigger` is: **both
|
|
28
|
+
* registrations are effects**, and an effect cannot run in a loop.
|
|
29
|
+
* Any member of a registering group ends up shaped like this.
|
|
30
|
+
*
|
|
31
|
+
* The checked option is filled rather than merely tinted. A
|
|
32
|
+
* segmented control's whole job is to be readable at a glance from
|
|
33
|
+
* across a room — rip-deck's lives above a nine-bay grid on a wall
|
|
34
|
+
* display — and a one-shade difference does not survive that or a
|
|
35
|
+
* `daylight` theme in sunlight.
|
|
36
|
+
*/
|
|
37
|
+
export const SegmentedOption = ({
|
|
38
|
+
isChecked,
|
|
39
|
+
item,
|
|
40
|
+
onChoose,
|
|
41
|
+
registerFocus,
|
|
42
|
+
registerSelection,
|
|
43
|
+
size,
|
|
44
|
+
tabIndex,
|
|
45
|
+
trackElement,
|
|
46
|
+
}: SegmentedOptionProps): ReactNode => {
|
|
47
|
+
const { isDisabled = false, label, value } = item
|
|
48
|
+
|
|
49
|
+
useEffect(
|
|
50
|
+
() => registerSelection(value),
|
|
51
|
+
[registerSelection, value],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (isDisabled) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return registerFocus(value)
|
|
60
|
+
}, [isDisabled, registerFocus, value])
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
// biome-ignore lint/a11y/useSemanticElements: `<input type="radio">` owns `checked` *and* owns arrow-key navigation within a `name` group. That is the self-owning-control conflict `Modal` already has with `<dialog>`, except here it collides with both state kinds at once — the browser and `SinglePicker` would each believe they hold the choice, and native roving would fight `RovingFocus`. APG's radio-group pattern exists for exactly this case.
|
|
64
|
+
<button
|
|
65
|
+
aria-checked={isChecked}
|
|
66
|
+
className={toClassName(
|
|
67
|
+
"cursor-pointer rounded-sm border border-transparent font-medium tabular-nums transition-colors duration-(--duration-fast) ease-standard",
|
|
68
|
+
CONTROL_SIZE_CLASS[size],
|
|
69
|
+
// Two entries rather than one interpolated string:
|
|
70
|
+
// `tailwindCandidates.test.ts` rejects a template literal in
|
|
71
|
+
// a className outright, because Tailwind's scanner cannot
|
|
72
|
+
// see a class that only exists at runtime.
|
|
73
|
+
isChecked
|
|
74
|
+
? "bg-intent-accent-solid text-intent-accent-on-solid"
|
|
75
|
+
: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
|
|
76
|
+
isDisabled &&
|
|
77
|
+
"cursor-not-allowed text-content-disabled hover:bg-transparent hover:text-content-disabled",
|
|
78
|
+
FOCUS_RING_CLASS,
|
|
79
|
+
)}
|
|
80
|
+
disabled={isDisabled}
|
|
81
|
+
onClick={() => {
|
|
82
|
+
onChoose(value)
|
|
83
|
+
}}
|
|
84
|
+
ref={(element) => {
|
|
85
|
+
trackElement(value, element)
|
|
86
|
+
}}
|
|
87
|
+
role="radio"
|
|
88
|
+
tabIndex={tabIndex}
|
|
89
|
+
type="button"
|
|
90
|
+
>
|
|
91
|
+
{label}
|
|
92
|
+
</button>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as SkeletonStories from "./Skeleton.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={SkeletonStories} />
|
|
6
|
+
|
|
7
|
+
# Skeleton
|
|
8
|
+
|
|
9
|
+
**Also zero fleet-wide.** The counterpart to `Spinner`, and the better answer wherever the
|
|
10
|
+
shape of the incoming content is already known: a poster grid that skeletons does not
|
|
11
|
+
reflow when the images land, and layout shift is what users actually notice.
|
|
12
|
+
|
|
13
|
+
<Canvas of={SkeletonStories.AllVariants} />
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
<Controls />
|
|
18
|
+
|
|
19
|
+
## `aria-hidden` is the contract, not an optimisation
|
|
20
|
+
|
|
21
|
+
A skeleton is decoration standing in for content that does not exist yet. Announcing it
|
|
22
|
+
means a screen reader reading three empty bars, and an agent finding a "loading" element it
|
|
23
|
+
cannot act on.
|
|
24
|
+
|
|
25
|
+
**The load has to be announced by the region that owns it** — a `Spinner`, or `aria-busy`
|
|
26
|
+
on the container. The `Default` story asserts the skeleton is hidden from assistive
|
|
27
|
+
technology, and `Interactive` asserts it contributes no roles at all. Both are assertions
|
|
28
|
+
axe cannot make for you: there is nothing invalid about a visible placeholder.
|
|
29
|
+
|
|
30
|
+
<Canvas of={SkeletonStories.AllStates} />
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ContainerBoard,
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
StorySection,
|
|
8
|
+
} from "../board.storyHelpers.tsx"
|
|
9
|
+
import { Card } from "../Card/Card.tsx"
|
|
10
|
+
import { Skeleton } from "./Skeleton.tsx"
|
|
11
|
+
|
|
12
|
+
const meta = {
|
|
13
|
+
title: "Components/Skeleton",
|
|
14
|
+
component: Skeleton,
|
|
15
|
+
parameters: { layout: "padded" },
|
|
16
|
+
args: { lineCount: 1, shape: "block" },
|
|
17
|
+
} satisfies Meta<typeof Skeleton>
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof meta>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The inverse of every other component's gate. A skeleton is
|
|
25
|
+
* decoration standing in for content that does not exist yet, so it
|
|
26
|
+
* is hidden from AT entirely — otherwise a screen reader reads three
|
|
27
|
+
* empty bars.
|
|
28
|
+
*/
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: { blockSize: "1.5rem", inlineSize: "12rem" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const AllVariants: Story = {
|
|
34
|
+
args: {},
|
|
35
|
+
render: () => (
|
|
36
|
+
<StorySection title="Three shapes, and the multi-line text case — last line short, so a paragraph placeholder reads as prose rather than as a table.">
|
|
37
|
+
<StoryGrid columns={2}>
|
|
38
|
+
<StoryCell align="stretch" label="block">
|
|
39
|
+
<Skeleton blockSize="4rem" inlineSize="12rem" />
|
|
40
|
+
</StoryCell>
|
|
41
|
+
|
|
42
|
+
<StoryCell align="stretch" label="circle">
|
|
43
|
+
<Skeleton
|
|
44
|
+
blockSize="3rem"
|
|
45
|
+
inlineSize="3rem"
|
|
46
|
+
shape="circle"
|
|
47
|
+
/>
|
|
48
|
+
</StoryCell>
|
|
49
|
+
|
|
50
|
+
<StoryCell
|
|
51
|
+
align="stretch"
|
|
52
|
+
label="text (single line)"
|
|
53
|
+
>
|
|
54
|
+
<Skeleton inlineSize="14rem" shape="text" />
|
|
55
|
+
</StoryCell>
|
|
56
|
+
|
|
57
|
+
<StoryCell
|
|
58
|
+
align="stretch"
|
|
59
|
+
label="text (four lines)"
|
|
60
|
+
>
|
|
61
|
+
<Skeleton
|
|
62
|
+
inlineSize="14rem"
|
|
63
|
+
lineCount={4}
|
|
64
|
+
shape="text"
|
|
65
|
+
/>
|
|
66
|
+
</StoryCell>
|
|
67
|
+
</StoryGrid>
|
|
68
|
+
</StorySection>
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A skeleton has no interactive states — no hover, no focus, no
|
|
74
|
+
* disabled. What it has instead is a *fidelity* obligation: the
|
|
75
|
+
* placeholder has to occupy the same box the real content will, or
|
|
76
|
+
* the grid reflows when the data lands and the user loses their
|
|
77
|
+
* place. So this story is the comparison rather than a state grid.
|
|
78
|
+
*/
|
|
79
|
+
export const AllStates: Story = {
|
|
80
|
+
args: {},
|
|
81
|
+
render: () => (
|
|
82
|
+
<StoryGrid columns={2}>
|
|
83
|
+
<StoryCell align="stretch" label="loading">
|
|
84
|
+
<Card heading="Bay 3 — loading">
|
|
85
|
+
<Skeleton
|
|
86
|
+
inlineSize="100%"
|
|
87
|
+
lineCount={3}
|
|
88
|
+
shape="text"
|
|
89
|
+
/>
|
|
90
|
+
</Card>
|
|
91
|
+
</StoryCell>
|
|
92
|
+
|
|
93
|
+
<StoryCell align="stretch" label="loaded — same box">
|
|
94
|
+
<Card heading="Bay 3 — loaded">
|
|
95
|
+
<p className="text-content-secondary text-sm">
|
|
96
|
+
Ripping <em>Blade Runner (1982)</em> — title 4
|
|
97
|
+
of 9, AACS handshake complete, 38 minutes
|
|
98
|
+
remaining on this title.
|
|
99
|
+
</p>
|
|
100
|
+
</Card>
|
|
101
|
+
</StoryCell>
|
|
102
|
+
</StoryGrid>
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const Responsive: Story = {
|
|
107
|
+
args: {},
|
|
108
|
+
render: () => (
|
|
109
|
+
<ContainerBoard>
|
|
110
|
+
<Skeleton
|
|
111
|
+
inlineSize="100%"
|
|
112
|
+
lineCount={3}
|
|
113
|
+
shape="text"
|
|
114
|
+
/>
|
|
115
|
+
</ContainerBoard>
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Nothing to drive, and that is the point: a skeleton contributes no
|
|
121
|
+
* roles at all, so an agent sweeping the page never finds a control
|
|
122
|
+
* it cannot act on.
|
|
123
|
+
*/
|
|
124
|
+
export const Interactive: Story = {
|
|
125
|
+
args: {
|
|
126
|
+
inlineSize: "12rem",
|
|
127
|
+
lineCount: 3,
|
|
128
|
+
shape: "text",
|
|
129
|
+
},
|
|
130
|
+
}
|