@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,47 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
6
|
+
import { expectHiddenFromAgents } from "../testing/index.ts"
|
|
7
|
+
import * as stories from "./Skeleton.stories.tsx"
|
|
8
|
+
|
|
9
|
+
const { Default, Interactive } = composeStories(stories)
|
|
10
|
+
|
|
11
|
+
test("a skeleton is hidden from assistive technology", async () => {
|
|
12
|
+
const { canvasElement } = await mountStory(Default)
|
|
13
|
+
|
|
14
|
+
const skeleton = canvasElement.querySelector(
|
|
15
|
+
"div[aria-hidden]",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if (!skeleton) {
|
|
19
|
+
throw new Error("Skeleton did not render")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The inverse of every other component's gate: this one asserts
|
|
23
|
+
// the component is *not* reachable. A placeholder standing in for
|
|
24
|
+
// content that does not exist yet must not be read out as three
|
|
25
|
+
// empty bars.
|
|
26
|
+
expectHiddenFromAgents(skeleton)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("it contributes no roles at all", async () => {
|
|
30
|
+
const { canvas, canvasElement } =
|
|
31
|
+
await mountStory(Interactive)
|
|
32
|
+
|
|
33
|
+
await expect(
|
|
34
|
+
canvas.queryAllByRole("status"),
|
|
35
|
+
).toHaveLength(0)
|
|
36
|
+
|
|
37
|
+
await expect(
|
|
38
|
+
canvas.queryAllByRole("progressbar"),
|
|
39
|
+
).toHaveLength(0)
|
|
40
|
+
|
|
41
|
+
// `lineCount` really produces that many bars — the fidelity
|
|
42
|
+
// obligation, since a placeholder that occupies the wrong box
|
|
43
|
+
// reflows the grid when the data lands.
|
|
44
|
+
await expect(
|
|
45
|
+
canvasElement.querySelectorAll(".charcuterie-shimmer"),
|
|
46
|
+
).toHaveLength(3)
|
|
47
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentPropsWithRef,
|
|
3
|
+
ReactNode,
|
|
4
|
+
} from "react"
|
|
5
|
+
|
|
6
|
+
import { toClassName } from "../toClassName.ts"
|
|
7
|
+
|
|
8
|
+
export type SkeletonShape = "block" | "circle" | "text"
|
|
9
|
+
|
|
10
|
+
export type SkeletonProps = Omit<
|
|
11
|
+
ComponentPropsWithRef<"div">,
|
|
12
|
+
"children"
|
|
13
|
+
> & {
|
|
14
|
+
blockSize?: string
|
|
15
|
+
inlineSize?: string
|
|
16
|
+
/**
|
|
17
|
+
* Text skeletons render this many bars, the last one short —
|
|
18
|
+
* because a paragraph placeholder whose every line is full width
|
|
19
|
+
* reads as a table, not as prose.
|
|
20
|
+
*/
|
|
21
|
+
lineCount?: number
|
|
22
|
+
shape?: SkeletonShape
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SHAPE_CLASS: Record<SkeletonShape, string> = {
|
|
26
|
+
block: "rounded-md",
|
|
27
|
+
circle: "rounded-full",
|
|
28
|
+
text: "rounded-sm",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bars of a multi-line text skeleton, described rather than
|
|
33
|
+
* indexed.
|
|
34
|
+
*
|
|
35
|
+
* The last line is **60%**, fixed. Not random: a placeholder that
|
|
36
|
+
* differs between two renders of the same list makes every
|
|
37
|
+
* screenshot diff useless, and visual regression over P0 x scheme x
|
|
38
|
+
* density is a planned addition once tokens stop moving.
|
|
39
|
+
*/
|
|
40
|
+
const buildLines = (lineCount: number) =>
|
|
41
|
+
Array.from({ length: lineCount }, (_, index) => ({
|
|
42
|
+
id: `charcuterie-skeleton-line-${index + 1}`,
|
|
43
|
+
inlineSize: index === lineCount - 1 ? "60%" : undefined,
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* **Also zero of these fleet-wide.** The counterpart to `Spinner`
|
|
48
|
+
* and the better answer wherever the shape of the incoming content
|
|
49
|
+
* is already known: a poster grid that skeletons does not reflow
|
|
50
|
+
* when the images land, and layout shift is the thing users
|
|
51
|
+
* actually notice.
|
|
52
|
+
*
|
|
53
|
+
* `aria-hidden` is not an optimisation, it is the contract. A
|
|
54
|
+
* skeleton is decoration standing in for content that does not
|
|
55
|
+
* exist yet; announcing it means a screen reader reading three
|
|
56
|
+
* empty bars. **The load has to be announced by the region that
|
|
57
|
+
* owns it** — a `Spinner`, or an `aria-busy` on the container — and
|
|
58
|
+
* a skeleton that AT can see is a bug, which is why the story
|
|
59
|
+
* asserts it cannot be found by role.
|
|
60
|
+
*/
|
|
61
|
+
export const Skeleton = ({
|
|
62
|
+
blockSize,
|
|
63
|
+
className,
|
|
64
|
+
inlineSize,
|
|
65
|
+
lineCount = 1,
|
|
66
|
+
shape = "block",
|
|
67
|
+
style,
|
|
68
|
+
...divProps
|
|
69
|
+
}: SkeletonProps): ReactNode => {
|
|
70
|
+
const barClassName = toClassName(
|
|
71
|
+
"charcuterie-shimmer block bg-surface-sunken",
|
|
72
|
+
SHAPE_CLASS[shape],
|
|
73
|
+
shape === "text" && "h-[1em]",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (shape === "text" && lineCount > 1) {
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
{...divProps}
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
className={toClassName(
|
|
82
|
+
"flex flex-col gap-2",
|
|
83
|
+
className,
|
|
84
|
+
)}
|
|
85
|
+
style={{ inlineSize, ...style }}
|
|
86
|
+
>
|
|
87
|
+
{buildLines(lineCount).map((line) => (
|
|
88
|
+
<span
|
|
89
|
+
className={barClassName}
|
|
90
|
+
key={line.id}
|
|
91
|
+
style={{ inlineSize: line.inlineSize }}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
{...divProps}
|
|
101
|
+
aria-hidden="true"
|
|
102
|
+
className={toClassName(barClassName, className)}
|
|
103
|
+
style={{ blockSize, inlineSize, ...style }}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as SpinnerStories from "./Spinner.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={SpinnerStories} />
|
|
6
|
+
|
|
7
|
+
# Spinner
|
|
8
|
+
|
|
9
|
+
**Zero of these exist anywhere in the fleet.** Not one repo has a spinner; every app
|
|
10
|
+
renders bare `"loading…"` text. Best effort-to-payoff component in M3.
|
|
11
|
+
|
|
12
|
+
<Canvas of={SpinnerStories.Default} />
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
<Controls />
|
|
17
|
+
|
|
18
|
+
## Three things a hand-rolled `animate-spin` does not do
|
|
19
|
+
|
|
20
|
+
1. **It is a live region.** `role="status"` with the label inside, so the load is
|
|
21
|
+
announced. A rotating `<div>` announces nothing.
|
|
22
|
+
2. **It stops.** The animation runs at `--duration-loop-fast`, which
|
|
23
|
+
`prefers-reduced-motion` zeroes and `@charcuterie/ui/styles.css` then switches off
|
|
24
|
+
outright. Tailwind's `animate-spin` hardcodes 1s straight past the media query.
|
|
25
|
+
3. **It is findable.** `role="status"` takes **no accessible name from its content** — it
|
|
26
|
+
is a live region, not a widget — so the component sets `aria-label` as well. Without
|
|
27
|
+
that, `getByRole("status", { name: "Loading…" })` matches nothing, and axe never
|
|
28
|
+
mentions it.
|
|
29
|
+
|
|
30
|
+
## Reduced motion
|
|
31
|
+
|
|
32
|
+
There is deliberately no "reduced motion" story: it is a browser state, not a prop. Toggle
|
|
33
|
+
it (DevTools → Rendering → Emulate `prefers-reduced-motion`) against `AllStates` — the
|
|
34
|
+
ring must stop, stay a ring, and keep its label.
|
|
35
|
+
|
|
36
|
+
<Canvas of={SpinnerStories.AllStates} />
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import { Button } from "../Button/Button.tsx"
|
|
5
|
+
import {
|
|
6
|
+
ContainerBoard,
|
|
7
|
+
StoryCell,
|
|
8
|
+
StoryGrid,
|
|
9
|
+
StorySection,
|
|
10
|
+
} from "../board.storyHelpers.tsx"
|
|
11
|
+
import { Spinner } from "./Spinner.tsx"
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
title: "Components/Spinner",
|
|
15
|
+
component: Spinner,
|
|
16
|
+
parameters: { layout: "padded" },
|
|
17
|
+
argTypes: { size: controlSizeArgType },
|
|
18
|
+
args: {
|
|
19
|
+
isLabelVisible: false,
|
|
20
|
+
label: "Loading…",
|
|
21
|
+
size: "md",
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof Spinner>
|
|
24
|
+
|
|
25
|
+
export default meta
|
|
26
|
+
|
|
27
|
+
type Story = StoryObj<typeof meta>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The name comes from the *hidden* label, which is the whole reason
|
|
31
|
+
* this beats a rotating `<div>`: the state is announced even though
|
|
32
|
+
* nothing is printed.
|
|
33
|
+
*/
|
|
34
|
+
export const Default: Story = {
|
|
35
|
+
args: {},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const AllVariants: Story = {
|
|
39
|
+
args: {},
|
|
40
|
+
render: (spinnerProps) => (
|
|
41
|
+
<StorySection title="Three sizes, and inside a button — where `border-current` makes it inherit the button's own text colour with nothing passed in.">
|
|
42
|
+
<StoryGrid columns={4}>
|
|
43
|
+
<StoryCell label="sm">
|
|
44
|
+
<Spinner {...spinnerProps} size="sm" />
|
|
45
|
+
</StoryCell>
|
|
46
|
+
|
|
47
|
+
<StoryCell label="md">
|
|
48
|
+
<Spinner {...spinnerProps} size="md" />
|
|
49
|
+
</StoryCell>
|
|
50
|
+
|
|
51
|
+
<StoryCell label="lg">
|
|
52
|
+
<Spinner {...spinnerProps} size="lg" />
|
|
53
|
+
</StoryCell>
|
|
54
|
+
|
|
55
|
+
<StoryCell label="inside a solid button">
|
|
56
|
+
<Button isLoading>Ripping</Button>
|
|
57
|
+
</StoryCell>
|
|
58
|
+
</StoryGrid>
|
|
59
|
+
</StorySection>
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const AllStates: Story = {
|
|
64
|
+
args: {},
|
|
65
|
+
render: (spinnerProps) => (
|
|
66
|
+
<StoryGrid columns={3}>
|
|
67
|
+
<StoryCell label="label hidden (default)">
|
|
68
|
+
<Spinner {...spinnerProps} />
|
|
69
|
+
</StoryCell>
|
|
70
|
+
|
|
71
|
+
<StoryCell label="label visible">
|
|
72
|
+
<Spinner {...spinnerProps} isLabelVisible />
|
|
73
|
+
</StoryCell>
|
|
74
|
+
|
|
75
|
+
<StoryCell label="custom label">
|
|
76
|
+
<Spinner
|
|
77
|
+
{...spinnerProps}
|
|
78
|
+
isLabelVisible
|
|
79
|
+
label="Reading disc structure…"
|
|
80
|
+
/>
|
|
81
|
+
</StoryCell>
|
|
82
|
+
|
|
83
|
+
<StoryCell label="on a tinted surface">
|
|
84
|
+
<span className="inline-flex rounded-md bg-intent-info-surface p-2 text-intent-info-content">
|
|
85
|
+
<Spinner {...spinnerProps} />
|
|
86
|
+
</span>
|
|
87
|
+
</StoryCell>
|
|
88
|
+
</StoryGrid>
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* There is no "reduced motion" story, deliberately — it is a
|
|
94
|
+
* *browser* state, not a prop, so faking it here would prove
|
|
95
|
+
* nothing. Toggle the OS setting (or DevTools → Rendering → Emulate
|
|
96
|
+
* `prefers-reduced-motion`) against this board: the ring must stop
|
|
97
|
+
* and still read as a ring, and the label must still be announced.
|
|
98
|
+
*/
|
|
99
|
+
export const Responsive: Story = {
|
|
100
|
+
args: { isLabelVisible: true },
|
|
101
|
+
render: (spinnerProps) => (
|
|
102
|
+
<ContainerBoard>
|
|
103
|
+
<Spinner {...spinnerProps} />
|
|
104
|
+
</ContainerBoard>
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A named region with a visible label, and a ring that stays *out*
|
|
110
|
+
* of the accessibility tree — otherwise a screen reader reads the
|
|
111
|
+
* label twice, once as the region and once as an unnamed child.
|
|
112
|
+
*/
|
|
113
|
+
export const Interactive: Story = {
|
|
114
|
+
args: {
|
|
115
|
+
isLabelVisible: true,
|
|
116
|
+
label: "Reading disc structure…",
|
|
117
|
+
},
|
|
118
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
6
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
7
|
+
import * as stories from "./Spinner.stories.tsx"
|
|
8
|
+
|
|
9
|
+
const { Default, Interactive } = composeStories(stories)
|
|
10
|
+
|
|
11
|
+
test("the hidden label is the accessible name", async () => {
|
|
12
|
+
const { canvas } = await mountStory(Default)
|
|
13
|
+
|
|
14
|
+
// A live region takes **no** accessible name from its content, so
|
|
15
|
+
// `Spinner` sets both — text for the announcement, `aria-label`
|
|
16
|
+
// for the name. Without that this query matches nothing:
|
|
17
|
+
// announced, but unfindable, and axe stays silent about it.
|
|
18
|
+
expectAgentDrivable(canvas, {
|
|
19
|
+
name: "Loading…",
|
|
20
|
+
role: "status",
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("the ring is decoration and stays out of the tree", async () => {
|
|
25
|
+
const { canvas } = await mountStory(Interactive)
|
|
26
|
+
|
|
27
|
+
const status = expectAgentDrivable(canvas, {
|
|
28
|
+
name: "Reading disc structure…",
|
|
29
|
+
role: "status",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Otherwise a screen reader reads the label twice — once as the
|
|
33
|
+
// region, once as an unnamed child.
|
|
34
|
+
const ring = status.querySelector("[aria-hidden='true']")
|
|
35
|
+
|
|
36
|
+
await expect(ring).toBeInTheDocument()
|
|
37
|
+
await expect(ring).toHaveClass("charcuterie-spin")
|
|
38
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type {
|
|
3
|
+
ComponentPropsWithRef,
|
|
4
|
+
ReactNode,
|
|
5
|
+
} from "react"
|
|
6
|
+
|
|
7
|
+
import { SPINNER_SIZE_CLASS } from "../controlStyles.ts"
|
|
8
|
+
import { toClassName } from "../toClassName.ts"
|
|
9
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
10
|
+
|
|
11
|
+
export type SpinnerProps = Omit<
|
|
12
|
+
ComponentPropsWithRef<"span">,
|
|
13
|
+
"children"
|
|
14
|
+
> & {
|
|
15
|
+
/**
|
|
16
|
+
* Required by omission of a default that could be wrong.
|
|
17
|
+
* "Loading…" is right for a fetch and wrong for "Ripping disc 3",
|
|
18
|
+
* and the label is the *only* thing a screen-reader user gets —
|
|
19
|
+
* the animation says nothing.
|
|
20
|
+
*/
|
|
21
|
+
label?: string
|
|
22
|
+
isLabelVisible?: boolean
|
|
23
|
+
size?: ControlSize
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* **Zero of these exist anywhere in the fleet.** Not one repo has a
|
|
28
|
+
* spinner; every app renders bare `"loading…"` text, which is why
|
|
29
|
+
* this is the best effort-to-payoff component in the M3 list.
|
|
30
|
+
*
|
|
31
|
+
* Three things it does that a hand-rolled `animate-spin` does not:
|
|
32
|
+
*
|
|
33
|
+
* - **It is a live region.** `role="status"` with the label inside
|
|
34
|
+
* means a screen reader announces the load starting. A bare
|
|
35
|
+
* rotating `<div>` announces nothing, which is the state of the
|
|
36
|
+
* art in every repo that renders `"loading…"` into a `<span>`.
|
|
37
|
+
* - **It stops.** The animation is `--duration-loop-fast`, which
|
|
38
|
+
* `prefers-reduced-motion` zeroes and
|
|
39
|
+
* `@charcuterie/ui/styles.css` then switches off outright —
|
|
40
|
+
* vestibular-triggering motion is the single most common a11y
|
|
41
|
+
* complaint about spinners, and Tailwind's `animate-spin`
|
|
42
|
+
* hardcodes 1s past the media query.
|
|
43
|
+
* - **It leaves a static fallback that still reads.** With motion
|
|
44
|
+
* off, the ring stays a ring and the label stays a label, so the
|
|
45
|
+
* component degrades to "there is a thing here, and it says
|
|
46
|
+
* loading" rather than to nothing.
|
|
47
|
+
*/
|
|
48
|
+
export const Spinner = ({
|
|
49
|
+
className,
|
|
50
|
+
isLabelVisible = false,
|
|
51
|
+
label = "Loading…",
|
|
52
|
+
size = "md",
|
|
53
|
+
...spanProps
|
|
54
|
+
}: SpinnerProps): ReactNode => (
|
|
55
|
+
<span
|
|
56
|
+
{...spanProps}
|
|
57
|
+
// Both, and neither is redundant. `role="status"` takes **no
|
|
58
|
+
// accessible name from its content** — it is a live region, not
|
|
59
|
+
// a widget — so without `aria-label` the spinner is announced on
|
|
60
|
+
// change but cannot be *found*:
|
|
61
|
+
// `getByRole("status", { name: "Loading…" })` matches nothing,
|
|
62
|
+
// which is a gap an agent hits and axe never reports. The text
|
|
63
|
+
// child is what a screen reader reads when the region appears;
|
|
64
|
+
// the label is what names it for navigation and for Playwright.
|
|
65
|
+
aria-label={label}
|
|
66
|
+
className={toClassName(
|
|
67
|
+
"inline-flex items-center gap-2 align-middle text-current",
|
|
68
|
+
className,
|
|
69
|
+
)}
|
|
70
|
+
role="status"
|
|
71
|
+
>
|
|
72
|
+
<span
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
className={toClassName(
|
|
75
|
+
// `border-current` on three sides and a transparent fourth
|
|
76
|
+
// is the whole trick: it inherits the parent's text colour,
|
|
77
|
+
// so a spinner inside a solid button is legible without the
|
|
78
|
+
// button telling it anything.
|
|
79
|
+
"charcuterie-spin inline-block shrink-0 rounded-full border-current border-t-transparent",
|
|
80
|
+
SPINNER_SIZE_CLASS[size],
|
|
81
|
+
)}
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{isLabelVisible ? (
|
|
85
|
+
<span className="text-content-secondary">
|
|
86
|
+
{label}
|
|
87
|
+
</span>
|
|
88
|
+
) : (
|
|
89
|
+
<VisuallyHidden>{label}</VisuallyHidden>
|
|
90
|
+
)}
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { useEffect } from "react"
|
|
3
|
+
|
|
4
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
import type { TabItem, TabsOrientation } from "./Tabs.tsx"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The selected marker runs along the edge the tab list itself
|
|
10
|
+
* sits on — under a horizontal bar, down the inline-end of a
|
|
11
|
+
* vertical rail — and the negative margin pulls it over the
|
|
12
|
+
* list's own border so the two read as one line rather than two.
|
|
13
|
+
*
|
|
14
|
+
* Logical properties throughout, which is the house rule and also
|
|
15
|
+
* the only way a vertical rail lands on the correct side in RTL.
|
|
16
|
+
*/
|
|
17
|
+
const ORIENTATION_EDGE_CLASS: Record<
|
|
18
|
+
TabsOrientation,
|
|
19
|
+
{ base: string; selected: string }
|
|
20
|
+
> = {
|
|
21
|
+
horizontal: {
|
|
22
|
+
base: "-mb-px border-b-2",
|
|
23
|
+
selected: "border-b-intent-accent-solid",
|
|
24
|
+
},
|
|
25
|
+
vertical: {
|
|
26
|
+
base: "-me-px border-e-2 text-start",
|
|
27
|
+
selected: "border-e-intent-accent-solid",
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TabTriggerProps = {
|
|
32
|
+
id: string
|
|
33
|
+
isSelected: boolean
|
|
34
|
+
onSelect: (key: string) => void
|
|
35
|
+
orientation: TabsOrientation
|
|
36
|
+
panelId: string
|
|
37
|
+
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
38
|
+
registerFocus: (value: string) => () => void
|
|
39
|
+
/** `SinglePicker.register` — membership of the choice. */
|
|
40
|
+
registerSelection: (value: string) => () => void
|
|
41
|
+
tab: TabItem
|
|
42
|
+
tabIndex: number
|
|
43
|
+
trackElement: (
|
|
44
|
+
key: string,
|
|
45
|
+
element: HTMLButtonElement | null,
|
|
46
|
+
) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Its own file for a reason that is not style: **both
|
|
51
|
+
* registrations are effects**, and an effect cannot run in a loop.
|
|
52
|
+
* Any member of a registering group ends up shaped like this, so
|
|
53
|
+
* it is worth seeing on its own.
|
|
54
|
+
*
|
|
55
|
+
* The two registrations are separate on purpose. A disabled tab
|
|
56
|
+
* joins the *choice* — it is still one of the options, still owns
|
|
57
|
+
* a panel and an id, and can still be the one selected when a
|
|
58
|
+
* consumer says so — and stays out of the *focus* group, so the
|
|
59
|
+
* arrow keys skip it without any command in `RovingFocus` having
|
|
60
|
+
* to know what "disabled" means. Registration is membership; that
|
|
61
|
+
* is the whole mechanism.
|
|
62
|
+
*/
|
|
63
|
+
export const TabTrigger = ({
|
|
64
|
+
id,
|
|
65
|
+
isSelected,
|
|
66
|
+
onSelect,
|
|
67
|
+
orientation,
|
|
68
|
+
panelId,
|
|
69
|
+
registerFocus,
|
|
70
|
+
registerSelection,
|
|
71
|
+
tab,
|
|
72
|
+
tabIndex,
|
|
73
|
+
trackElement,
|
|
74
|
+
}: TabTriggerProps): ReactNode => {
|
|
75
|
+
const { isDisabled = false, key, label } = tab
|
|
76
|
+
|
|
77
|
+
const edge = ORIENTATION_EDGE_CLASS[orientation]
|
|
78
|
+
|
|
79
|
+
useEffect(
|
|
80
|
+
() => registerSelection(key),
|
|
81
|
+
[key, registerSelection],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (isDisabled) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return registerFocus(key)
|
|
90
|
+
}, [isDisabled, key, registerFocus])
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
aria-controls={panelId}
|
|
95
|
+
aria-selected={isSelected}
|
|
96
|
+
className={toClassName(
|
|
97
|
+
"cursor-pointer border-transparent px-3 py-2 font-medium text-sm whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard",
|
|
98
|
+
edge.base,
|
|
99
|
+
// Two entries rather than one interpolated string:
|
|
100
|
+
// `tailwindCandidates.test.ts` rejects a template literal
|
|
101
|
+
// in a className outright, because Tailwind's scanner
|
|
102
|
+
// cannot see a class that only exists at runtime.
|
|
103
|
+
isSelected && edge.selected,
|
|
104
|
+
isSelected
|
|
105
|
+
? "text-content-primary"
|
|
106
|
+
: "text-content-secondary hover:text-content-primary",
|
|
107
|
+
isDisabled &&
|
|
108
|
+
"cursor-not-allowed text-content-disabled hover:text-content-disabled",
|
|
109
|
+
FOCUS_RING_CLASS,
|
|
110
|
+
)}
|
|
111
|
+
disabled={isDisabled}
|
|
112
|
+
id={id}
|
|
113
|
+
onClick={() => {
|
|
114
|
+
onSelect(key)
|
|
115
|
+
}}
|
|
116
|
+
ref={(element) => {
|
|
117
|
+
trackElement(key, element)
|
|
118
|
+
}}
|
|
119
|
+
role="tab"
|
|
120
|
+
tabIndex={tabIndex}
|
|
121
|
+
type="button"
|
|
122
|
+
>
|
|
123
|
+
{label}
|
|
124
|
+
</button>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as TabsStories from "./Tabs.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={TabsStories} />
|
|
6
|
+
|
|
7
|
+
# Tabs
|
|
8
|
+
|
|
9
|
+
**The state layer's falsification test.** Tabs is P0 for that reason and no other — the
|
|
10
|
+
fifteen ad-hoc tab bars that used to justify it were in the withdrawn evidence. The plan
|
|
11
|
+
was explicit: if this came out ugly, stop and reconsider the state layer before building
|
|
12
|
+
fifteen more components.
|
|
13
|
+
|
|
14
|
+
It did not come out ugly. The composition is two hooks and one decision.
|
|
15
|
+
|
|
16
|
+
<Canvas of={TabsStories.Default} />
|
|
17
|
+
|
|
18
|
+
## Props
|
|
19
|
+
|
|
20
|
+
<Controls />
|
|
21
|
+
|
|
22
|
+
## The composition
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const selection = useSinglePicker({ selectedValue: initialKey }) // which tab is chosen
|
|
26
|
+
const focus = useRovingFocus({ activeValue: initialKey }) // which tab is tabbable
|
|
27
|
+
|
|
28
|
+
// `automatic` is this line. `manual` is its absence.
|
|
29
|
+
if (activation === "automatic") selection.select(focus.activeValue)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That one line is the whole difference between the two ARIA activation modes, and it is
|
|
33
|
+
only that small because **focus and selection are separate kinds**. In `manual`, focus
|
|
34
|
+
lands on a tab that is *not* chosen — a state the original three-kind model could not
|
|
35
|
+
represent at all, because it had one notion of "the current one" doing both jobs. The
|
|
36
|
+
`Interactive` story asserts both halves against the same component.
|
|
37
|
+
|
|
38
|
+
## Selection, not visibility
|
|
39
|
+
|
|
40
|
+
M4 built this on `VisibilityGroup`, and the panels come out one-at-a-time either way: the
|
|
41
|
+
two cores are the same shape, deliberately. What settled it is **what registers**. The
|
|
42
|
+
thing that calls `register` here is the *tab*, not the panel, and the question a tab bar
|
|
43
|
+
asks is *which one did you pick* — so the kind that answers it is `SinglePicker`, and the
|
|
44
|
+
panel's `hidden` is derived from the answer rather than being the answer.
|
|
45
|
+
|
|
46
|
+
That is not bookkeeping. Reading it the other way made `aria-selected` a report about a
|
|
47
|
+
panel's visibility, so a tab bar with **no panels at all** — a segmented filter, a view
|
|
48
|
+
switcher that swaps a route — had to model its choice as a group of visibilities it did not
|
|
49
|
+
have. `SegmentedControl` is exactly that consumer, and it shares this core rather than
|
|
50
|
+
hand-rolling a fourth one.
|
|
51
|
+
[Decision](../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
|
|
52
|
+
|
|
53
|
+
## What it does not use, and why that is the answer
|
|
54
|
+
|
|
55
|
+
The plan expected `createLinkedIds` here too, alongside Visibility, VisibilityGroup, and
|
|
56
|
+
RovingFocus. **It does not fit, and forcing it would have been wrong.**
|
|
57
|
+
|
|
58
|
+
That kind exists for a *dynamic* trigger↔target pair — a `Popover` whose panel mounts and
|
|
59
|
+
unmounts, where the multiset is what stops `aria-controls` pointing at a node React has
|
|
60
|
+
already removed. A tab bar's pairing is static and known at render, so the ids are derived
|
|
61
|
+
from one `useUniqueId` and cannot get out of step at all. Three of the four kinds, and a
|
|
62
|
+
specific reason for the fourth.
|
|
63
|
+
|
|
64
|
+
## Registration is membership
|
|
65
|
+
|
|
66
|
+
A disabled tab joins the **choice** — it is still one of the options, still owns a panel
|
|
67
|
+
and an id — and stays out of the **focus** group. That is all "disabled" means here: the
|
|
68
|
+
arrow keys skip it because it is not a member, and nothing in `RovingFocus` has to know the
|
|
69
|
+
word.
|
|
70
|
+
|
|
71
|
+
`TabTrigger` is its own file for a reason that is not style: both registrations are
|
|
72
|
+
effects, and an effect cannot run in a loop. Any member of a registering group ends up
|
|
73
|
+
shaped like this.
|
|
74
|
+
|
|
75
|
+
<Canvas of={TabsStories.AllVariants} />
|
|
76
|
+
|
|
77
|
+
## `activeKey` is initial, not controlled
|
|
78
|
+
|
|
79
|
+
Read once, on the first render. A parent that needs to push a value calls the hook's own
|
|
80
|
+
setter; there is no controlled mode and no prop-sync effect, which is
|
|
81
|
+
[the M2 decision](../../../docs/decisions/2026-07-29-logic-hooks-are-uncontrolled.md) and
|
|
82
|
+
the thing that keeps the fleet's existing echo loops out of this library.
|
|
83
|
+
|
|
84
|
+
## Focus is moved deliberately, and only then
|
|
85
|
+
|
|
86
|
+
`RovingFocus` is state; something still has to call `.focus()`. It happens only while the
|
|
87
|
+
tablist already contains the focused element — otherwise mounting a tab bar, or any parent
|
|
88
|
+
re-render that moved `activeValue`, yanks the caret out of whatever the user was doing.
|
|
89
|
+
|
|
90
|
+
<Canvas of={TabsStories.AllStates} />
|
|
91
|
+
|
|
92
|
+
## What Tabs found in M3's own testing helper
|
|
93
|
+
|
|
94
|
+
`expectAgentDrivable` rejected every tab outright:
|
|
95
|
+
|
|
96
|
+
> Not agent-drivable: role "tab" named "Log" has a negative tabindex, so it can be clicked
|
|
97
|
+
> but never reached with Tab.
|
|
98
|
+
|
|
99
|
+
Correct for a standalone button, wrong for a tab. **A roving group's whole design is that
|
|
100
|
+
exactly one member is in the tab order** and the arrow keys reach the rest; a tab list
|
|
101
|
+
where every tab had `tabindex="0"` would be the bug.
|
|
102
|
+
|
|
103
|
+
So the rejection was not skipped for composite widgets — it was **replaced by the roving
|
|
104
|
+
rule itself**, which is the stronger assertion: inside a `tablist` / `listbox` / `menu` /
|
|
105
|
+
`radiogroup` / `tree` / `grid` / `toolbar`, exactly one enabled member may be in the tab
|
|
106
|
+
order. Zero strands the widget; several mean the pattern was never implemented.
|
|
107
|
+
`AllStates` proves the failing half too, with a four-line stub standing in for the canvas —
|
|
108
|
+
`AgentQueries` is structural precisely so that it can.
|
|
109
|
+
|
|
110
|
+
<Canvas of={TabsStories.Responsive} />
|
|
111
|
+
|
|
112
|
+
## A tab bar scrolls, it does not wrap
|
|
113
|
+
|
|
114
|
+
A wrapped tablist puts tabs on two rows, which breaks the visual row and the mental model
|
|
115
|
+
the arrow keys give — "right" stops meaning right. `ContainerBoard` appears here as a width
|
|
116
|
+
harness rather than a container-query claim: `Tabs` declares no container, because nothing
|
|
117
|
+
about it needs to *know* its width. It only has to survive being narrow.
|
|
118
|
+
|
|
119
|
+
## Manual activation is the falsification test
|
|
120
|
+
|
|
121
|
+
`automatic` and `manual` differ only in whether moving focus also *chooses* — and that
|
|
122
|
+
difference is *one line*, because focus and selection are separate kinds. Arrow through the
|
|
123
|
+
bar below and the underline stays put until Enter: focus has moved to a tab that is not
|
|
124
|
+
selected, which is a state a three-kind model cannot represent at all.
|
|
125
|
+
|
|
126
|
+
<Canvas of={TabsStories.Manual} />
|