@charcuterie/ui 2.7.0 → 2.9.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/README.md +11 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +94 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.js +73 -0
- package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -0
- package/dist/AdaptiveGrid/chooseColumns.d.ts +206 -0
- package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -0
- package/dist/AdaptiveGrid/chooseColumns.js +156 -0
- package/dist/AdaptiveGrid/chooseColumns.js.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +155 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js +153 -0
- package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -0
- package/dist/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +11 -6
- package/dist/Button/Button.js.map +1 -1
- package/dist/ButtonLink/ButtonLink.d.ts +59 -0
- package/dist/ButtonLink/ButtonLink.d.ts.map +1 -0
- package/dist/ButtonLink/ButtonLink.js +69 -0
- package/dist/ButtonLink/ButtonLink.js.map +1 -0
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +1 -1
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -1
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +1 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/Field/FieldGroup.d.ts.map +1 -1
- package/dist/Field/FieldGroup.js +1 -1
- package/dist/Field/FieldGroup.js.map +1 -1
- package/dist/LogViewer/LogViewer.d.ts.map +1 -1
- package/dist/LogViewer/LogViewer.js +33 -1
- package/dist/LogViewer/LogViewer.js.map +1 -1
- package/dist/RouterLink/AnchorLink.d.ts +17 -0
- package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
- package/dist/RouterLink/AnchorLink.js +16 -0
- package/dist/RouterLink/AnchorLink.js.map +1 -0
- package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
- package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
- package/dist/RouterLink/RouterLinkProvider.js +47 -0
- package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
- package/dist/RouterLink/routerLink.d.ts +45 -0
- package/dist/RouterLink/routerLink.d.ts.map +1 -0
- package/dist/RouterLink/routerLink.js +31 -0
- package/dist/RouterLink/routerLink.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
- package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
- package/dist/TextLink/TextLink.d.ts +69 -0
- package/dist/TextLink/TextLink.d.ts.map +1 -0
- package/dist/TextLink/TextLink.js +79 -0
- package/dist/TextLink/TextLink.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +5 -1
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/controlStyles.d.ts +36 -1
- package/dist/controlStyles.d.ts.map +1 -1
- package/dist/controlStyles.js +30 -0
- package/dist/controlStyles.js.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/intentStyles.d.ts +17 -0
- package/dist/intentStyles.d.ts.map +1 -1
- package/dist/intentStyles.js +17 -0
- package/dist/intentStyles.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +56 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
- package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
- package/dist/reactRouter/ReactRouterLink.js +23 -0
- package/dist/reactRouter/ReactRouterLink.js.map +1 -0
- package/dist/reactRouter/index.d.ts +29 -0
- package/dist/reactRouter/index.d.ts.map +1 -0
- package/dist/reactRouter/index.js +29 -0
- package/dist/reactRouter/index.js.map +1 -0
- package/package.json +15 -3
- package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
- package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
- package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
- package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
- package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
- package/src/AdaptiveGrid/chooseColumns.ts +270 -0
- package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
- package/src/Button/Button.tsx +10 -22
- package/src/ButtonLink/ButtonLink.mdx +75 -0
- package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
- package/src/ButtonLink/ButtonLink.test.tsx +203 -0
- package/src/ButtonLink/ButtonLink.tsx +149 -0
- package/src/Combobox/Combobox.tsx +4 -1
- package/src/Dialog/Dialog.tsx +18 -2
- package/src/Field/Field.tsx +10 -2
- package/src/Field/FieldGroup.tsx +4 -2
- package/src/LogViewer/LogViewer.test.tsx +47 -0
- package/src/LogViewer/LogViewer.tsx +35 -1
- package/src/RouterLink/AnchorLink.tsx +23 -0
- package/src/RouterLink/RouterLink.mdx +178 -0
- package/src/RouterLink/RouterLinkProvider.tsx +68 -0
- package/src/RouterLink/routerLink.ts +56 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
- package/src/TextLink/TextLink.mdx +93 -0
- package/src/TextLink/TextLink.stories.tsx +260 -0
- package/src/TextLink/TextLink.test.tsx +230 -0
- package/src/TextLink/TextLink.tsx +194 -0
- package/src/Tooltip/Tooltip.tsx +5 -1
- package/src/controlStyles.ts +69 -1
- package/src/icons.storyHelpers.tsx +32 -0
- package/src/index.ts +41 -0
- package/src/intentStyles.ts +19 -0
- package/src/mountStory.testHelpers.ts +63 -0
- package/src/reactRouter/ReactRouterLink.tsx +30 -0
- package/src/reactRouter/index.ts +29 -0
- package/src/sourceRules.test.ts +41 -6
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
controlSizeArgType,
|
|
6
|
+
intentArgType,
|
|
7
|
+
} 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 {
|
|
17
|
+
ArrowRightIcon,
|
|
18
|
+
ExternalIcon,
|
|
19
|
+
} from "../icons.storyHelpers.tsx"
|
|
20
|
+
import type { IntentAppearance } from "../intentStyles.ts"
|
|
21
|
+
import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
|
|
22
|
+
import type { RouterLinkProps } from "../RouterLink/routerLink.ts"
|
|
23
|
+
import { ButtonLink } from "./ButtonLink.tsx"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A stand-in for react-router's `<Link>` — the marker attribute is
|
|
27
|
+
* how the board (and `ButtonLink.test.tsx`) shows which destinations
|
|
28
|
+
* the injected router was handed.
|
|
29
|
+
*/
|
|
30
|
+
const SoftRouterLink = ({
|
|
31
|
+
href,
|
|
32
|
+
...linkProps
|
|
33
|
+
}: RouterLinkProps): ReactNode => (
|
|
34
|
+
<a {...linkProps} data-router="soft" href={href} />
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const INTENTS = [
|
|
38
|
+
"neutral",
|
|
39
|
+
"accent",
|
|
40
|
+
"success",
|
|
41
|
+
"warning",
|
|
42
|
+
"danger",
|
|
43
|
+
"info",
|
|
44
|
+
] as const
|
|
45
|
+
|
|
46
|
+
const APPEARANCES: IntentAppearance[] = [
|
|
47
|
+
"solid",
|
|
48
|
+
"soft",
|
|
49
|
+
"outline",
|
|
50
|
+
"ghost",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const meta = {
|
|
54
|
+
title: "Components/ButtonLink",
|
|
55
|
+
component: ButtonLink,
|
|
56
|
+
parameters: { layout: "padded" },
|
|
57
|
+
argTypes: {
|
|
58
|
+
intent: intentArgType,
|
|
59
|
+
size: controlSizeArgType,
|
|
60
|
+
},
|
|
61
|
+
// The component's own defaults, restated. Storybook has not
|
|
62
|
+
// seeded `args` from a docgen `defaultValue` since v7, so without
|
|
63
|
+
// this the props table prints `"solid"` in the Default column
|
|
64
|
+
// while the radio beside it has nothing selected.
|
|
65
|
+
args: {
|
|
66
|
+
appearance: "solid",
|
|
67
|
+
href: "/channels/settings",
|
|
68
|
+
intent: "accent",
|
|
69
|
+
isDisabled: false,
|
|
70
|
+
isExternal: false,
|
|
71
|
+
isFullWidth: false,
|
|
72
|
+
size: "md",
|
|
73
|
+
},
|
|
74
|
+
} satisfies Meta<typeof ButtonLink>
|
|
75
|
+
|
|
76
|
+
export default meta
|
|
77
|
+
|
|
78
|
+
type Story = StoryObj<typeof meta>
|
|
79
|
+
|
|
80
|
+
export const Default: Story = {
|
|
81
|
+
args: { children: "Configure" },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The claim this component exists to make, side by side: a
|
|
86
|
+
* `ButtonLink` and a `Button` with identical visual props are the
|
|
87
|
+
* same pixels, and one of them is an `<a href>`.
|
|
88
|
+
*
|
|
89
|
+
* `ButtonLink.test.tsx` compares their computed styles rather than
|
|
90
|
+
* trusting this board — but the board is where a human sees it.
|
|
91
|
+
*/
|
|
92
|
+
export const BesideAButton: Story = {
|
|
93
|
+
args: { children: "Configure" },
|
|
94
|
+
render: (buttonLinkProps) => (
|
|
95
|
+
<StorySection title="Same paint, different element. Middle-click the left one and it opens a tab; middle-click the right one and nothing happens.">
|
|
96
|
+
<StoryRow>
|
|
97
|
+
<StoryCell label="ButtonLink — an <a href>">
|
|
98
|
+
<ButtonLink {...buttonLinkProps} />
|
|
99
|
+
</StoryCell>
|
|
100
|
+
|
|
101
|
+
<StoryCell label="Button — a <button>">
|
|
102
|
+
<Button
|
|
103
|
+
appearance={buttonLinkProps.appearance}
|
|
104
|
+
intent={buttonLinkProps.intent}
|
|
105
|
+
size={buttonLinkProps.size}
|
|
106
|
+
>
|
|
107
|
+
Configure
|
|
108
|
+
</Button>
|
|
109
|
+
</StoryCell>
|
|
110
|
+
</StoryRow>
|
|
111
|
+
</StorySection>
|
|
112
|
+
),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const AllVariants: Story = {
|
|
116
|
+
args: { children: "Configure" },
|
|
117
|
+
render: (buttonLinkProps) => (
|
|
118
|
+
<StorySection title="Six intents x four appearances — the same maps Button indexes, because they are the same maps.">
|
|
119
|
+
<StoryGrid columns={4}>
|
|
120
|
+
{APPEARANCES.flatMap((appearance) =>
|
|
121
|
+
INTENTS.map((intent) => (
|
|
122
|
+
<StoryCell
|
|
123
|
+
key={`${appearance}-${intent}`}
|
|
124
|
+
label={`${appearance} · ${intent}`}
|
|
125
|
+
>
|
|
126
|
+
<ButtonLink
|
|
127
|
+
{...buttonLinkProps}
|
|
128
|
+
appearance={appearance}
|
|
129
|
+
intent={intent}
|
|
130
|
+
>
|
|
131
|
+
{intent}
|
|
132
|
+
</ButtonLink>
|
|
133
|
+
</StoryCell>
|
|
134
|
+
)),
|
|
135
|
+
)}
|
|
136
|
+
</StoryGrid>
|
|
137
|
+
</StorySection>
|
|
138
|
+
),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const AllSizes: Story = {
|
|
142
|
+
args: { children: "Configure" },
|
|
143
|
+
render: (buttonLinkProps) => (
|
|
144
|
+
<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.">
|
|
145
|
+
<StoryRow>
|
|
146
|
+
<ButtonLink {...buttonLinkProps} size="sm">
|
|
147
|
+
Small
|
|
148
|
+
</ButtonLink>
|
|
149
|
+
|
|
150
|
+
<ButtonLink {...buttonLinkProps} size="md">
|
|
151
|
+
Medium
|
|
152
|
+
</ButtonLink>
|
|
153
|
+
|
|
154
|
+
<ButtonLink {...buttonLinkProps} size="lg">
|
|
155
|
+
Large
|
|
156
|
+
</ButtonLink>
|
|
157
|
+
</StoryRow>
|
|
158
|
+
</StorySection>
|
|
159
|
+
),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Hover, active, and focus are real here, not simulated: the
|
|
164
|
+
* pseudo-states addon forces them, which is the only way to review a
|
|
165
|
+
* hover colour in a static board.
|
|
166
|
+
*
|
|
167
|
+
* There is no `loading` cell, and that is the design: a navigation has
|
|
168
|
+
* no pending state this component owns.
|
|
169
|
+
*/
|
|
170
|
+
export const AllStates: Story = {
|
|
171
|
+
args: { children: "Configure" },
|
|
172
|
+
parameters: {
|
|
173
|
+
pseudo: {
|
|
174
|
+
active: ["#active"],
|
|
175
|
+
focusVisible: ["#focus-target"],
|
|
176
|
+
hover: ["#hover"],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
render: (buttonLinkProps) => (
|
|
180
|
+
<StoryGrid columns={3}>
|
|
181
|
+
<StoryCell label="default">
|
|
182
|
+
<ButtonLink {...buttonLinkProps} />
|
|
183
|
+
</StoryCell>
|
|
184
|
+
|
|
185
|
+
<StoryCell label="hover (forced)">
|
|
186
|
+
<ButtonLink {...buttonLinkProps} id="hover" />
|
|
187
|
+
</StoryCell>
|
|
188
|
+
|
|
189
|
+
<StoryCell label="active (forced)">
|
|
190
|
+
<ButtonLink {...buttonLinkProps} id="active" />
|
|
191
|
+
</StoryCell>
|
|
192
|
+
|
|
193
|
+
<StoryCell label="focus-visible (forced)">
|
|
194
|
+
<ButtonLink
|
|
195
|
+
{...buttonLinkProps}
|
|
196
|
+
id="focus-target"
|
|
197
|
+
/>
|
|
198
|
+
</StoryCell>
|
|
199
|
+
|
|
200
|
+
<StoryCell label="disabled">
|
|
201
|
+
<ButtonLink {...buttonLinkProps} isDisabled />
|
|
202
|
+
</StoryCell>
|
|
203
|
+
|
|
204
|
+
<StoryCell label="with an iconEnd">
|
|
205
|
+
<ButtonLink
|
|
206
|
+
{...buttonLinkProps}
|
|
207
|
+
iconEnd={<ArrowRightIcon />}
|
|
208
|
+
/>
|
|
209
|
+
</StoryCell>
|
|
210
|
+
</StoryGrid>
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Plex Channels' actual control. It reads as the primary action on the
|
|
216
|
+
* card and it goes to another page, so it is an `<a>` — today it is a
|
|
217
|
+
* `<button onClick={() => navigate("#/settings")}>`, which cannot be
|
|
218
|
+
* middle-clicked, ctrl-clicked, opened in a new tab, or copied.
|
|
219
|
+
*/
|
|
220
|
+
export const ConfigureCard: Story = {
|
|
221
|
+
args: {
|
|
222
|
+
children: "Configure",
|
|
223
|
+
href: "/channels/settings",
|
|
224
|
+
iconEnd: <ArrowRightIcon />,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const External: Story = {
|
|
229
|
+
args: {
|
|
230
|
+
children: "Open MakeMKV docs",
|
|
231
|
+
href: "https://makemkv.com/developers",
|
|
232
|
+
iconEnd: <ExternalIcon />,
|
|
233
|
+
isExternal: true,
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The seam. An in-app path renders through the injected router
|
|
239
|
+
* component; an external URL falls back to a plain `<a>`, because a
|
|
240
|
+
* router pushing `https://…` onto its history stack lands the SPA on
|
|
241
|
+
* a route that does not exist.
|
|
242
|
+
*/
|
|
243
|
+
export const Routed: Story = {
|
|
244
|
+
args: { children: "Configure" },
|
|
245
|
+
decorators: [
|
|
246
|
+
(Story) => (
|
|
247
|
+
<RouterLinkProvider link={SoftRouterLink}>
|
|
248
|
+
<Story />
|
|
249
|
+
</RouterLinkProvider>
|
|
250
|
+
),
|
|
251
|
+
],
|
|
252
|
+
render: (buttonLinkProps) => (
|
|
253
|
+
<StoryRow>
|
|
254
|
+
<ButtonLink
|
|
255
|
+
{...buttonLinkProps}
|
|
256
|
+
href="/channels/settings"
|
|
257
|
+
>
|
|
258
|
+
Routed to /channels/settings
|
|
259
|
+
</ButtonLink>
|
|
260
|
+
|
|
261
|
+
<ButtonLink
|
|
262
|
+
{...buttonLinkProps}
|
|
263
|
+
appearance="outline"
|
|
264
|
+
href="https://makemkv.com/developers"
|
|
265
|
+
iconEnd={<ExternalIcon />}
|
|
266
|
+
isExternal
|
|
267
|
+
>
|
|
268
|
+
External to makemkv.com
|
|
269
|
+
</ButtonLink>
|
|
270
|
+
</StoryRow>
|
|
271
|
+
),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const Responsive: Story = {
|
|
275
|
+
args: { children: "Configure", isFullWidth: true },
|
|
276
|
+
render: (buttonLinkProps) => (
|
|
277
|
+
<ContainerBoard>
|
|
278
|
+
<ButtonLink {...buttonLinkProps} />
|
|
279
|
+
</ContainerBoard>
|
|
280
|
+
),
|
|
281
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
composeStories,
|
|
3
|
+
composeStory,
|
|
4
|
+
} from "@storybook/react"
|
|
5
|
+
import { expect, userEvent } from "storybook/test"
|
|
6
|
+
import { test } from "vitest"
|
|
7
|
+
|
|
8
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
9
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
10
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
11
|
+
import meta, * as stories from "./ButtonLink.stories.tsx"
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
AllStates,
|
|
15
|
+
AllVariants,
|
|
16
|
+
BesideAButton,
|
|
17
|
+
Default,
|
|
18
|
+
External,
|
|
19
|
+
Routed,
|
|
20
|
+
} = composeStories(stories)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The properties a `<button onClick={navigate}>` cannot have, and the
|
|
24
|
+
* reason this component exists rather than a `Button` with an
|
|
25
|
+
* `onClick`.
|
|
26
|
+
*
|
|
27
|
+
* Nothing here clicks: a real anchor in a real browser navigates, and
|
|
28
|
+
* that it does is the browser's job — getting it for free is the
|
|
29
|
+
* whole argument.
|
|
30
|
+
*/
|
|
31
|
+
test("is a link, not a button", async () => {
|
|
32
|
+
const { canvas } = await mountStory(Default)
|
|
33
|
+
|
|
34
|
+
const link = expectAgentDrivable(canvas, {
|
|
35
|
+
name: "Configure",
|
|
36
|
+
role: "link",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
await expect(link.tagName).toBe("A")
|
|
40
|
+
await expect(link).toHaveAttribute(
|
|
41
|
+
"href",
|
|
42
|
+
"/channels/settings",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// Middle-click, ctrl-click, "open in new tab", "copy link address"
|
|
46
|
+
// and the status bar all come from *this* — an `href` on an `<a>` —
|
|
47
|
+
// and none of them can be added to a `<button>`.
|
|
48
|
+
await expect(
|
|
49
|
+
canvas.queryByRole("button", { name: "Configure" }),
|
|
50
|
+
).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("Tab reaches it", async () => {
|
|
54
|
+
const { canvas } = await mountStory(Default)
|
|
55
|
+
|
|
56
|
+
const link = canvas.getByRole("link", {
|
|
57
|
+
name: "Configure",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
await userEvent.tab()
|
|
61
|
+
|
|
62
|
+
await expect(link).toHaveFocus()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The claim the component is named for, measured.
|
|
67
|
+
*
|
|
68
|
+
* `Button` and `ButtonLink` both assemble their class list through
|
|
69
|
+
* `getControlClassName`, so there is one source — but a shared
|
|
70
|
+
* function still leaves room for a stray utility, a different base
|
|
71
|
+
* string, or a wrapper element that changes the box. Comparing
|
|
72
|
+
* **computed** styles is the version of "paints identically" that a
|
|
73
|
+
* refactor cannot quietly break, and it is measured in the same
|
|
74
|
+
* chromium the boards screenshot in.
|
|
75
|
+
*/
|
|
76
|
+
const PAINTED_PROPERTIES = [
|
|
77
|
+
"background-color",
|
|
78
|
+
"block-size",
|
|
79
|
+
"border-color",
|
|
80
|
+
"border-radius",
|
|
81
|
+
"border-width",
|
|
82
|
+
"color",
|
|
83
|
+
"display",
|
|
84
|
+
"font-size",
|
|
85
|
+
"font-weight",
|
|
86
|
+
"padding-inline-end",
|
|
87
|
+
"padding-inline-start",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
const getPaint = (element: Element) => {
|
|
91
|
+
const computed = getComputedStyle(element)
|
|
92
|
+
|
|
93
|
+
return Object.fromEntries(
|
|
94
|
+
PAINTED_PROPERTIES.map((property) => [
|
|
95
|
+
property,
|
|
96
|
+
computed.getPropertyValue(property),
|
|
97
|
+
]),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("paints identically to a Button with the same props", async () => {
|
|
102
|
+
const { canvas } = await mountStory(BesideAButton)
|
|
103
|
+
|
|
104
|
+
const link = canvas.getByRole("link", {
|
|
105
|
+
name: "Configure",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const button = canvas.getByRole("button", {
|
|
109
|
+
name: "Configure",
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
await expect(getPaint(link)).toEqual(getPaint(button))
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("an external link opens safely and says so", async () => {
|
|
116
|
+
const { canvas, canvasElement } =
|
|
117
|
+
await mountStory(External)
|
|
118
|
+
|
|
119
|
+
const link = expectAgentDrivable(canvas, {
|
|
120
|
+
name: /Open MakeMKV docs/,
|
|
121
|
+
role: "link",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await expect(link).toHaveAttribute("target", "_blank")
|
|
125
|
+
await expect(link).toHaveAttribute(
|
|
126
|
+
"rel",
|
|
127
|
+
"noopener noreferrer",
|
|
128
|
+
)
|
|
129
|
+
await expect(link).toHaveAccessibleName(
|
|
130
|
+
/opens in a new tab/,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
await expectNoAxeViolations(canvasElement)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `href` is dropped rather than kept-and-ignored: an anchor with no
|
|
138
|
+
* `href` is out of the tab order and inert by the platform's own
|
|
139
|
+
* rules, and `aria-disabled` is what keeps it announced as a link
|
|
140
|
+
* that is currently unavailable.
|
|
141
|
+
*/
|
|
142
|
+
test("a disabled link has no href, is skipped by Tab, and still names itself", async () => {
|
|
143
|
+
const { canvas, canvasElement } = await mountStory(
|
|
144
|
+
composeStory(
|
|
145
|
+
{ args: { children: "Configure", isDisabled: true } },
|
|
146
|
+
meta,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const link = canvas.getByRole("link", {
|
|
151
|
+
name: "Configure",
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await expect(link).not.toHaveAttribute("href")
|
|
155
|
+
await expect(link).toHaveAttribute(
|
|
156
|
+
"aria-disabled",
|
|
157
|
+
"true",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
await userEvent.tab()
|
|
161
|
+
|
|
162
|
+
await expect(link).not.toHaveFocus()
|
|
163
|
+
|
|
164
|
+
await expectNoAxeViolations(canvasElement)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* There is no `isLoading`, and the absence is the design: a
|
|
169
|
+
* navigation has no pending state this component owns.
|
|
170
|
+
*/
|
|
171
|
+
test("it has no loading state to get wrong", async () => {
|
|
172
|
+
const { canvas } = await mountStory(AllStates)
|
|
173
|
+
|
|
174
|
+
await expect(canvas.queryByRole("status")).toBeNull()
|
|
175
|
+
|
|
176
|
+
for (const link of canvas.getAllByRole("link", {
|
|
177
|
+
name: "Configure",
|
|
178
|
+
})) {
|
|
179
|
+
await expect(link).not.toHaveAttribute("aria-busy")
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("an injected router takes in-app paths and not external ones", async () => {
|
|
184
|
+
const { canvas } = await mountStory(Routed)
|
|
185
|
+
|
|
186
|
+
await expect(
|
|
187
|
+
canvas.getByRole("link", {
|
|
188
|
+
name: "Routed to /channels/settings",
|
|
189
|
+
}),
|
|
190
|
+
).toHaveAttribute("data-router", "soft")
|
|
191
|
+
|
|
192
|
+
await expect(
|
|
193
|
+
canvas.getByRole("link", {
|
|
194
|
+
name: /External to makemkv\.com/,
|
|
195
|
+
}),
|
|
196
|
+
).not.toHaveAttribute("data-router")
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("the full variant board is axe-clean", async () => {
|
|
200
|
+
const { canvasElement } = await mountStory(AllVariants)
|
|
201
|
+
|
|
202
|
+
await expectNoAxeViolations(canvasElement)
|
|
203
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ControlSize,
|
|
3
|
+
IntentName,
|
|
4
|
+
} from "@charcuterie/tokens"
|
|
5
|
+
import type {
|
|
6
|
+
ComponentPropsWithRef,
|
|
7
|
+
ReactNode,
|
|
8
|
+
} from "react"
|
|
9
|
+
|
|
10
|
+
import { getControlClassName } from "../controlStyles.ts"
|
|
11
|
+
import type { IntentAppearance } from "../intentStyles.ts"
|
|
12
|
+
import { ARIA_DISABLED_CLASS } from "../intentStyles.ts"
|
|
13
|
+
import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
|
|
14
|
+
import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
|
|
15
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
16
|
+
|
|
17
|
+
export type ButtonLinkProps = Omit<
|
|
18
|
+
ComponentPropsWithRef<"a">,
|
|
19
|
+
"href"
|
|
20
|
+
> & {
|
|
21
|
+
appearance?: IntentAppearance
|
|
22
|
+
/**
|
|
23
|
+
* Required, and required even when disabled — a navigation that
|
|
24
|
+
* cannot say where it goes is a button wearing a link's clothes.
|
|
25
|
+
*/
|
|
26
|
+
href: string
|
|
27
|
+
iconEnd?: ReactNode
|
|
28
|
+
iconStart?: ReactNode
|
|
29
|
+
intent?: IntentName
|
|
30
|
+
isDisabled?: boolean
|
|
31
|
+
isExternal?: boolean
|
|
32
|
+
isFullWidth?: boolean
|
|
33
|
+
/** What `isExternal` announces. Not shown. */
|
|
34
|
+
newTabLabel?: string
|
|
35
|
+
size?: ControlSize
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A navigation that looks like a button.
|
|
40
|
+
*
|
|
41
|
+
* This is Plex Channels' "Configure ›": it reads as the primary
|
|
42
|
+
* action on the card, and it goes to another page — so it must be an
|
|
43
|
+
* `<a href>` (middle-click, ctrl-click, "open in new tab", "copy link
|
|
44
|
+
* address", and the status bar all come from the element, not from
|
|
45
|
+
* the paint) while looking exactly like `Button`. Today that control
|
|
46
|
+
* is a `<button onClick={() => navigate(…)}>`, which has none of
|
|
47
|
+
* them.
|
|
48
|
+
*
|
|
49
|
+
* **Exactly like `Button` is structural, not aspirational.** Both
|
|
50
|
+
* call `getControlClassName` with the same arguments, so there is one
|
|
51
|
+
* class list, and `ButtonLink.test.tsx` compares the two elements'
|
|
52
|
+
* *computed* styles rather than trusting that.
|
|
53
|
+
*
|
|
54
|
+
* Three things `Button` has that this deliberately does not:
|
|
55
|
+
*
|
|
56
|
+
* - **`isLoading`.** A navigation has no pending state this
|
|
57
|
+
* component owns. The wait belongs to the destination — a router's
|
|
58
|
+
* transition, a `Suspense` boundary, a `ProgressBar` on the page
|
|
59
|
+
* being entered — and a spinner here would be this component
|
|
60
|
+
* lying about work it is not doing.
|
|
61
|
+
* - **`type`.** There is no form to submit.
|
|
62
|
+
* - **`sizing="icon"`.** An icon-only navigation is an `IconButton`
|
|
63
|
+
* problem — a glyph is not an accessible name — and solving it
|
|
64
|
+
* here would mean a second name-enforcing type.
|
|
65
|
+
*
|
|
66
|
+
* `isDisabled` drops `href` entirely rather than shipping a
|
|
67
|
+
* focusable `<a>` that silently ignores clicks: an anchor with no
|
|
68
|
+
* `href` is inert and out of the tab order by the platform's own
|
|
69
|
+
* rules, and the explicit `role="link"` + `aria-disabled` keeps it
|
|
70
|
+
* *announced* as a link that is currently unavailable rather than
|
|
71
|
+
* vanishing from the accessibility tree. Reach for it rarely — a
|
|
72
|
+
* destination that does not exist yet is usually better absent than
|
|
73
|
+
* present-but-dead.
|
|
74
|
+
*/
|
|
75
|
+
export const ButtonLink = ({
|
|
76
|
+
appearance = "solid",
|
|
77
|
+
children,
|
|
78
|
+
className,
|
|
79
|
+
href,
|
|
80
|
+
iconEnd,
|
|
81
|
+
iconStart,
|
|
82
|
+
intent = "accent",
|
|
83
|
+
isDisabled = false,
|
|
84
|
+
isExternal = false,
|
|
85
|
+
isFullWidth = false,
|
|
86
|
+
newTabLabel = "(opens in a new tab)",
|
|
87
|
+
size = "md",
|
|
88
|
+
...anchorProps
|
|
89
|
+
}: ButtonLinkProps): ReactNode => {
|
|
90
|
+
const RouterLink = useRouterLink()
|
|
91
|
+
|
|
92
|
+
// An external destination or a fragment is never the router's, and
|
|
93
|
+
// a disabled one goes nowhere at all — each falls back to the plain
|
|
94
|
+
// anchor below rather than through the injected component.
|
|
95
|
+
const LinkElement =
|
|
96
|
+
isExternal || !getIsRoutedHref(href) ? "a" : RouterLink
|
|
97
|
+
|
|
98
|
+
const controlClassName = getControlClassName({
|
|
99
|
+
appearance,
|
|
100
|
+
className,
|
|
101
|
+
disabledClass: ARIA_DISABLED_CLASS,
|
|
102
|
+
intent,
|
|
103
|
+
isFullWidth,
|
|
104
|
+
size,
|
|
105
|
+
sizing: "control",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const content = (
|
|
109
|
+
<>
|
|
110
|
+
{iconStart}
|
|
111
|
+
|
|
112
|
+
{children}
|
|
113
|
+
|
|
114
|
+
{iconEnd}
|
|
115
|
+
|
|
116
|
+
{isExternal ? (
|
|
117
|
+
<VisuallyHidden>{newTabLabel}</VisuallyHidden>
|
|
118
|
+
) : null}
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if (isDisabled) {
|
|
123
|
+
return (
|
|
124
|
+
<a
|
|
125
|
+
{...anchorProps}
|
|
126
|
+
aria-disabled="true"
|
|
127
|
+
className={controlClassName}
|
|
128
|
+
role="link"
|
|
129
|
+
>
|
|
130
|
+
{content}
|
|
131
|
+
</a>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<LinkElement
|
|
137
|
+
{...anchorProps}
|
|
138
|
+
className={controlClassName}
|
|
139
|
+
href={href}
|
|
140
|
+
// `noreferrer` alongside `noopener` on purpose: `noopener`
|
|
141
|
+
// closes the `window.opener` hole, and `noreferrer` is what
|
|
142
|
+
// keeps a private app's URL out of the destination's logs.
|
|
143
|
+
rel={isExternal ? "noopener noreferrer" : undefined}
|
|
144
|
+
target={isExternal ? "_blank" : undefined}
|
|
145
|
+
>
|
|
146
|
+
{content}
|
|
147
|
+
</LinkElement>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -607,7 +607,10 @@ export const Combobox = ({
|
|
|
607
607
|
style={floatingStyles}
|
|
608
608
|
>
|
|
609
609
|
{isAttached ? null : (
|
|
610
|
-
|
|
610
|
+
// px-3 aligns the input's text start with the options
|
|
611
|
+
// below: each option sits at 0.75rem (listbox p-1 + option
|
|
612
|
+
// px-2), so the search row needs the same 0.75rem inset.
|
|
613
|
+
<div className="flex items-center gap-1 border-border-subtle border-b px-3 py-2">
|
|
611
614
|
<input
|
|
612
615
|
aria-activedescendant={
|
|
613
616
|
activeValue === undefined
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -99,7 +99,7 @@ export const Dialog = ({
|
|
|
99
99
|
isVisible={isVisible}
|
|
100
100
|
onClose={onClose}
|
|
101
101
|
>
|
|
102
|
-
<header className="flex shrink-0 items-
|
|
102
|
+
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border-subtle p-4">
|
|
103
103
|
<Heading
|
|
104
104
|
className="font-semibold text-lg leading-tight"
|
|
105
105
|
id={headingId}
|
|
@@ -119,7 +119,23 @@ export const Dialog = ({
|
|
|
119
119
|
) : null}
|
|
120
120
|
</header>
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
{/*
|
|
123
|
+
`tabIndex={0}` is load-bearing, not decoration. This region
|
|
124
|
+
scrolls, and a scrollable region that cannot be focused cannot
|
|
125
|
+
be scrolled by anyone driving the keyboard — there is nothing
|
|
126
|
+
to put the caret in and no way to reach the overflow. axe
|
|
127
|
+
gates it as `scrollable-region-focusable` (serious).
|
|
128
|
+
|
|
129
|
+
It only surfaced once the body actually overflowed, which is
|
|
130
|
+
why it could land green: the rule fires on a region that is
|
|
131
|
+
scrollable *right now*, so a dialog whose content happened to
|
|
132
|
+
fit stayed silent.
|
|
133
|
+
*/}
|
|
134
|
+
<div
|
|
135
|
+
className="min-h-0 flex-1 overflow-auto p-4"
|
|
136
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable or a keyboard user can see the pane and cannot scroll it — axe's `scrollable-region-focusable`, which fails this component without the line. Same call, and the same wording, as `LogViewer`'s scroll pane. The rule is right about `<div>`s in general and wrong about scrollers.
|
|
137
|
+
tabIndex={0}
|
|
138
|
+
>
|
|
123
139
|
{children}
|
|
124
140
|
</div>
|
|
125
141
|
|
package/src/Field/Field.tsx
CHANGED
|
@@ -202,9 +202,17 @@ export const Field = ({
|
|
|
202
202
|
|
|
203
203
|
{clonedControl}
|
|
204
204
|
|
|
205
|
+
{/*
|
|
206
|
+
Both of these are `text-sm`, not `text-xs`, since 2026-08-10.
|
|
207
|
+
A field description is the instruction for the input directly
|
|
208
|
+
above it, and an error is the one thing the user has to read
|
|
209
|
+
to get unstuck — neither survives being the smallest text on
|
|
210
|
+
the page. `aria-describedby` already ties them to the control;
|
|
211
|
+
the size is what decides whether a sighted user reads them.
|
|
212
|
+
*/}
|
|
205
213
|
{description === undefined ? null : (
|
|
206
214
|
<p
|
|
207
|
-
className="text-content-secondary text-
|
|
215
|
+
className="text-content-secondary text-sm"
|
|
208
216
|
id={descriptionId}
|
|
209
217
|
>
|
|
210
218
|
{description}
|
|
@@ -213,7 +221,7 @@ export const Field = ({
|
|
|
213
221
|
|
|
214
222
|
{error === undefined ? null : (
|
|
215
223
|
<p
|
|
216
|
-
className="text-intent-danger-content text-
|
|
224
|
+
className="text-intent-danger-content text-sm"
|
|
217
225
|
id={errorId}
|
|
218
226
|
>
|
|
219
227
|
{error}
|