@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,175 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
controlSizeArgType,
|
|
5
|
+
intentArgType,
|
|
6
|
+
} from "../argTypes.storyHelpers.ts"
|
|
7
|
+
import {
|
|
8
|
+
ContainerBoard,
|
|
9
|
+
StoryCell,
|
|
10
|
+
StoryGrid,
|
|
11
|
+
StoryRow,
|
|
12
|
+
StorySection,
|
|
13
|
+
} from "../board.storyHelpers.tsx"
|
|
14
|
+
import type { IntentAppearance } from "../intentStyles.ts"
|
|
15
|
+
import { Button } from "./Button.tsx"
|
|
16
|
+
|
|
17
|
+
const INTENTS = [
|
|
18
|
+
"neutral",
|
|
19
|
+
"accent",
|
|
20
|
+
"success",
|
|
21
|
+
"warning",
|
|
22
|
+
"danger",
|
|
23
|
+
"info",
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
const APPEARANCES: IntentAppearance[] = [
|
|
27
|
+
"solid",
|
|
28
|
+
"soft",
|
|
29
|
+
"outline",
|
|
30
|
+
"ghost",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const meta = {
|
|
34
|
+
title: "Components/Button",
|
|
35
|
+
component: Button,
|
|
36
|
+
parameters: { layout: "padded" },
|
|
37
|
+
argTypes: {
|
|
38
|
+
intent: intentArgType,
|
|
39
|
+
size: controlSizeArgType,
|
|
40
|
+
},
|
|
41
|
+
// The component's own defaults, restated. Storybook has not
|
|
42
|
+
// seeded `args` from a docgen `defaultValue` since v7, so without
|
|
43
|
+
// this the props table prints `"solid"` in the Default column
|
|
44
|
+
// while the radio beside it has nothing selected.
|
|
45
|
+
args: {
|
|
46
|
+
appearance: "solid",
|
|
47
|
+
intent: "accent",
|
|
48
|
+
isDisabled: false,
|
|
49
|
+
isFullWidth: false,
|
|
50
|
+
isLoading: false,
|
|
51
|
+
size: "md",
|
|
52
|
+
sizing: "control",
|
|
53
|
+
},
|
|
54
|
+
} satisfies Meta<typeof Button>
|
|
55
|
+
|
|
56
|
+
export default meta
|
|
57
|
+
|
|
58
|
+
type Story = StoryObj<typeof meta>
|
|
59
|
+
|
|
60
|
+
export const Default: Story = {
|
|
61
|
+
args: { children: "Start rip" },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const AllVariants: Story = {
|
|
65
|
+
args: { children: "Start rip" },
|
|
66
|
+
render: (buttonProps) => (
|
|
67
|
+
<StorySection title="Six intents x four appearances. The whole of seven repos' worth of primary/secondary/danger/ghost.">
|
|
68
|
+
<StoryGrid columns={4}>
|
|
69
|
+
{APPEARANCES.flatMap((appearance) =>
|
|
70
|
+
INTENTS.map((intent) => (
|
|
71
|
+
<StoryCell
|
|
72
|
+
key={`${appearance}-${intent}`}
|
|
73
|
+
label={`${appearance} · ${intent}`}
|
|
74
|
+
>
|
|
75
|
+
<Button
|
|
76
|
+
{...buttonProps}
|
|
77
|
+
appearance={appearance}
|
|
78
|
+
intent={intent}
|
|
79
|
+
>
|
|
80
|
+
{intent}
|
|
81
|
+
</Button>
|
|
82
|
+
</StoryCell>
|
|
83
|
+
)),
|
|
84
|
+
)}
|
|
85
|
+
</StoryGrid>
|
|
86
|
+
</StorySection>
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const AllSizes: Story = {
|
|
91
|
+
args: { children: "Start rip" },
|
|
92
|
+
render: (buttonProps) => (
|
|
93
|
+
<StorySection title="Sizes come from the density axis — flip Density in the toolbar and every one of these changes with no prop change and no re-render.">
|
|
94
|
+
<StoryRow>
|
|
95
|
+
<Button {...buttonProps} size="sm">
|
|
96
|
+
Small
|
|
97
|
+
</Button>
|
|
98
|
+
|
|
99
|
+
<Button {...buttonProps} size="md">
|
|
100
|
+
Medium
|
|
101
|
+
</Button>
|
|
102
|
+
|
|
103
|
+
<Button {...buttonProps} size="lg">
|
|
104
|
+
Large
|
|
105
|
+
</Button>
|
|
106
|
+
</StoryRow>
|
|
107
|
+
</StorySection>
|
|
108
|
+
),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Hover, active, and focus are real here, not simulated: the
|
|
113
|
+
* pseudo-states addon forces them, which is the only way to review
|
|
114
|
+
* a hover colour in a static board.
|
|
115
|
+
*
|
|
116
|
+
* `focusVisible` is forced the same way rather than tabbed to. A
|
|
117
|
+
* board that has to be *driven* to show its states is a test
|
|
118
|
+
* wearing a demo's clothes — and the tab count silently depended on
|
|
119
|
+
* how many focusable cells sat above it.
|
|
120
|
+
*/
|
|
121
|
+
export const AllStates: Story = {
|
|
122
|
+
args: { children: "Start rip" },
|
|
123
|
+
parameters: {
|
|
124
|
+
pseudo: {
|
|
125
|
+
active: ["#active"],
|
|
126
|
+
focusVisible: ["#focus-target"],
|
|
127
|
+
hover: ["#hover"],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
render: (buttonProps) => (
|
|
131
|
+
<StoryGrid columns={3}>
|
|
132
|
+
<StoryCell label="default">
|
|
133
|
+
<Button {...buttonProps} />
|
|
134
|
+
</StoryCell>
|
|
135
|
+
|
|
136
|
+
<StoryCell label="hover (forced)">
|
|
137
|
+
<Button {...buttonProps} id="hover" />
|
|
138
|
+
</StoryCell>
|
|
139
|
+
|
|
140
|
+
<StoryCell label="active (forced)">
|
|
141
|
+
<Button {...buttonProps} id="active" />
|
|
142
|
+
</StoryCell>
|
|
143
|
+
|
|
144
|
+
<StoryCell label="focus-visible (forced)">
|
|
145
|
+
<Button {...buttonProps} id="focus-target" />
|
|
146
|
+
</StoryCell>
|
|
147
|
+
|
|
148
|
+
<StoryCell label="disabled">
|
|
149
|
+
<Button {...buttonProps} isDisabled />
|
|
150
|
+
</StoryCell>
|
|
151
|
+
|
|
152
|
+
<StoryCell label="loading">
|
|
153
|
+
<Button {...buttonProps} isLoading />
|
|
154
|
+
</StoryCell>
|
|
155
|
+
</StoryGrid>
|
|
156
|
+
),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Disabled *and* announced. The spinner's label lives in a
|
|
161
|
+
* `role="status"`, so a screen reader hears the work start rather
|
|
162
|
+
* than only seeing it.
|
|
163
|
+
*/
|
|
164
|
+
export const Loading: Story = {
|
|
165
|
+
args: { children: "Ripping disc 3", isLoading: true },
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const Responsive: Story = {
|
|
169
|
+
args: { children: "Start rip", isFullWidth: true },
|
|
170
|
+
render: (buttonProps) => (
|
|
171
|
+
<ContainerBoard>
|
|
172
|
+
<Button {...buttonProps} />
|
|
173
|
+
</ContainerBoard>
|
|
174
|
+
),
|
|
175
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
composeStories,
|
|
3
|
+
composeStory,
|
|
4
|
+
} from "@storybook/react"
|
|
5
|
+
import { expect, fn, userEvent } from "storybook/test"
|
|
6
|
+
import { test } from "vitest"
|
|
7
|
+
|
|
8
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
9
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
10
|
+
import meta, * as stories from "./Button.stories.tsx"
|
|
11
|
+
|
|
12
|
+
const { AllStates, Default, Loading } =
|
|
13
|
+
composeStories(stories)
|
|
14
|
+
|
|
15
|
+
test("is drivable by role and name", async () => {
|
|
16
|
+
const { canvas } = await mountStory(Default)
|
|
17
|
+
|
|
18
|
+
expectAgentDrivable(canvas, {
|
|
19
|
+
name: "Start rip",
|
|
20
|
+
role: "button",
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The keyboard contract, asserted rather than assumed: Tab reaches
|
|
26
|
+
* it, Enter activates, Space activates. Every hand-rolled
|
|
27
|
+
* `<div onClick>` in the fleet fails all three.
|
|
28
|
+
*/
|
|
29
|
+
test("Tab reaches it, and Enter and Space both activate", async () => {
|
|
30
|
+
const onClick = fn()
|
|
31
|
+
|
|
32
|
+
const { canvas } = await mountStory(
|
|
33
|
+
composeStory(
|
|
34
|
+
{ args: { children: "Start rip", onClick } },
|
|
35
|
+
meta,
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const button = expectAgentDrivable(canvas, {
|
|
40
|
+
name: "Start rip",
|
|
41
|
+
role: "button",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await userEvent.tab()
|
|
45
|
+
|
|
46
|
+
await expect(button).toHaveFocus()
|
|
47
|
+
|
|
48
|
+
await userEvent.keyboard("{Enter}")
|
|
49
|
+
|
|
50
|
+
await expect(onClick).toHaveBeenCalledTimes(1)
|
|
51
|
+
|
|
52
|
+
await userEvent.keyboard(" ")
|
|
53
|
+
|
|
54
|
+
await expect(onClick).toHaveBeenCalledTimes(2)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("a disabled button is skipped by Tab and ignores a click", async () => {
|
|
58
|
+
const onClick = fn()
|
|
59
|
+
|
|
60
|
+
const { canvas } = await mountStory(
|
|
61
|
+
composeStory(
|
|
62
|
+
{
|
|
63
|
+
args: {
|
|
64
|
+
children: "Start rip",
|
|
65
|
+
isDisabled: true,
|
|
66
|
+
onClick,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
meta,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const button = canvas.getByRole("button", {
|
|
74
|
+
name: "Start rip",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await userEvent.click(button)
|
|
78
|
+
|
|
79
|
+
// A `<div role="button">` with a guard clause looks the same and
|
|
80
|
+
// is not the same: a real `disabled` is skipped by Tab, ignored
|
|
81
|
+
// by click, and reported to AT.
|
|
82
|
+
await expect(onClick).not.toHaveBeenCalled()
|
|
83
|
+
await expect(button).toBeDisabled()
|
|
84
|
+
|
|
85
|
+
await userEvent.tab()
|
|
86
|
+
|
|
87
|
+
await expect(button).not.toHaveFocus()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("loading disables the button and announces the work", async () => {
|
|
91
|
+
const { canvas } = await mountStory(Loading)
|
|
92
|
+
|
|
93
|
+
const button = expectAgentDrivable(canvas, {
|
|
94
|
+
name: /Ripping disc 3/,
|
|
95
|
+
role: "button",
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await expect(button).toBeDisabled()
|
|
99
|
+
await expect(button).toHaveAttribute("aria-busy", "true")
|
|
100
|
+
|
|
101
|
+
// The spinner's label lives in a `role="status"`, so a screen
|
|
102
|
+
// reader hears the work start rather than only seeing it.
|
|
103
|
+
await expect(
|
|
104
|
+
canvas.getByRole("status"),
|
|
105
|
+
).toBeInTheDocument()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("the states board really renders a busy button", async () => {
|
|
109
|
+
const { canvas } = await mountStory(AllStates)
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
canvas.getByRole("button", { name: /loading/i }),
|
|
113
|
+
).toHaveAttribute("aria-busy", "true")
|
|
114
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ControlSize,
|
|
3
|
+
IntentName,
|
|
4
|
+
} from "@charcuterie/tokens"
|
|
5
|
+
import type {
|
|
6
|
+
ComponentPropsWithRef,
|
|
7
|
+
ReactNode,
|
|
8
|
+
} from "react"
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CONTROL_SIZE_CLASS,
|
|
12
|
+
ICON_CONTROL_SIZE_CLASS,
|
|
13
|
+
} from "../controlStyles.ts"
|
|
14
|
+
import type { IntentAppearance } from "../intentStyles.ts"
|
|
15
|
+
import {
|
|
16
|
+
DISABLED_CLASS,
|
|
17
|
+
FOCUS_RING_CLASS,
|
|
18
|
+
INTENT_APPEARANCE_CLASS,
|
|
19
|
+
INTENT_HOVER_CLASS,
|
|
20
|
+
} from "../intentStyles.ts"
|
|
21
|
+
import { Spinner } from "../Spinner/Spinner.tsx"
|
|
22
|
+
import { toClassName } from "../toClassName.ts"
|
|
23
|
+
|
|
24
|
+
export type ButtonProps = Omit<
|
|
25
|
+
ComponentPropsWithRef<"button">,
|
|
26
|
+
"disabled"
|
|
27
|
+
> & {
|
|
28
|
+
appearance?: IntentAppearance
|
|
29
|
+
iconEnd?: ReactNode
|
|
30
|
+
iconStart?: ReactNode
|
|
31
|
+
intent?: IntentName
|
|
32
|
+
isDisabled?: boolean
|
|
33
|
+
isFullWidth?: boolean
|
|
34
|
+
isLoading?: boolean
|
|
35
|
+
/** What the spinner announces. Not shown. */
|
|
36
|
+
loadingLabel?: string
|
|
37
|
+
size?: ControlSize
|
|
38
|
+
/**
|
|
39
|
+
* Two sizing modes rather than a second component's worth of
|
|
40
|
+
* markup. `icon` is square on the control height with no inline
|
|
41
|
+
* padding, which is what lines an `IconButton` up with the text
|
|
42
|
+
* button beside it in a toolbar.
|
|
43
|
+
*
|
|
44
|
+
* It lives here rather than in `IconButton` because appending a
|
|
45
|
+
* competing `size-*`/`px-0` to this component's `className` would
|
|
46
|
+
* be a specificity coin-flip: two utilities setting the same
|
|
47
|
+
* property, and the winner decided by Tailwind's internal
|
|
48
|
+
* ordering rather than by the caller.
|
|
49
|
+
*/
|
|
50
|
+
sizing?: "control" | "icon"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Hand-rolled in **seven repos**, each with its own
|
|
55
|
+
* primary/secondary/danger/ghost, and this is the component that
|
|
56
|
+
* erases the most duplication in the fleet.
|
|
57
|
+
*
|
|
58
|
+
* Four decisions worth knowing, all of them fixing something an
|
|
59
|
+
* inventoried repo gets wrong:
|
|
60
|
+
*
|
|
61
|
+
* - **`type="button"` by default.** A `<button>` inside a form
|
|
62
|
+
* submits it unless told otherwise, and "clicking the copy icon
|
|
63
|
+
* reloaded the page" is a bug every app in the fleet has shipped
|
|
64
|
+
* at least once.
|
|
65
|
+
* - **`isLoading` disables and announces.** The button goes
|
|
66
|
+
* `aria-busy`, the spinner replaces `iconStart` rather than
|
|
67
|
+
* being appended (so the label does not jump), and the label
|
|
68
|
+
* itself stays — a spinner that swallows the text leaves the
|
|
69
|
+
* user unsure what they pressed.
|
|
70
|
+
* - **`focus-visible`, never `focus`.** See `FOCUS_RING_CLASS`.
|
|
71
|
+
* - **Sizing comes from the density axis**, not from a prop table:
|
|
72
|
+
* `size="md"` is 2.25rem on a desktop and 3.25rem on the kiosk,
|
|
73
|
+
* with no prop change. `MIN_TOUCH_TARGET_CLASS` is available for
|
|
74
|
+
* a control that must clear 44px regardless.
|
|
75
|
+
*
|
|
76
|
+
* The accessible name is the children. `IconButton` exists because
|
|
77
|
+
* a glyph is not a name, and enforcing that in *this* component's
|
|
78
|
+
* types would mean rejecting `<Button>{icon} Copy</Button>`, which
|
|
79
|
+
* is fine.
|
|
80
|
+
*/
|
|
81
|
+
export const Button = ({
|
|
82
|
+
appearance = "solid",
|
|
83
|
+
children,
|
|
84
|
+
className,
|
|
85
|
+
iconEnd,
|
|
86
|
+
iconStart,
|
|
87
|
+
intent = "accent",
|
|
88
|
+
isDisabled = false,
|
|
89
|
+
isFullWidth = false,
|
|
90
|
+
isLoading = false,
|
|
91
|
+
loadingLabel = "Loading…",
|
|
92
|
+
size = "md",
|
|
93
|
+
sizing = "control",
|
|
94
|
+
type = "button",
|
|
95
|
+
...buttonProps
|
|
96
|
+
}: ButtonProps): ReactNode => (
|
|
97
|
+
<button
|
|
98
|
+
{...buttonProps}
|
|
99
|
+
aria-busy={isLoading || undefined}
|
|
100
|
+
className={toClassName(
|
|
101
|
+
"inline-flex cursor-pointer items-center justify-center rounded-md border font-medium whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard select-none",
|
|
102
|
+
sizing === "icon"
|
|
103
|
+
? ICON_CONTROL_SIZE_CLASS[size]
|
|
104
|
+
: CONTROL_SIZE_CLASS[size],
|
|
105
|
+
INTENT_APPEARANCE_CLASS[intent][appearance],
|
|
106
|
+
INTENT_HOVER_CLASS[intent][appearance],
|
|
107
|
+
FOCUS_RING_CLASS,
|
|
108
|
+
DISABLED_CLASS,
|
|
109
|
+
isFullWidth && "w-full",
|
|
110
|
+
className,
|
|
111
|
+
)}
|
|
112
|
+
disabled={isDisabled || isLoading}
|
|
113
|
+
type={type}
|
|
114
|
+
>
|
|
115
|
+
{isLoading ? (
|
|
116
|
+
<Spinner label={loadingLabel} size={size} />
|
|
117
|
+
) : (
|
|
118
|
+
iconStart
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{children}
|
|
122
|
+
|
|
123
|
+
{iconEnd}
|
|
124
|
+
</button>
|
|
125
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as CardStories from "./Card.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={CardStories} />
|
|
6
|
+
|
|
7
|
+
# Card
|
|
8
|
+
|
|
9
|
+
Five repos, and the reason it is P0 is the `<section>`: give a card a heading and it becomes
|
|
10
|
+
a **landmark**, so `getByRole("region", { name: "Bay 3" })` scopes every query inside it.
|
|
11
|
+
Without that, an agent driving a 16-bay tower sees sixteen identical "Start" buttons and no
|
|
12
|
+
way to say which one it means. The `Interactive` story asserts exactly that.
|
|
13
|
+
|
|
14
|
+
<Canvas of={CardStories.Default} />
|
|
15
|
+
|
|
16
|
+
## Props
|
|
17
|
+
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## Density is not a prop
|
|
21
|
+
|
|
22
|
+
The withdrawn bambuddy evidence included a `CardDensityProvider`, and it is the wrong shape
|
|
23
|
+
for this fleet: density is a global axis on `<html>`, so a compact bay list is
|
|
24
|
+
`data-density="compact"` on the page rather than a context around the cards. `padding`
|
|
25
|
+
stays a prop because a card wrapping a poster grid genuinely has none — a layout fact, not
|
|
26
|
+
a density one.
|
|
27
|
+
|
|
28
|
+
## Names must be unique
|
|
29
|
+
|
|
30
|
+
Two `<section>`s sharing an accessible name is axe's `landmark-unique`, and it is a real
|
|
31
|
+
finding rather than a technicality: a screen-reader user navigating by landmark gets two
|
|
32
|
+
"Bay 3" regions and no way to tell them apart. A card with no heading is not a landmark at
|
|
33
|
+
all, deliberately — it has nothing to be named.
|
|
34
|
+
|
|
35
|
+
`raised` means *more separated from base*, not *lighter*, which is why light mode's base is
|
|
36
|
+
`#F5F7FA` and its raised is white.
|
|
37
|
+
|
|
38
|
+
<Canvas of={CardStories.AllVariants} />
|
|
39
|
+
|
|
40
|
+
## Container queries, not media queries
|
|
41
|
+
|
|
42
|
+
> **One constraint that comes with the territory.** `container-type: inline-size` implies
|
|
43
|
+
> `contain: inline-size`, so a `Card` may not be sized by its own contents. In a grid track,
|
|
44
|
+
> a block flow, or a `flex-1` slot it is fine — that is nearly every real placement. Drop one
|
|
45
|
+
> into a shrink-to-fit parent (`items-start`, an `inline-flex` row) and it collapses to
|
|
46
|
+
> min-content, wrapping every line after one word. Give it a definite inline size there.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
The header stacks below `--cq-sm` and sits on one row above it, and the container is the
|
|
50
|
+
**card**. The same card in a sidebar and full-bleed on a dashboard is the case a media query
|
|
51
|
+
cannot tell apart, and it is where the fleet's poster grids look wrong today.
|
|
52
|
+
|
|
53
|
+
<Canvas of={CardStories.Responsive} />
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { toStoryChoice } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import { Badge } from "../Badge/Badge.tsx"
|
|
5
|
+
import { Button } from "../Button/Button.tsx"
|
|
6
|
+
import {
|
|
7
|
+
ContainerBoard,
|
|
8
|
+
StoryCell,
|
|
9
|
+
StoryGrid,
|
|
10
|
+
StorySection,
|
|
11
|
+
} from "../board.storyHelpers.tsx"
|
|
12
|
+
import { IconButton } from "../IconButton/IconButton.tsx"
|
|
13
|
+
import { SettingsIcon } from "../icons.storyHelpers.tsx"
|
|
14
|
+
import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
|
|
15
|
+
import { Card, ELEVATION_CLASS } from "./Card.tsx"
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: "Components/Card",
|
|
19
|
+
component: Card,
|
|
20
|
+
parameters: { layout: "padded" },
|
|
21
|
+
argTypes: {
|
|
22
|
+
elevation: toStoryChoice(
|
|
23
|
+
Object.keys(
|
|
24
|
+
ELEVATION_CLASS,
|
|
25
|
+
) as (keyof typeof ELEVATION_CLASS)[],
|
|
26
|
+
),
|
|
27
|
+
},
|
|
28
|
+
args: {
|
|
29
|
+
elevation: "low",
|
|
30
|
+
headingLevel: 2,
|
|
31
|
+
padding: "md",
|
|
32
|
+
surface: "raised",
|
|
33
|
+
},
|
|
34
|
+
} satisfies Meta<typeof Card>
|
|
35
|
+
|
|
36
|
+
export default meta
|
|
37
|
+
|
|
38
|
+
type Story = StoryObj<typeof meta>
|
|
39
|
+
|
|
40
|
+
export const Default: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
children: (
|
|
43
|
+
<p className="text-content-secondary text-sm">
|
|
44
|
+
Ripping <em>Blade Runner (1982)</em> — title 4 of 9.
|
|
45
|
+
</p>
|
|
46
|
+
),
|
|
47
|
+
heading: "Bay 3",
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const AllVariants: Story = {
|
|
52
|
+
args: { children: "Content" },
|
|
53
|
+
render: () => (
|
|
54
|
+
<StorySection title="Three surfaces x four elevations. `raised` means more separated from base, not lighter — which is why light mode's base is #F5F7FA and its raised is white.">
|
|
55
|
+
<StoryGrid columns={3}>
|
|
56
|
+
{(["base", "raised", "sunken"] as const).flatMap(
|
|
57
|
+
(surface) =>
|
|
58
|
+
(
|
|
59
|
+
["none", "low", "medium", "high"] as const
|
|
60
|
+
).map((elevation) => (
|
|
61
|
+
<StoryCell
|
|
62
|
+
align="stretch"
|
|
63
|
+
key={`${surface}-${elevation}`}
|
|
64
|
+
label={`${surface} · ${elevation}`}
|
|
65
|
+
>
|
|
66
|
+
<Card
|
|
67
|
+
elevation={elevation}
|
|
68
|
+
// Distinct, because two `<section>`s sharing an
|
|
69
|
+
// accessible name is an axe `landmark-unique`
|
|
70
|
+
// violation — a real one: a screen-reader user
|
|
71
|
+
// navigating by landmark gets two "raised"
|
|
72
|
+
// regions and no way to tell them apart.
|
|
73
|
+
heading={`${surface} · ${elevation}`}
|
|
74
|
+
surface={surface}
|
|
75
|
+
>
|
|
76
|
+
<p className="text-content-secondary text-sm">
|
|
77
|
+
shadow-{elevation}
|
|
78
|
+
</p>
|
|
79
|
+
</Card>
|
|
80
|
+
</StoryCell>
|
|
81
|
+
)),
|
|
82
|
+
)}
|
|
83
|
+
</StoryGrid>
|
|
84
|
+
</StorySection>
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const AllStates: Story = {
|
|
89
|
+
args: { children: "Content" },
|
|
90
|
+
render: () => (
|
|
91
|
+
<StoryGrid columns={2}>
|
|
92
|
+
<StoryCell align="stretch" label="heading only">
|
|
93
|
+
<Card heading="Bay 1" />
|
|
94
|
+
</StoryCell>
|
|
95
|
+
|
|
96
|
+
<StoryCell
|
|
97
|
+
align="stretch"
|
|
98
|
+
label="no heading — not a landmark, by design"
|
|
99
|
+
>
|
|
100
|
+
<Card>
|
|
101
|
+
<p className="text-content-secondary text-sm">
|
|
102
|
+
A card with no name cannot be a region, so it is
|
|
103
|
+
not pretending to be one.
|
|
104
|
+
</p>
|
|
105
|
+
</Card>
|
|
106
|
+
</StoryCell>
|
|
107
|
+
|
|
108
|
+
<StoryCell
|
|
109
|
+
align="stretch"
|
|
110
|
+
label="heading + actions + footer"
|
|
111
|
+
>
|
|
112
|
+
<Card
|
|
113
|
+
actions={
|
|
114
|
+
<>
|
|
115
|
+
<Badge intent="info" size="sm">
|
|
116
|
+
ripping
|
|
117
|
+
</Badge>
|
|
118
|
+
|
|
119
|
+
<IconButton
|
|
120
|
+
appearance="ghost"
|
|
121
|
+
label="Bay 3 settings"
|
|
122
|
+
size="sm"
|
|
123
|
+
>
|
|
124
|
+
<SettingsIcon />
|
|
125
|
+
</IconButton>
|
|
126
|
+
</>
|
|
127
|
+
}
|
|
128
|
+
footer="Started 14:02 · MakeMKV 1.17.8"
|
|
129
|
+
heading="Bay 2"
|
|
130
|
+
>
|
|
131
|
+
<ProgressBar
|
|
132
|
+
isValueShown
|
|
133
|
+
label="Ripping title 4 of 9"
|
|
134
|
+
value={47}
|
|
135
|
+
/>
|
|
136
|
+
</Card>
|
|
137
|
+
</StoryCell>
|
|
138
|
+
|
|
139
|
+
<StoryCell
|
|
140
|
+
align="stretch"
|
|
141
|
+
label="padding=none — for a poster grid"
|
|
142
|
+
>
|
|
143
|
+
<Card heading="Recently added" padding="none">
|
|
144
|
+
<div className="h-16 rounded-md bg-surface-sunken" />
|
|
145
|
+
</Card>
|
|
146
|
+
</StoryCell>
|
|
147
|
+
</StoryGrid>
|
|
148
|
+
),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The header stacks below `--cq-sm` and sits on one row above it —
|
|
153
|
+
* and the container is the *card*, not the window, which is the case
|
|
154
|
+
* a media query genuinely cannot express: the same card in a sidebar
|
|
155
|
+
* and full-bleed on a dashboard.
|
|
156
|
+
*/
|
|
157
|
+
export const Responsive: Story = {
|
|
158
|
+
args: { children: "Content" },
|
|
159
|
+
render: () => (
|
|
160
|
+
<ContainerBoard>
|
|
161
|
+
{(width) => (
|
|
162
|
+
<Card
|
|
163
|
+
actions={
|
|
164
|
+
<Button appearance="soft" size="sm">
|
|
165
|
+
Start rip
|
|
166
|
+
</Button>
|
|
167
|
+
}
|
|
168
|
+
heading={`Bay 3 at ${width} — Blade Runner (1982)`}
|
|
169
|
+
>
|
|
170
|
+
<ProgressBar
|
|
171
|
+
label={`Ripping title 4 of 9 at ${width}`}
|
|
172
|
+
value={47}
|
|
173
|
+
/>
|
|
174
|
+
</Card>
|
|
175
|
+
)}
|
|
176
|
+
</ContainerBoard>
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Two identical "Start rip" buttons on one page.
|
|
182
|
+
*
|
|
183
|
+
* Unscoped, `getByRole("button", { name: "Start rip" })` is
|
|
184
|
+
* ambiguous — which is exactly what `expectAgentDrivable` refuses.
|
|
185
|
+
* Scoping by region is the fix, and it only exists because the cards
|
|
186
|
+
* are named. That is the whole a11y argument for this component: an
|
|
187
|
+
* agent driving a 16-bay tower can say *which* "Start" it means.
|
|
188
|
+
*/
|
|
189
|
+
export const Interactive: Story = {
|
|
190
|
+
args: { children: "Content" },
|
|
191
|
+
render: () => (
|
|
192
|
+
<div className="flex flex-col gap-3">
|
|
193
|
+
{[3, 4].map((bay) => (
|
|
194
|
+
<Card
|
|
195
|
+
actions={<Button size="sm">Start rip</Button>}
|
|
196
|
+
heading={`Bay ${bay}`}
|
|
197
|
+
key={bay}
|
|
198
|
+
>
|
|
199
|
+
<ProgressBar
|
|
200
|
+
label={`Bay ${bay} progress`}
|
|
201
|
+
value={bay * 10}
|
|
202
|
+
/>
|
|
203
|
+
</Card>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
),
|
|
207
|
+
}
|