@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,56 @@
|
|
|
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 "./Card.stories.tsx"
|
|
8
|
+
|
|
9
|
+
const { AllStates, Default, Interactive } =
|
|
10
|
+
composeStories(stories)
|
|
11
|
+
|
|
12
|
+
test("a heading turns the card into a named landmark", async () => {
|
|
13
|
+
const { canvas } = await mountStory(Default)
|
|
14
|
+
|
|
15
|
+
expectAgentDrivable(canvas, {
|
|
16
|
+
name: "Bay 3",
|
|
17
|
+
role: "region",
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("a card with no heading is not a landmark", async () => {
|
|
22
|
+
const { canvas } = await mountStory(AllStates)
|
|
23
|
+
|
|
24
|
+
// Four cards on the board, three of them named. A `<section>` with
|
|
25
|
+
// no accessible name is not a region at all — which is the point:
|
|
26
|
+
// the component does not pretend to be one it cannot name.
|
|
27
|
+
await expect(canvas.getAllByRole("region")).toHaveLength(
|
|
28
|
+
3,
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Two identical "Start rip" buttons on one page, disambiguated only
|
|
34
|
+
* by the region each sits in.
|
|
35
|
+
*/
|
|
36
|
+
test("region scoping disambiguates two identical buttons", async () => {
|
|
37
|
+
const { canvas } = await mountStory(Interactive)
|
|
38
|
+
|
|
39
|
+
const bayFour = expectAgentDrivable(canvas, {
|
|
40
|
+
name: "Bay 4",
|
|
41
|
+
role: "region",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const scoped = bayFour.querySelectorAll("button")
|
|
45
|
+
|
|
46
|
+
await expect(scoped).toHaveLength(1)
|
|
47
|
+
|
|
48
|
+
// And the progress bar inside that region is the one named for it,
|
|
49
|
+
// so an agent reading a value off a 16-bay tower reads the right
|
|
50
|
+
// bay's.
|
|
51
|
+
await expect(
|
|
52
|
+
canvas.getByRole("progressbar", {
|
|
53
|
+
name: "Bay 4 progress",
|
|
54
|
+
}),
|
|
55
|
+
).toHaveAttribute("aria-valuenow", "40")
|
|
56
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type {
|
|
3
|
+
ElevationStep,
|
|
4
|
+
SurfaceRole,
|
|
5
|
+
} from "@charcuterie/tokens"
|
|
6
|
+
import type {
|
|
7
|
+
ComponentPropsWithRef,
|
|
8
|
+
ReactNode,
|
|
9
|
+
} from "react"
|
|
10
|
+
|
|
11
|
+
import { toClassName } from "../toClassName.ts"
|
|
12
|
+
|
|
13
|
+
export type CardSurface = Extract<
|
|
14
|
+
SurfaceRole,
|
|
15
|
+
"base" | "raised" | "sunken"
|
|
16
|
+
>
|
|
17
|
+
|
|
18
|
+
export type CardPadding = "lg" | "md" | "none" | "sm"
|
|
19
|
+
|
|
20
|
+
export type CardProps = ComponentPropsWithRef<"section"> & {
|
|
21
|
+
/** Buttons or a badge, laid out beside the heading. */
|
|
22
|
+
actions?: ReactNode
|
|
23
|
+
elevation?: ElevationStep
|
|
24
|
+
footer?: ReactNode
|
|
25
|
+
heading?: string
|
|
26
|
+
headingLevel?: 2 | 3 | 4
|
|
27
|
+
padding?: CardPadding
|
|
28
|
+
surface?: CardSurface
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SURFACE_CLASS: Record<CardSurface, string> = {
|
|
32
|
+
base: "bg-surface-base",
|
|
33
|
+
raised: "bg-surface-raised",
|
|
34
|
+
sunken: "bg-surface-sunken",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Exported only so the story can derive its `elevation` control from
|
|
39
|
+
* the same map the component indexes — `ElevationStep` comes from
|
|
40
|
+
* `@charcuterie/tokens`, which react-docgen will not follow, so the
|
|
41
|
+
* options have to be stated and this is the one statement of them
|
|
42
|
+
* that cannot drift.
|
|
43
|
+
*/
|
|
44
|
+
export const ELEVATION_CLASS: Record<
|
|
45
|
+
ElevationStep,
|
|
46
|
+
string
|
|
47
|
+
> = {
|
|
48
|
+
none: "shadow-none",
|
|
49
|
+
low: "shadow-low",
|
|
50
|
+
medium: "shadow-medium",
|
|
51
|
+
high: "shadow-high",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const PADDING_CLASS: Record<CardPadding, string> = {
|
|
55
|
+
none: "p-0",
|
|
56
|
+
sm: "p-3",
|
|
57
|
+
md: "p-4",
|
|
58
|
+
lg: "p-6",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Five repos, and the reason this is a P0 rather than a nicety is
|
|
63
|
+
* the `<section>`: give a card a heading and it becomes a landmark,
|
|
64
|
+
* so `getByRole("region", { name: "Bay 3" })` scopes every
|
|
65
|
+
* subsequent query to that card. Without it, an agent driving a
|
|
66
|
+
* 16-bay tower has sixteen identical "Start" buttons and no way to
|
|
67
|
+
* say which one it means.
|
|
68
|
+
*
|
|
69
|
+
* `aria-labelledby` rather than `aria-label`, because the heading is
|
|
70
|
+
* on screen already and stating the name twice is how the two drift
|
|
71
|
+
* apart.
|
|
72
|
+
*
|
|
73
|
+
* **Density is not a prop here.** mux-magic's inventory turned up a
|
|
74
|
+
* `CardDensityProvider` in the withdrawn bambuddy evidence, and it
|
|
75
|
+
* is the wrong shape for this fleet: density is a global axis on
|
|
76
|
+
* `<html>`, so a compact bay list is `data-density="compact"` on the
|
|
77
|
+
* page, not a context around the cards. `padding` remains a prop
|
|
78
|
+
* because a card wrapping a poster grid genuinely has none, which is
|
|
79
|
+
* a layout fact rather than a density one.
|
|
80
|
+
*/
|
|
81
|
+
export const Card = ({
|
|
82
|
+
actions,
|
|
83
|
+
children,
|
|
84
|
+
className,
|
|
85
|
+
elevation = "low",
|
|
86
|
+
footer,
|
|
87
|
+
heading,
|
|
88
|
+
headingLevel = 2,
|
|
89
|
+
padding = "md",
|
|
90
|
+
surface = "raised",
|
|
91
|
+
...sectionProps
|
|
92
|
+
}: CardProps): ReactNode => {
|
|
93
|
+
const headingId = useUniqueId()
|
|
94
|
+
|
|
95
|
+
const Heading = `h${headingLevel}` as const
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<section
|
|
99
|
+
{...sectionProps}
|
|
100
|
+
aria-labelledby={heading ? headingId : undefined}
|
|
101
|
+
className={toClassName(
|
|
102
|
+
// `@container` here, not on a wrapper, is what lets the
|
|
103
|
+
// header stack at narrow widths *inside a wide viewport* —
|
|
104
|
+
// a card in a sidebar and the same card full-bleed are the
|
|
105
|
+
// case a media query cannot tell apart, and the fleet's
|
|
106
|
+
// poster grids get it wrong at intermediate widths today.
|
|
107
|
+
"@container flex flex-col gap-3 rounded-lg border border-border-subtle text-content-primary",
|
|
108
|
+
SURFACE_CLASS[surface],
|
|
109
|
+
ELEVATION_CLASS[elevation],
|
|
110
|
+
PADDING_CLASS[padding],
|
|
111
|
+
className,
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
{heading || actions ? (
|
|
115
|
+
<header className="flex flex-col gap-2 cq-sm:flex-row cq-sm:items-start cq-sm:justify-between">
|
|
116
|
+
{heading ? (
|
|
117
|
+
<Heading
|
|
118
|
+
className="font-semibold text-md leading-tight"
|
|
119
|
+
id={headingId}
|
|
120
|
+
>
|
|
121
|
+
{heading}
|
|
122
|
+
</Heading>
|
|
123
|
+
) : null}
|
|
124
|
+
|
|
125
|
+
{actions ? (
|
|
126
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
127
|
+
{actions}
|
|
128
|
+
</div>
|
|
129
|
+
) : null}
|
|
130
|
+
</header>
|
|
131
|
+
) : null}
|
|
132
|
+
|
|
133
|
+
{children}
|
|
134
|
+
|
|
135
|
+
{footer ? (
|
|
136
|
+
<footer className="border-t border-border-subtle pt-3 text-content-secondary text-sm">
|
|
137
|
+
{footer}
|
|
138
|
+
</footer>
|
|
139
|
+
) : null}
|
|
140
|
+
</section>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as EmptyStateStories from "./EmptyState.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={EmptyStateStories} />
|
|
6
|
+
|
|
7
|
+
# EmptyState
|
|
8
|
+
|
|
9
|
+
**Six repos, eleven-plus copies** — plex-channels alone has five `li.empty`s and castkit
|
|
10
|
+
six `.idle`s. Every copy is a bare string in a `<div>`, so "no results", "not started yet",
|
|
11
|
+
and "the socket died" all look identical and none of them is findable by role.
|
|
12
|
+
|
|
13
|
+
Designed fresh rather than ported: the two repos that had a real `EmptyState` with typed
|
|
14
|
+
variants were bambuddy and spoolbuddy, which are upstream projects rather than Kevin's
|
|
15
|
+
code, so that evidence was withdrawn from the plan.
|
|
16
|
+
|
|
17
|
+
<Canvas of={EmptyStateStories.Default} />
|
|
18
|
+
|
|
19
|
+
## Props
|
|
20
|
+
|
|
21
|
+
<Controls />
|
|
22
|
+
|
|
23
|
+
## Why there is no `variant`
|
|
24
|
+
|
|
25
|
+
"Empty" and "failed" differ in wording and in whether there is a retry button — both props
|
|
26
|
+
already. A `variant="error"` that only changes a colour invites callers to encode the
|
|
27
|
+
meaning in the colour alone, which is the one thing a colour cannot carry.
|
|
28
|
+
|
|
29
|
+
<Canvas of={EmptyStateStories.AllVariants} />
|
|
30
|
+
|
|
31
|
+
## The heading is the handle
|
|
32
|
+
|
|
33
|
+
`headingLevel` is the caller's, because only the caller knows what this sits under. A real
|
|
34
|
+
heading makes `getByRole("heading", { name: "No discs queued" })` work, where `getByText`
|
|
35
|
+
against eleven copies of "Nothing here" does not.
|
|
36
|
+
|
|
37
|
+
The `md` size gains air past `--cq-md`, so the same component is tight in a 15rem sidebar
|
|
38
|
+
and generous filling a dashboard — decided by the panel, not the window.
|
|
39
|
+
|
|
40
|
+
<Canvas of={EmptyStateStories.Responsive} />
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import { Button } from "../Button/Button.tsx"
|
|
3
|
+
import {
|
|
4
|
+
ContainerBoard,
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
StorySection,
|
|
8
|
+
} from "../board.storyHelpers.tsx"
|
|
9
|
+
import { Card } from "../Card/Card.tsx"
|
|
10
|
+
import {
|
|
11
|
+
AlertIcon,
|
|
12
|
+
InboxIcon,
|
|
13
|
+
PlayIcon,
|
|
14
|
+
SearchIcon,
|
|
15
|
+
} from "../icons.storyHelpers.tsx"
|
|
16
|
+
import { EmptyState } from "./EmptyState.tsx"
|
|
17
|
+
|
|
18
|
+
const meta = {
|
|
19
|
+
title: "Components/EmptyState",
|
|
20
|
+
component: EmptyState,
|
|
21
|
+
parameters: { layout: "padded" },
|
|
22
|
+
args: { headingLevel: 2, size: "md" },
|
|
23
|
+
} satisfies Meta<typeof EmptyState>
|
|
24
|
+
|
|
25
|
+
export default meta
|
|
26
|
+
|
|
27
|
+
type Story = StoryObj<typeof meta>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The heading is the handle. Eleven copies of "Nothing here" in a
|
|
31
|
+
* `<div>` are indistinguishable to an agent; a named heading is not.
|
|
32
|
+
*/
|
|
33
|
+
export const Default: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
description:
|
|
36
|
+
"Insert a disc and rip-deck will pick it up automatically.",
|
|
37
|
+
heading: "No discs queued",
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* There is no `variant` prop, on purpose: "empty" and "failed"
|
|
43
|
+
* differ in wording and in whether there is a retry button, both of
|
|
44
|
+
* which are props already. A `variant="error"` that only changes a
|
|
45
|
+
* colour invites callers to encode the meaning in the colour alone,
|
|
46
|
+
* which is the one thing a colour cannot carry.
|
|
47
|
+
*/
|
|
48
|
+
export const AllVariants: Story = {
|
|
49
|
+
args: { heading: "No discs queued" },
|
|
50
|
+
render: () => (
|
|
51
|
+
<StorySection title="The four real cases from the inventory — plex-channels' empty queue, castkit's idle panel, a filtered search, and a dead socket.">
|
|
52
|
+
<StoryGrid columns={2}>
|
|
53
|
+
<StoryCell align="stretch" label="empty">
|
|
54
|
+
<EmptyState
|
|
55
|
+
description="Insert a disc and rip-deck will pick it up automatically."
|
|
56
|
+
heading="No discs queued"
|
|
57
|
+
icon={<InboxIcon />}
|
|
58
|
+
/>
|
|
59
|
+
</StoryCell>
|
|
60
|
+
|
|
61
|
+
<StoryCell
|
|
62
|
+
align="stretch"
|
|
63
|
+
label="idle — nothing has happened yet"
|
|
64
|
+
>
|
|
65
|
+
<EmptyState
|
|
66
|
+
description="The channel is connected and waiting for its first playback."
|
|
67
|
+
heading="Nothing playing"
|
|
68
|
+
icon={<PlayIcon />}
|
|
69
|
+
/>
|
|
70
|
+
</StoryCell>
|
|
71
|
+
|
|
72
|
+
<StoryCell
|
|
73
|
+
align="stretch"
|
|
74
|
+
label="filtered to nothing"
|
|
75
|
+
>
|
|
76
|
+
<EmptyState
|
|
77
|
+
action={
|
|
78
|
+
<Button appearance="soft" size="sm">
|
|
79
|
+
Clear filters
|
|
80
|
+
</Button>
|
|
81
|
+
}
|
|
82
|
+
description="No titles match “blade” with the 4K filter on."
|
|
83
|
+
heading="No matches"
|
|
84
|
+
icon={<SearchIcon />}
|
|
85
|
+
/>
|
|
86
|
+
</StoryCell>
|
|
87
|
+
|
|
88
|
+
<StoryCell align="stretch" label="failed">
|
|
89
|
+
<EmptyState
|
|
90
|
+
action={
|
|
91
|
+
<Button intent="danger" size="sm">
|
|
92
|
+
Reconnect
|
|
93
|
+
</Button>
|
|
94
|
+
}
|
|
95
|
+
description="The websocket closed after 3 retries. Nothing has been lost — the tower keeps ripping."
|
|
96
|
+
heading="Lost the connection"
|
|
97
|
+
icon={<AlertIcon />}
|
|
98
|
+
/>
|
|
99
|
+
</StoryCell>
|
|
100
|
+
</StoryGrid>
|
|
101
|
+
</StorySection>
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const AllStates: Story = {
|
|
106
|
+
args: { heading: "No discs queued" },
|
|
107
|
+
render: () => (
|
|
108
|
+
<StoryGrid columns={2}>
|
|
109
|
+
<StoryCell
|
|
110
|
+
align="stretch"
|
|
111
|
+
label="sm — inside a panel"
|
|
112
|
+
>
|
|
113
|
+
<Card heading="Tower alerts" padding="sm">
|
|
114
|
+
<EmptyState heading="No alerts" size="sm" />
|
|
115
|
+
</Card>
|
|
116
|
+
</StoryCell>
|
|
117
|
+
|
|
118
|
+
<StoryCell align="stretch" label="md — heading only">
|
|
119
|
+
<EmptyState heading="No discs queued" />
|
|
120
|
+
</StoryCell>
|
|
121
|
+
|
|
122
|
+
<StoryCell
|
|
123
|
+
align="stretch"
|
|
124
|
+
label="md — with description"
|
|
125
|
+
>
|
|
126
|
+
<EmptyState
|
|
127
|
+
description="Insert a disc and rip-deck will pick it up automatically."
|
|
128
|
+
heading="No discs queued"
|
|
129
|
+
/>
|
|
130
|
+
</StoryCell>
|
|
131
|
+
|
|
132
|
+
<StoryCell
|
|
133
|
+
align="stretch"
|
|
134
|
+
label="md — icon, description, action"
|
|
135
|
+
>
|
|
136
|
+
<EmptyState
|
|
137
|
+
action={<Button size="sm">Scan drives</Button>}
|
|
138
|
+
description="Insert a disc and rip-deck will pick it up automatically."
|
|
139
|
+
heading="No discs queued"
|
|
140
|
+
icon={<InboxIcon />}
|
|
141
|
+
/>
|
|
142
|
+
</StoryCell>
|
|
143
|
+
</StoryGrid>
|
|
144
|
+
),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The container-query story that actually changes something: the
|
|
149
|
+
* `md` size gains air past `--cq-md`, so the same component is
|
|
150
|
+
* appropriately tight in a 15rem sidebar and appropriately generous
|
|
151
|
+
* filling a dashboard — decided by the panel, not by the window.
|
|
152
|
+
*/
|
|
153
|
+
export const Responsive: Story = {
|
|
154
|
+
args: { heading: "No discs queued" },
|
|
155
|
+
render: () => (
|
|
156
|
+
<ContainerBoard>
|
|
157
|
+
<Card padding="none">
|
|
158
|
+
<EmptyState
|
|
159
|
+
description="Insert a disc and rip-deck will pick it up automatically."
|
|
160
|
+
heading="No discs queued"
|
|
161
|
+
icon={<InboxIcon />}
|
|
162
|
+
/>
|
|
163
|
+
</Card>
|
|
164
|
+
</ContainerBoard>
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Two things an agent needs from an empty state: read *why* it is
|
|
170
|
+
* empty, and reach the way out. So the heading carries the reason
|
|
171
|
+
* and Tab lands on the action.
|
|
172
|
+
*/
|
|
173
|
+
export const Interactive: Story = {
|
|
174
|
+
args: { heading: "No discs queued" },
|
|
175
|
+
render: (emptyStateProps) => (
|
|
176
|
+
<EmptyState
|
|
177
|
+
{...emptyStateProps}
|
|
178
|
+
action={<Button size="sm">Scan drives</Button>}
|
|
179
|
+
description="Insert a disc and rip-deck will pick it up automatically."
|
|
180
|
+
headingLevel={3}
|
|
181
|
+
/>
|
|
182
|
+
),
|
|
183
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent } 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 "./EmptyState.stories.tsx"
|
|
8
|
+
|
|
9
|
+
const { Default, Interactive } = composeStories(stories)
|
|
10
|
+
|
|
11
|
+
test("the heading is the handle", async () => {
|
|
12
|
+
const { canvas } = await mountStory(Default)
|
|
13
|
+
|
|
14
|
+
expectAgentDrivable(canvas, {
|
|
15
|
+
name: "No discs queued",
|
|
16
|
+
role: "heading",
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("the heading level is the caller's, and Tab reaches the way out", async () => {
|
|
21
|
+
const { canvas } = await mountStory(Interactive)
|
|
22
|
+
|
|
23
|
+
// `headingLevel` is a prop rather than a guess, because an empty
|
|
24
|
+
// state nested in a card is not an `<h2>` — and a document whose
|
|
25
|
+
// heading levels skip is one a screen-reader user cannot skim.
|
|
26
|
+
await expect(
|
|
27
|
+
canvas.getByRole("heading", {
|
|
28
|
+
level: 3,
|
|
29
|
+
name: "No discs queued",
|
|
30
|
+
}),
|
|
31
|
+
).toBeVisible()
|
|
32
|
+
|
|
33
|
+
const action = expectAgentDrivable(canvas, {
|
|
34
|
+
name: "Scan drives",
|
|
35
|
+
role: "button",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await userEvent.tab()
|
|
39
|
+
|
|
40
|
+
await expect(action).toHaveFocus()
|
|
41
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComponentPropsWithRef,
|
|
3
|
+
ReactNode,
|
|
4
|
+
} from "react"
|
|
5
|
+
|
|
6
|
+
import { toClassName } from "../toClassName.ts"
|
|
7
|
+
|
|
8
|
+
export type EmptyStateSize = "md" | "sm"
|
|
9
|
+
|
|
10
|
+
export type EmptyStateProps = Omit<
|
|
11
|
+
ComponentPropsWithRef<"div">,
|
|
12
|
+
"children"
|
|
13
|
+
> & {
|
|
14
|
+
/** Buttons, usually. Rendered under the description. */
|
|
15
|
+
action?: ReactNode
|
|
16
|
+
description?: ReactNode
|
|
17
|
+
/**
|
|
18
|
+
* A real heading element, and the level is the caller's because
|
|
19
|
+
* only the caller knows what it sits under. Getting this wrong
|
|
20
|
+
* breaks a screen reader's document outline, which is how the
|
|
21
|
+
* fleet's `<h2>`-with-a-`<button>`-inside pattern happened.
|
|
22
|
+
*/
|
|
23
|
+
headingLevel?: 2 | 3 | 4
|
|
24
|
+
heading: string
|
|
25
|
+
icon?: ReactNode
|
|
26
|
+
size?: EmptyStateSize
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SIZE_CLASS: Record<EmptyStateSize, string> = {
|
|
30
|
+
sm: "gap-2 px-4 py-6 text-sm",
|
|
31
|
+
// An empty state in a 300px panel and the same one filling a
|
|
32
|
+
// dashboard want different air. Container-relative, so it is the
|
|
33
|
+
// *panel* that decides rather than the window.
|
|
34
|
+
md: "gap-3 px-6 py-10 text-md cq-md:gap-4 cq-md:px-8 cq-md:py-14",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* **Six repos, eleven-plus copies** — plex-channels alone has five
|
|
39
|
+
* `li.empty`s and castkit six `.idle`s — and not one of them is a
|
|
40
|
+
* component. Every copy is a bare string in a `<div>`, so "no
|
|
41
|
+
* results" and "not started yet" and "the socket died" all look
|
|
42
|
+
* identical, and none of them is findable by role.
|
|
43
|
+
*
|
|
44
|
+
* Designed fresh rather than ported: the two repos that had a real
|
|
45
|
+
* `EmptyState` with typed variants were bambuddy and spoolbuddy,
|
|
46
|
+
* which are upstream projects rather than Kevin's code, so that
|
|
47
|
+
* evidence was withdrawn from the plan.
|
|
48
|
+
*
|
|
49
|
+
* The shape is deliberately flat — icon, heading, description,
|
|
50
|
+
* action — with **no variant enum**. An "empty" and an "error" empty
|
|
51
|
+
* state differ in wording and in whether there is a retry button,
|
|
52
|
+
* both of which are props already; a `variant="error"` that only
|
|
53
|
+
* changes a colour invites callers to encode meaning in the colour
|
|
54
|
+
* alone, which is the one thing a colour cannot carry.
|
|
55
|
+
*
|
|
56
|
+
* The heading is what makes it agent-drivable:
|
|
57
|
+
* `getByRole("heading", { name: "No discs queued" })` works, where
|
|
58
|
+
* `getByText` against eleven copies of "Nothing here" does not.
|
|
59
|
+
*/
|
|
60
|
+
export const EmptyState = ({
|
|
61
|
+
action,
|
|
62
|
+
className,
|
|
63
|
+
description,
|
|
64
|
+
heading,
|
|
65
|
+
headingLevel = 2,
|
|
66
|
+
icon,
|
|
67
|
+
size = "md",
|
|
68
|
+
...divProps
|
|
69
|
+
}: EmptyStateProps): ReactNode => {
|
|
70
|
+
const Heading = `h${headingLevel}` as const
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
{...divProps}
|
|
75
|
+
className={toClassName("@container", className)}
|
|
76
|
+
>
|
|
77
|
+
{/* The container and the thing querying it **cannot be the same
|
|
78
|
+
element** — a container query only matches descendants, so
|
|
79
|
+
`@container cq-md:px-8` on one `<div>` silently never fires.
|
|
80
|
+
Nothing errors, the padding just stays small forever. Hence
|
|
81
|
+
the wrapper: `className` stays on the outer element where a
|
|
82
|
+
consumer expects it, and the size classes move inside. */}
|
|
83
|
+
<div
|
|
84
|
+
className={toClassName(
|
|
85
|
+
"flex flex-col items-center justify-center text-center",
|
|
86
|
+
SIZE_CLASS[size],
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
{icon ? (
|
|
90
|
+
<span
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
className="text-content-muted"
|
|
93
|
+
>
|
|
94
|
+
{icon}
|
|
95
|
+
</span>
|
|
96
|
+
) : null}
|
|
97
|
+
|
|
98
|
+
<Heading className="font-semibold text-content-primary">
|
|
99
|
+
{heading}
|
|
100
|
+
</Heading>
|
|
101
|
+
|
|
102
|
+
{description ? (
|
|
103
|
+
<p className="max-w-prose text-content-secondary">
|
|
104
|
+
{description}
|
|
105
|
+
</p>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
{action ? (
|
|
109
|
+
<div className="mt-1">{action}</div>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as IconButtonStories from "./IconButton.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={IconButtonStories} />
|
|
6
|
+
|
|
7
|
+
# IconButton
|
|
8
|
+
|
|
9
|
+
Three repos, three strategies, **no names**: mux-magic hand-writes five SVG components,
|
|
10
|
+
castkit keeps an `ICON_PATHS` map, and plex-channels renders raw glyphs (`↶`, `↷`, `▶`,
|
|
11
|
+
`⚙`, `≡`) straight into a `<button>`.
|
|
12
|
+
|
|
13
|
+
A screen reader announces the last of those as "↶", and
|
|
14
|
+
`getByRole("button", { name: "Undo" })` finds nothing at all. That is the concrete reason
|
|
15
|
+
the fleet is not agent-drivable today, and the reason this component exists as something
|
|
16
|
+
other than `<Button sizing="icon">`.
|
|
17
|
+
|
|
18
|
+
<Canvas of={IconButtonStories.Default} />
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
<Controls />
|
|
23
|
+
|
|
24
|
+
## The one rule
|
|
25
|
+
|
|
26
|
+
`label` is **required**, is a `string`, and becomes `aria-label`. There is no way to render
|
|
27
|
+
this component without a name, which is the point: the type system asks the question that
|
|
28
|
+
three hand-rolled versions forgot.
|
|
29
|
+
|
|
30
|
+
The library ships **no icons**. `children` is whatever the app already has — lucide (ISC)
|
|
31
|
+
is the fleet recommendation — and it is hidden from assistive technology, because the label
|
|
32
|
+
already names the control.
|
|
33
|
+
|
|
34
|
+
<Canvas of={IconButtonStories.AllVariants} />
|