@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.
Files changed (117) hide show
  1. package/README.md +11 -0
  2. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts +94 -0
  3. package/dist/AdaptiveGrid/AdaptiveGrid.d.ts.map +1 -0
  4. package/dist/AdaptiveGrid/AdaptiveGrid.js +73 -0
  5. package/dist/AdaptiveGrid/AdaptiveGrid.js.map +1 -0
  6. package/dist/AdaptiveGrid/chooseColumns.d.ts +206 -0
  7. package/dist/AdaptiveGrid/chooseColumns.d.ts.map +1 -0
  8. package/dist/AdaptiveGrid/chooseColumns.js +156 -0
  9. package/dist/AdaptiveGrid/chooseColumns.js.map +1 -0
  10. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts +155 -0
  11. package/dist/AdaptiveGrid/useAdaptiveColumns.d.ts.map +1 -0
  12. package/dist/AdaptiveGrid/useAdaptiveColumns.js +153 -0
  13. package/dist/AdaptiveGrid/useAdaptiveColumns.js.map +1 -0
  14. package/dist/Button/Button.d.ts.map +1 -1
  15. package/dist/Button/Button.js +11 -6
  16. package/dist/Button/Button.js.map +1 -1
  17. package/dist/ButtonLink/ButtonLink.d.ts +59 -0
  18. package/dist/ButtonLink/ButtonLink.d.ts.map +1 -0
  19. package/dist/ButtonLink/ButtonLink.js +69 -0
  20. package/dist/ButtonLink/ButtonLink.js.map +1 -0
  21. package/dist/Combobox/Combobox.d.ts.map +1 -1
  22. package/dist/Combobox/Combobox.js +1 -1
  23. package/dist/Combobox/Combobox.js.map +1 -1
  24. package/dist/Dialog/Dialog.d.ts.map +1 -1
  25. package/dist/Dialog/Dialog.js +3 -1
  26. package/dist/Dialog/Dialog.js.map +1 -1
  27. package/dist/Field/Field.d.ts.map +1 -1
  28. package/dist/Field/Field.js +1 -1
  29. package/dist/Field/Field.js.map +1 -1
  30. package/dist/Field/FieldGroup.d.ts.map +1 -1
  31. package/dist/Field/FieldGroup.js +1 -1
  32. package/dist/Field/FieldGroup.js.map +1 -1
  33. package/dist/LogViewer/LogViewer.d.ts.map +1 -1
  34. package/dist/LogViewer/LogViewer.js +33 -1
  35. package/dist/LogViewer/LogViewer.js.map +1 -1
  36. package/dist/RouterLink/AnchorLink.d.ts +17 -0
  37. package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
  38. package/dist/RouterLink/AnchorLink.js +16 -0
  39. package/dist/RouterLink/AnchorLink.js.map +1 -0
  40. package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
  41. package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
  42. package/dist/RouterLink/RouterLinkProvider.js +47 -0
  43. package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
  44. package/dist/RouterLink/routerLink.d.ts +45 -0
  45. package/dist/RouterLink/routerLink.d.ts.map +1 -0
  46. package/dist/RouterLink/routerLink.js +31 -0
  47. package/dist/RouterLink/routerLink.js.map +1 -0
  48. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
  49. package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
  50. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
  51. package/dist/TextLink/TextLink.d.ts +69 -0
  52. package/dist/TextLink/TextLink.d.ts.map +1 -0
  53. package/dist/TextLink/TextLink.js +79 -0
  54. package/dist/TextLink/TextLink.js.map +1 -0
  55. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  56. package/dist/Tooltip/Tooltip.js +5 -1
  57. package/dist/Tooltip/Tooltip.js.map +1 -1
  58. package/dist/controlStyles.d.ts +36 -1
  59. package/dist/controlStyles.d.ts.map +1 -1
  60. package/dist/controlStyles.js +30 -0
  61. package/dist/controlStyles.js.map +1 -1
  62. package/dist/index.d.ts +16 -2
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +10 -2
  65. package/dist/index.js.map +1 -1
  66. package/dist/intentStyles.d.ts +17 -0
  67. package/dist/intentStyles.d.ts.map +1 -1
  68. package/dist/intentStyles.js +17 -0
  69. package/dist/intentStyles.js.map +1 -1
  70. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  71. package/dist/mountStory.testHelpers.js +56 -0
  72. package/dist/mountStory.testHelpers.js.map +1 -1
  73. package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
  74. package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
  75. package/dist/reactRouter/ReactRouterLink.js +23 -0
  76. package/dist/reactRouter/ReactRouterLink.js.map +1 -0
  77. package/dist/reactRouter/index.d.ts +29 -0
  78. package/dist/reactRouter/index.d.ts.map +1 -0
  79. package/dist/reactRouter/index.js +29 -0
  80. package/dist/reactRouter/index.js.map +1 -0
  81. package/package.json +15 -3
  82. package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
  83. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
  84. package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
  85. package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
  86. package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
  87. package/src/AdaptiveGrid/chooseColumns.ts +270 -0
  88. package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
  89. package/src/Button/Button.tsx +10 -22
  90. package/src/ButtonLink/ButtonLink.mdx +75 -0
  91. package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
  92. package/src/ButtonLink/ButtonLink.test.tsx +203 -0
  93. package/src/ButtonLink/ButtonLink.tsx +149 -0
  94. package/src/Combobox/Combobox.tsx +4 -1
  95. package/src/Dialog/Dialog.tsx +18 -2
  96. package/src/Field/Field.tsx +10 -2
  97. package/src/Field/FieldGroup.tsx +4 -2
  98. package/src/LogViewer/LogViewer.test.tsx +47 -0
  99. package/src/LogViewer/LogViewer.tsx +35 -1
  100. package/src/RouterLink/AnchorLink.tsx +23 -0
  101. package/src/RouterLink/RouterLink.mdx +178 -0
  102. package/src/RouterLink/RouterLinkProvider.tsx +68 -0
  103. package/src/RouterLink/routerLink.ts +56 -0
  104. package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
  105. package/src/TextLink/TextLink.mdx +93 -0
  106. package/src/TextLink/TextLink.stories.tsx +260 -0
  107. package/src/TextLink/TextLink.test.tsx +230 -0
  108. package/src/TextLink/TextLink.tsx +194 -0
  109. package/src/Tooltip/Tooltip.tsx +5 -1
  110. package/src/controlStyles.ts +69 -1
  111. package/src/icons.storyHelpers.tsx +32 -0
  112. package/src/index.ts +41 -0
  113. package/src/intentStyles.ts +19 -0
  114. package/src/mountStory.testHelpers.ts +63 -0
  115. package/src/reactRouter/ReactRouterLink.tsx +30 -0
  116. package/src/reactRouter/index.ts +29 -0
  117. 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
- <div className="flex items-center gap-1 border-border-subtle border-b p-2">
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
@@ -99,7 +99,7 @@ export const Dialog = ({
99
99
  isVisible={isVisible}
100
100
  onClose={onClose}
101
101
  >
102
- <header className="flex shrink-0 items-start justify-between gap-3 border-b border-border-subtle p-4">
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
- <div className="min-h-0 flex-1 overflow-auto p-4">
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
 
@@ -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-xs"
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-xs"
224
+ className="text-intent-danger-content text-sm"
217
225
  id={errorId}
218
226
  >
219
227
  {error}