@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,191 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ContainerBoard,
|
|
7
|
+
StoryCell,
|
|
8
|
+
StoryGrid,
|
|
9
|
+
StoryRow,
|
|
10
|
+
StorySection,
|
|
11
|
+
} from "../board.storyHelpers.tsx"
|
|
12
|
+
import {
|
|
13
|
+
RedoIcon,
|
|
14
|
+
SettingsIcon,
|
|
15
|
+
UndoIcon,
|
|
16
|
+
} from "../icons.storyHelpers.tsx"
|
|
17
|
+
import { IconButton } from "./IconButton.tsx"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The library ships **no icons** — lucide is the fleet recommendation
|
|
21
|
+
* and `children` is whatever the app already has, so these are
|
|
22
|
+
* story-only SVGs.
|
|
23
|
+
*
|
|
24
|
+
* `RawGlyph` below keeps plex-channels' actual `↶` for comparison:
|
|
25
|
+
* the glyph is unchanged, the *name* is what this component adds.
|
|
26
|
+
*/
|
|
27
|
+
const UNDO_ICON = <UndoIcon />
|
|
28
|
+
|
|
29
|
+
const meta = {
|
|
30
|
+
title: "Components/IconButton",
|
|
31
|
+
component: IconButton,
|
|
32
|
+
parameters: { layout: "padded" },
|
|
33
|
+
argTypes: { size: controlSizeArgType },
|
|
34
|
+
args: {
|
|
35
|
+
appearance: "ghost",
|
|
36
|
+
isDisabled: false,
|
|
37
|
+
isLoading: false,
|
|
38
|
+
size: "md",
|
|
39
|
+
},
|
|
40
|
+
} satisfies Meta<typeof IconButton>
|
|
41
|
+
|
|
42
|
+
export default meta
|
|
43
|
+
|
|
44
|
+
type Story = StoryObj<typeof meta>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The name is the whole component. plex-channels renders `↶` into a
|
|
48
|
+
* bare `<button>` today, so a screen reader announces "↶" and
|
|
49
|
+
* `getByRole("button", { name: "Undo" })` finds nothing.
|
|
50
|
+
*/
|
|
51
|
+
export const Default: Story = {
|
|
52
|
+
args: { children: UNDO_ICON, label: "Undo" },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const AllVariants: Story = {
|
|
56
|
+
args: { children: UNDO_ICON, label: "Undo" },
|
|
57
|
+
render: (iconButtonProps) => (
|
|
58
|
+
<StorySection title="Same four appearances as Button — ghost is the toolbar default, because a row of six solid squares is a wall.">
|
|
59
|
+
<StoryGrid columns={4}>
|
|
60
|
+
<StoryCell label="solid">
|
|
61
|
+
<IconButton
|
|
62
|
+
{...iconButtonProps}
|
|
63
|
+
appearance="solid"
|
|
64
|
+
/>
|
|
65
|
+
</StoryCell>
|
|
66
|
+
|
|
67
|
+
<StoryCell label="soft">
|
|
68
|
+
<IconButton
|
|
69
|
+
{...iconButtonProps}
|
|
70
|
+
appearance="soft"
|
|
71
|
+
/>
|
|
72
|
+
</StoryCell>
|
|
73
|
+
|
|
74
|
+
<StoryCell label="outline">
|
|
75
|
+
<IconButton
|
|
76
|
+
{...iconButtonProps}
|
|
77
|
+
appearance="outline"
|
|
78
|
+
/>
|
|
79
|
+
</StoryCell>
|
|
80
|
+
|
|
81
|
+
<StoryCell label="ghost">
|
|
82
|
+
<IconButton
|
|
83
|
+
{...iconButtonProps}
|
|
84
|
+
appearance="ghost"
|
|
85
|
+
/>
|
|
86
|
+
</StoryCell>
|
|
87
|
+
</StoryGrid>
|
|
88
|
+
</StorySection>
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const AllStates: Story = {
|
|
93
|
+
args: { children: UNDO_ICON, label: "Undo" },
|
|
94
|
+
parameters: {
|
|
95
|
+
pseudo: {
|
|
96
|
+
active: ["#active"],
|
|
97
|
+
focusVisible: ["#focus-target"],
|
|
98
|
+
hover: ["#hover"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
render: (iconButtonProps) => (
|
|
102
|
+
<StoryGrid columns={4}>
|
|
103
|
+
<StoryCell label="default">
|
|
104
|
+
<IconButton {...iconButtonProps} />
|
|
105
|
+
</StoryCell>
|
|
106
|
+
|
|
107
|
+
<StoryCell label="hover (forced)">
|
|
108
|
+
<IconButton {...iconButtonProps} id="hover" />
|
|
109
|
+
</StoryCell>
|
|
110
|
+
|
|
111
|
+
<StoryCell label="active (forced)">
|
|
112
|
+
<IconButton {...iconButtonProps} id="active" />
|
|
113
|
+
</StoryCell>
|
|
114
|
+
|
|
115
|
+
<StoryCell label="focus-visible (forced)">
|
|
116
|
+
<IconButton
|
|
117
|
+
{...iconButtonProps}
|
|
118
|
+
id="focus-target"
|
|
119
|
+
/>
|
|
120
|
+
</StoryCell>
|
|
121
|
+
|
|
122
|
+
<StoryCell label="disabled">
|
|
123
|
+
<IconButton {...iconButtonProps} isDisabled />
|
|
124
|
+
</StoryCell>
|
|
125
|
+
|
|
126
|
+
<StoryCell label="loading">
|
|
127
|
+
<IconButton {...iconButtonProps} isLoading />
|
|
128
|
+
</StoryCell>
|
|
129
|
+
</StoryGrid>
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const Sizes: Story = {
|
|
134
|
+
args: { children: UNDO_ICON, label: "Undo" },
|
|
135
|
+
render: (iconButtonProps) => (
|
|
136
|
+
<StorySection title="Square on the control height, so an icon button lines up with the text button beside it.">
|
|
137
|
+
<StoryRow>
|
|
138
|
+
<IconButton {...iconButtonProps} size="sm" />
|
|
139
|
+
|
|
140
|
+
<IconButton {...iconButtonProps} size="md" />
|
|
141
|
+
|
|
142
|
+
<IconButton {...iconButtonProps} size="lg" />
|
|
143
|
+
</StoryRow>
|
|
144
|
+
</StorySection>
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const Responsive: Story = {
|
|
149
|
+
args: { children: UNDO_ICON, label: "Undo" },
|
|
150
|
+
render: (iconButtonProps) => (
|
|
151
|
+
<ContainerBoard>
|
|
152
|
+
<StoryRow>
|
|
153
|
+
<IconButton
|
|
154
|
+
{...iconButtonProps}
|
|
155
|
+
appearance="ghost"
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
<IconButton
|
|
159
|
+
{...iconButtonProps}
|
|
160
|
+
appearance="ghost"
|
|
161
|
+
label="Redo"
|
|
162
|
+
>
|
|
163
|
+
<RedoIcon />
|
|
164
|
+
</IconButton>
|
|
165
|
+
|
|
166
|
+
<IconButton
|
|
167
|
+
{...iconButtonProps}
|
|
168
|
+
appearance="ghost"
|
|
169
|
+
label="Settings"
|
|
170
|
+
>
|
|
171
|
+
<SettingsIcon />
|
|
172
|
+
</IconButton>
|
|
173
|
+
</StoryRow>
|
|
174
|
+
</ContainerBoard>
|
|
175
|
+
),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* plex-channels' `↶`, rendered exactly as it is today — and named,
|
|
180
|
+
* which it is not today. `getByRole("button", { name: "Undo" })`
|
|
181
|
+
* resolves here and finds nothing there.
|
|
182
|
+
*
|
|
183
|
+
* A glyph depends on a font having that code point: this repo's
|
|
184
|
+
* headless Chromium has none for `↶`, so in a CI screenshot the
|
|
185
|
+
* button is empty while the *name* is still correct. That is the
|
|
186
|
+
* argument for an SVG icon set, made by the story rather than in a
|
|
187
|
+
* comment.
|
|
188
|
+
*/
|
|
189
|
+
export const RawGlyph: Story = {
|
|
190
|
+
args: { children: "↶", label: "Undo" },
|
|
191
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
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 { UndoIcon } from "../icons.storyHelpers.tsx"
|
|
9
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
10
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
11
|
+
import meta, * as stories from "./IconButton.stories.tsx"
|
|
12
|
+
|
|
13
|
+
const { Default, RawGlyph } = composeStories(stories)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The assertion the fleet fails today: plex-channels renders `↶`
|
|
17
|
+
* into a bare `<button>`, so a screen reader announces "↶" and this
|
|
18
|
+
* query finds nothing.
|
|
19
|
+
*/
|
|
20
|
+
test("a glyph gets a name", async () => {
|
|
21
|
+
const { canvas } = await mountStory(Default)
|
|
22
|
+
|
|
23
|
+
expectAgentDrivable(canvas, {
|
|
24
|
+
name: "Undo",
|
|
25
|
+
role: "button",
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("the same is true of a raw text glyph", async () => {
|
|
30
|
+
const { canvas } = await mountStory(RawGlyph)
|
|
31
|
+
|
|
32
|
+
// The glyph is unchanged from what plex-channels ships; the *name*
|
|
33
|
+
// is what this component adds. A font without the code point
|
|
34
|
+
// renders nothing at all — as this repo's headless chromium does —
|
|
35
|
+
// and the name still resolves.
|
|
36
|
+
expectAgentDrivable(canvas, {
|
|
37
|
+
name: "Undo",
|
|
38
|
+
role: "button",
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("Tab reaches it and Enter activates", async () => {
|
|
43
|
+
const onClick = fn()
|
|
44
|
+
|
|
45
|
+
const { canvas } = await mountStory(
|
|
46
|
+
composeStory(
|
|
47
|
+
{
|
|
48
|
+
args: {
|
|
49
|
+
children: <UndoIcon />,
|
|
50
|
+
label: "Undo",
|
|
51
|
+
onClick,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
meta,
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const button = expectAgentDrivable(canvas, {
|
|
59
|
+
name: "Undo",
|
|
60
|
+
role: "button",
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await userEvent.tab()
|
|
64
|
+
|
|
65
|
+
await expect(button).toHaveFocus()
|
|
66
|
+
|
|
67
|
+
await userEvent.keyboard("{Enter}")
|
|
68
|
+
|
|
69
|
+
await expect(onClick).toHaveBeenCalledTimes(1)
|
|
70
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import type { ButtonProps } from "../Button/Button.tsx"
|
|
4
|
+
import { Button } from "../Button/Button.tsx"
|
|
5
|
+
|
|
6
|
+
export type IconButtonProps = Omit<
|
|
7
|
+
ButtonProps,
|
|
8
|
+
| "aria-label"
|
|
9
|
+
| "children"
|
|
10
|
+
| "iconEnd"
|
|
11
|
+
| "iconStart"
|
|
12
|
+
| "sizing"
|
|
13
|
+
> & {
|
|
14
|
+
/**
|
|
15
|
+
* Required, and this single line is most of the component's
|
|
16
|
+
* reason to exist. `label` cannot be defaulted, cannot be
|
|
17
|
+
* omitted, and is not a `ReactNode` — it becomes `aria-label`,
|
|
18
|
+
* which is a string by specification.
|
|
19
|
+
*/
|
|
20
|
+
label: string
|
|
21
|
+
/** The glyph. Anything — lucide, an inline SVG, a character. */
|
|
22
|
+
children: ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Three repos, three strategies, and **none of them names the
|
|
27
|
+
* button**: mux-magic hand-writes five SVG components, castkit
|
|
28
|
+
* keeps an `ICON_PATHS` map, and plex-channels renders raw glyphs
|
|
29
|
+
* (`↶`, `↷`, `▶`, `⚙`, `≡`) straight into a `<button>`. A screen
|
|
30
|
+
* reader reads the last of those as "↶", and
|
|
31
|
+
* `getByRole("button", { name: "Undo" })` finds nothing at all —
|
|
32
|
+
* which is the concrete reason the fleet is not agent-drivable
|
|
33
|
+
* today.
|
|
34
|
+
*
|
|
35
|
+
* So the type system asks for the name. There is no way to render
|
|
36
|
+
* this component without one, and `expectAgentDrivable` in the
|
|
37
|
+
* story proves the name is the one Playwright will match on.
|
|
38
|
+
*
|
|
39
|
+
* **No icons ship here.** The library does not own an icon set —
|
|
40
|
+
* lucide (ISC) is the fleet recommendation — so `children` is
|
|
41
|
+
* whatever the app already has, and the icon is `aria-hidden` by
|
|
42
|
+
* virtue of the `aria-label` overriding the subtree name.
|
|
43
|
+
*/
|
|
44
|
+
export const IconButton = ({
|
|
45
|
+
children,
|
|
46
|
+
label,
|
|
47
|
+
...buttonProps
|
|
48
|
+
}: IconButtonProps): ReactNode => (
|
|
49
|
+
<Button {...buttonProps} aria-label={label} sizing="icon">
|
|
50
|
+
<span aria-hidden="true" className="contents">
|
|
51
|
+
{children}
|
|
52
|
+
</span>
|
|
53
|
+
</Button>
|
|
54
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as LiveStatusIndicatorStories from "./LiveStatusIndicator.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={LiveStatusIndicatorStories} />
|
|
6
|
+
|
|
7
|
+
# LiveStatusIndicator
|
|
8
|
+
|
|
9
|
+
Four repos, four implementations, and **it is the thing users read most** — a wall-mounted
|
|
10
|
+
panel showing stale data under a green dot is worse than one showing nothing.
|
|
11
|
+
|
|
12
|
+
<Canvas of={LiveStatusIndicatorStories.Default} />
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
<Controls />
|
|
17
|
+
|
|
18
|
+
## The distinction all four repos lose
|
|
19
|
+
|
|
20
|
+
`connecting` and `reconnecting` are different states and they are collapsed everywhere
|
|
21
|
+
today. A user who has seen data wants "lost it, getting it back" (amber, moving), not the
|
|
22
|
+
cold-start spinner they already sat through (blue), and not a dead link (red). That is why
|
|
23
|
+
`connectionTransitions` is one of only two machines `@charcuterie/logic` ships rather than
|
|
24
|
+
something each app defines.
|
|
25
|
+
|
|
26
|
+
Wording and colour both come from exhaustive switches over that machine, so adding a fifth
|
|
27
|
+
state stops this package compiling instead of rendering a grey dot.
|
|
28
|
+
|
|
29
|
+
<Canvas of={LiveStatusIndicatorStories.AllVariants} />
|
|
30
|
+
|
|
31
|
+
## Accessibility contract
|
|
32
|
+
|
|
33
|
+
- `role="status"` + `aria-live="polite"`, so a transition is announced without interrupting
|
|
34
|
+
what the user is doing. `assertive` would cut off the sentence in progress, and the dot is
|
|
35
|
+
not going anywhere.
|
|
36
|
+
- `aria-label` as well, because a `status` region takes no accessible name from its content
|
|
37
|
+
— that is what makes `getByRole("status", { name: "Connected" })` resolve.
|
|
38
|
+
- Only `connecting` and `reconnecting` animate. A dot that pulses forever is the fleet's
|
|
39
|
+
current "is it alive?" problem in a new costume.
|
|
40
|
+
- `data-status` is the stable handle for a Playwright assertion — it survives translation,
|
|
41
|
+
where the visible wording does not.
|
|
42
|
+
|
|
43
|
+
<Canvas of={LiveStatusIndicatorStories.Interactive} />
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connectionTransitions,
|
|
3
|
+
useStatus,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
6
|
+
|
|
7
|
+
import { connectionStatusArgType } from "../argTypes.storyHelpers.ts"
|
|
8
|
+
import { Button } from "../Button/Button.tsx"
|
|
9
|
+
import {
|
|
10
|
+
ContainerBoard,
|
|
11
|
+
StoryCell,
|
|
12
|
+
StoryGrid,
|
|
13
|
+
StoryRow,
|
|
14
|
+
StorySection,
|
|
15
|
+
} from "../board.storyHelpers.tsx"
|
|
16
|
+
import { Card } from "../Card/Card.tsx"
|
|
17
|
+
import { LiveStatusIndicator } from "./LiveStatusIndicator.tsx"
|
|
18
|
+
|
|
19
|
+
const CONNECTION_STATUSES = [
|
|
20
|
+
"connecting",
|
|
21
|
+
"connected",
|
|
22
|
+
"reconnecting",
|
|
23
|
+
"disconnected",
|
|
24
|
+
] as const
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: "Components/LiveStatusIndicator",
|
|
28
|
+
component: LiveStatusIndicator,
|
|
29
|
+
parameters: { layout: "padded" },
|
|
30
|
+
argTypes: { status: connectionStatusArgType },
|
|
31
|
+
args: { isLabelVisible: true, size: "md" },
|
|
32
|
+
} satisfies Meta<typeof LiveStatusIndicator>
|
|
33
|
+
|
|
34
|
+
export default meta
|
|
35
|
+
|
|
36
|
+
type Story = StoryObj<typeof meta>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A named live region, plus a `data-status` attribute — the stable
|
|
40
|
+
* handle for a Playwright assertion, because it survives translation
|
|
41
|
+
* where the visible wording does not.
|
|
42
|
+
*/
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: { status: "connected" },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The four states, and the distinction four repos currently lose:
|
|
49
|
+
* `connecting` is blue and cold-start, `reconnecting` is amber and
|
|
50
|
+
* means "you had data, I am getting it back". Collapsing them is why
|
|
51
|
+
* a flaky link reads wrong today.
|
|
52
|
+
*/
|
|
53
|
+
export const AllVariants: Story = {
|
|
54
|
+
args: { status: "connected" },
|
|
55
|
+
render: (indicatorProps) => (
|
|
56
|
+
<StorySection title="Wording and colour both come from the shared machine — add a fifth state to `connectionTransitions` and `statusIntent.ts` stops compiling.">
|
|
57
|
+
<StoryGrid columns={4}>
|
|
58
|
+
{CONNECTION_STATUSES.map((status) => (
|
|
59
|
+
<StoryCell key={status} label={status}>
|
|
60
|
+
<LiveStatusIndicator
|
|
61
|
+
{...indicatorProps}
|
|
62
|
+
status={status}
|
|
63
|
+
/>
|
|
64
|
+
</StoryCell>
|
|
65
|
+
))}
|
|
66
|
+
</StoryGrid>
|
|
67
|
+
</StorySection>
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const AllStates: Story = {
|
|
72
|
+
args: { status: "reconnecting" },
|
|
73
|
+
render: (indicatorProps) => (
|
|
74
|
+
<StoryGrid columns={2}>
|
|
75
|
+
<StoryCell label="label visible (default)">
|
|
76
|
+
<LiveStatusIndicator {...indicatorProps} />
|
|
77
|
+
</StoryCell>
|
|
78
|
+
|
|
79
|
+
<StoryCell label="dot only — label still announced">
|
|
80
|
+
<LiveStatusIndicator
|
|
81
|
+
{...indicatorProps}
|
|
82
|
+
isLabelVisible={false}
|
|
83
|
+
/>
|
|
84
|
+
</StoryCell>
|
|
85
|
+
|
|
86
|
+
<StoryCell label="with detail">
|
|
87
|
+
<LiveStatusIndicator
|
|
88
|
+
{...indicatorProps}
|
|
89
|
+
detail="3 of 4 bays reporting"
|
|
90
|
+
/>
|
|
91
|
+
</StoryCell>
|
|
92
|
+
|
|
93
|
+
<StoryCell label="sizes">
|
|
94
|
+
<StoryRow>
|
|
95
|
+
<LiveStatusIndicator
|
|
96
|
+
{...indicatorProps}
|
|
97
|
+
size="sm"
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
<LiveStatusIndicator
|
|
101
|
+
{...indicatorProps}
|
|
102
|
+
size="md"
|
|
103
|
+
/>
|
|
104
|
+
|
|
105
|
+
<LiveStatusIndicator
|
|
106
|
+
{...indicatorProps}
|
|
107
|
+
size="lg"
|
|
108
|
+
/>
|
|
109
|
+
</StoryRow>
|
|
110
|
+
</StoryCell>
|
|
111
|
+
|
|
112
|
+
{/*
|
|
113
|
+
`stretch`, because `Card` declares a container query. A
|
|
114
|
+
default `start` cell is shrink-to-fit, and
|
|
115
|
+
`container-type: inline-size` forbids the element from
|
|
116
|
+
being sized by its own contents — so the card collapses to
|
|
117
|
+
min-content and "16 bays · 4 ripping" wraps after every
|
|
118
|
+
word. `storyRules.test.ts` now fails on this.
|
|
119
|
+
*/}
|
|
120
|
+
<StoryCell
|
|
121
|
+
align="stretch"
|
|
122
|
+
label="in a card header — the real placement"
|
|
123
|
+
>
|
|
124
|
+
<Card
|
|
125
|
+
actions={
|
|
126
|
+
<LiveStatusIndicator
|
|
127
|
+
status="connected"
|
|
128
|
+
size="sm"
|
|
129
|
+
/>
|
|
130
|
+
}
|
|
131
|
+
heading="Tower"
|
|
132
|
+
>
|
|
133
|
+
<p className="text-content-secondary text-sm">
|
|
134
|
+
16 bays · 4 ripping
|
|
135
|
+
</p>
|
|
136
|
+
</Card>
|
|
137
|
+
</StoryCell>
|
|
138
|
+
</StoryGrid>
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const Responsive: Story = {
|
|
143
|
+
args: {
|
|
144
|
+
detail: "3 of 4 bays reporting",
|
|
145
|
+
status: "reconnecting",
|
|
146
|
+
},
|
|
147
|
+
render: (indicatorProps) => (
|
|
148
|
+
<ContainerBoard>
|
|
149
|
+
<LiveStatusIndicator {...indicatorProps} />
|
|
150
|
+
</ContainerBoard>
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ConnectionLifecycle = () => {
|
|
155
|
+
const { can, status, transitionTo } = useStatus({
|
|
156
|
+
initialState: "connecting",
|
|
157
|
+
transitions: connectionTransitions,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="flex flex-col gap-3">
|
|
162
|
+
<LiveStatusIndicator status={status} />
|
|
163
|
+
|
|
164
|
+
<StoryRow>
|
|
165
|
+
{CONNECTION_STATUSES.map((next) => (
|
|
166
|
+
<Button
|
|
167
|
+
appearance="soft"
|
|
168
|
+
isDisabled={!can(next) && status !== next}
|
|
169
|
+
key={next}
|
|
170
|
+
onClick={() => {
|
|
171
|
+
transitionTo(next)
|
|
172
|
+
}}
|
|
173
|
+
size="sm"
|
|
174
|
+
>
|
|
175
|
+
{next}
|
|
176
|
+
</Button>
|
|
177
|
+
))}
|
|
178
|
+
</StoryRow>
|
|
179
|
+
|
|
180
|
+
<p className="text-content-muted text-xs">
|
|
181
|
+
Buttons disable themselves on illegal edges —
|
|
182
|
+
`connected → connecting` is not one, because a live
|
|
183
|
+
link that wants to reconnect goes through
|
|
184
|
+
`reconnecting`.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The live region, driven. Each click is a real transition through
|
|
192
|
+
* `connectionTransitions`, and the indicator's wording, colour, and
|
|
193
|
+
* whether the dot moves all follow from the state rather than from
|
|
194
|
+
* four independent booleans.
|
|
195
|
+
*/
|
|
196
|
+
export const Interactive: Story = {
|
|
197
|
+
args: { status: "connecting" },
|
|
198
|
+
render: () => <ConnectionLifecycle />,
|
|
199
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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 "./LiveStatusIndicator.stories.tsx"
|
|
8
|
+
|
|
9
|
+
const { Default, Interactive } = composeStories(stories)
|
|
10
|
+
|
|
11
|
+
test("it is a named live region with a stable status handle", async () => {
|
|
12
|
+
const { canvas } = await mountStory(Default)
|
|
13
|
+
|
|
14
|
+
const indicator = expectAgentDrivable(canvas, {
|
|
15
|
+
name: "Connected",
|
|
16
|
+
role: "status",
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// `data-status` survives translation; the visible wording does
|
|
20
|
+
// not. A Playwright assertion in a consumer app wants the former.
|
|
21
|
+
await expect(indicator).toHaveAttribute(
|
|
22
|
+
"data-status",
|
|
23
|
+
"connected",
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The live region, driven. Each click is a real transition through
|
|
29
|
+
* `connectionTransitions`, so the wording, the colour, and whether
|
|
30
|
+
* the dot moves all follow from one state rather than from four
|
|
31
|
+
* independent booleans.
|
|
32
|
+
*/
|
|
33
|
+
test("the indicator follows the connection machine", async () => {
|
|
34
|
+
const { canvas } = await mountStory(Interactive)
|
|
35
|
+
|
|
36
|
+
const indicator = canvas.getByRole("status")
|
|
37
|
+
|
|
38
|
+
await expect(indicator).toHaveAttribute(
|
|
39
|
+
"data-status",
|
|
40
|
+
"connecting",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
await userEvent.click(
|
|
44
|
+
canvas.getByRole("button", { name: "connected" }),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
await expect(canvas.getByText("Connected")).toBeVisible()
|
|
48
|
+
|
|
49
|
+
await userEvent.click(
|
|
50
|
+
canvas.getByRole("button", { name: "reconnecting" }),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
await expect(
|
|
54
|
+
canvas.getByText("Reconnecting…"),
|
|
55
|
+
).toBeVisible()
|
|
56
|
+
|
|
57
|
+
// The distinction four repos currently lose: `reconnecting` is
|
|
58
|
+
// warning — you had data and I am getting it back — not the
|
|
59
|
+
// `connecting` blue and not the `disconnected` red.
|
|
60
|
+
await expect(indicator).toHaveClass(
|
|
61
|
+
"text-intent-warning-content",
|
|
62
|
+
)
|
|
63
|
+
})
|