@charcuterie/ui 2.7.0 → 2.8.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 (96) 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.js +1 -1
  25. package/dist/Dialog/Dialog.js.map +1 -1
  26. package/dist/LogViewer/LogViewer.d.ts.map +1 -1
  27. package/dist/LogViewer/LogViewer.js +33 -1
  28. package/dist/LogViewer/LogViewer.js.map +1 -1
  29. package/dist/RouterLink/AnchorLink.d.ts +17 -0
  30. package/dist/RouterLink/AnchorLink.d.ts.map +1 -0
  31. package/dist/RouterLink/AnchorLink.js +16 -0
  32. package/dist/RouterLink/AnchorLink.js.map +1 -0
  33. package/dist/RouterLink/RouterLinkProvider.d.ts +13 -0
  34. package/dist/RouterLink/RouterLinkProvider.d.ts.map +1 -0
  35. package/dist/RouterLink/RouterLinkProvider.js +47 -0
  36. package/dist/RouterLink/RouterLinkProvider.js.map +1 -0
  37. package/dist/RouterLink/routerLink.d.ts +45 -0
  38. package/dist/RouterLink/routerLink.d.ts.map +1 -0
  39. package/dist/RouterLink/routerLink.js +31 -0
  40. package/dist/RouterLink/routerLink.js.map +1 -0
  41. package/dist/TextLink/TextLink.d.ts +69 -0
  42. package/dist/TextLink/TextLink.d.ts.map +1 -0
  43. package/dist/TextLink/TextLink.js +79 -0
  44. package/dist/TextLink/TextLink.js.map +1 -0
  45. package/dist/controlStyles.d.ts +36 -1
  46. package/dist/controlStyles.d.ts.map +1 -1
  47. package/dist/controlStyles.js +30 -0
  48. package/dist/controlStyles.js.map +1 -1
  49. package/dist/index.d.ts +16 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +10 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/intentStyles.d.ts +17 -0
  54. package/dist/intentStyles.d.ts.map +1 -1
  55. package/dist/intentStyles.js +17 -0
  56. package/dist/intentStyles.js.map +1 -1
  57. package/dist/reactRouter/ReactRouterLink.d.ts +23 -0
  58. package/dist/reactRouter/ReactRouterLink.d.ts.map +1 -0
  59. package/dist/reactRouter/ReactRouterLink.js +23 -0
  60. package/dist/reactRouter/ReactRouterLink.js.map +1 -0
  61. package/dist/reactRouter/index.d.ts +29 -0
  62. package/dist/reactRouter/index.d.ts.map +1 -0
  63. package/dist/reactRouter/index.js +29 -0
  64. package/dist/reactRouter/index.js.map +1 -0
  65. package/package.json +15 -3
  66. package/src/AdaptiveGrid/AdaptiveGrid.mdx +169 -0
  67. package/src/AdaptiveGrid/AdaptiveGrid.stories.tsx +240 -0
  68. package/src/AdaptiveGrid/AdaptiveGrid.test.tsx +243 -0
  69. package/src/AdaptiveGrid/AdaptiveGrid.tsx +150 -0
  70. package/src/AdaptiveGrid/chooseColumns.test.ts +253 -0
  71. package/src/AdaptiveGrid/chooseColumns.ts +270 -0
  72. package/src/AdaptiveGrid/useAdaptiveColumns.ts +355 -0
  73. package/src/Button/Button.tsx +10 -22
  74. package/src/ButtonLink/ButtonLink.mdx +75 -0
  75. package/src/ButtonLink/ButtonLink.stories.tsx +281 -0
  76. package/src/ButtonLink/ButtonLink.test.tsx +203 -0
  77. package/src/ButtonLink/ButtonLink.tsx +149 -0
  78. package/src/Combobox/Combobox.tsx +4 -1
  79. package/src/Dialog/Dialog.tsx +1 -1
  80. package/src/LogViewer/LogViewer.test.tsx +47 -0
  81. package/src/LogViewer/LogViewer.tsx +35 -1
  82. package/src/RouterLink/AnchorLink.tsx +23 -0
  83. package/src/RouterLink/RouterLink.mdx +178 -0
  84. package/src/RouterLink/RouterLinkProvider.tsx +68 -0
  85. package/src/RouterLink/routerLink.ts +56 -0
  86. package/src/TextLink/TextLink.mdx +93 -0
  87. package/src/TextLink/TextLink.stories.tsx +260 -0
  88. package/src/TextLink/TextLink.test.tsx +230 -0
  89. package/src/TextLink/TextLink.tsx +194 -0
  90. package/src/controlStyles.ts +69 -1
  91. package/src/icons.storyHelpers.tsx +32 -0
  92. package/src/index.ts +41 -0
  93. package/src/intentStyles.ts +19 -0
  94. package/src/reactRouter/ReactRouterLink.tsx +30 -0
  95. package/src/reactRouter/index.ts +29 -0
  96. package/src/sourceRules.test.ts +41 -6
@@ -0,0 +1,355 @@
1
+ import type { RefObject } from "react"
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ useSyncExternalStore,
8
+ } from "react"
9
+
10
+ import type { ColumnChoice } from "./chooseColumns.ts"
11
+ import {
12
+ chooseColumns,
13
+ DEFAULT_MAX_AUTO_COLUMNS,
14
+ DEFAULT_MAX_MANUAL_COLUMNS,
15
+ DEFAULT_MIN_COLUMN_INLINE_SIZE_PX,
16
+ getContentMaxInlineSize,
17
+ } from "./chooseColumns.ts"
18
+
19
+ /**
20
+ * The height-first column rule, wired to a real element.
21
+ *
22
+ * `chooseColumns` is the decision; this is the measuring. The split
23
+ * is the point — the rule is a table of numbers anyone can check in
24
+ * Node, and everything that needs a browser is quarantined here
25
+ * behind seams.
26
+ *
27
+ * ## What it measures, and from where
28
+ *
29
+ * Two different sources, on purpose:
30
+ *
31
+ * - **Inline size comes from a container**, observed with a
32
+ * `ResizeObserver`. A grid sitting beside a rail has less room
33
+ * than `window.innerWidth` claims, and this library is built
34
+ * around the case where the component's container is what
35
+ * changed and the window never moved. The source app read
36
+ * `window.innerWidth` with a bare `resize` listener; that is the
37
+ * one part of the port that is not a lift.
38
+ * - **Block size comes from the viewport**, behind an injected
39
+ * resolver. It has to: the question is "will this scroll", and a
40
+ * grid in normal flow is exactly as tall as its contents, so
41
+ * measuring the element always answers "it fits."
42
+ *
43
+ * ⚠️ **The observed element must not itself be capped by the
44
+ * answer.** `contentMaxInlineSize` narrows the content when the
45
+ * count is low; if the ref sits on the element that cap is applied
46
+ * to, the fold eats its own output and can never widen again — one
47
+ * column forever, with no error anywhere. `AdaptiveGrid` prevents
48
+ * this structurally by measuring an uncapped outer box and capping
49
+ * an inner one, and a caller wiring the hook by hand owes itself
50
+ * the same shape.
51
+ *
52
+ * ## Composing with a shell
53
+ *
54
+ * `contentMaxInlineSize` is a CSS length meant to be handed
55
+ * straight to a shell's main region as its max inline size, so the
56
+ * page chrome and the grid widen together rather than the grid
57
+ * rattling around inside a fixed column.
58
+ */
59
+
60
+ /**
61
+ * Where the available block size comes from, and how to hear about
62
+ * it changing. The shape `useSyncExternalStore` wants, and the same
63
+ * `get`/`subscribe` seam `useColorScheme` takes its resolver in.
64
+ *
65
+ * Injected rather than hardcoded for the reason every seam in this
66
+ * library is: an Electron window, a fixed-height kiosk panel, and a
67
+ * test all have a block size, and none of them is
68
+ * `window.innerHeight`.
69
+ */
70
+ export type BlockSizeResolver = {
71
+ get: () => number
72
+ subscribe: (listener: () => void) => () => void
73
+ }
74
+
75
+ /**
76
+ * Where a manual choice is remembered between visits.
77
+ *
78
+ * Deliberately not a storage *key* on its own: the source app
79
+ * hardcoded `"rip-deck.layout-columns"`, which is exactly the kind
80
+ * of app-namespaced string a shared library must never invent for
81
+ * its consumers.
82
+ */
83
+ export type ColumnPersistence = {
84
+ read: () => string | null
85
+ write: (value: string) => void
86
+ }
87
+
88
+ /**
89
+ * `window.innerHeight`, as a resolver.
90
+ *
91
+ * Guards `typeof window`, so importing this in a server render is
92
+ * safe: it degrades to an inert zero rather than throwing on a
93
+ * missing global. A zero block size resolves to one column, which
94
+ * is the right thing to send down the wire — it is the layout that
95
+ * is correct everywhere, and it widens on the client.
96
+ */
97
+ export const viewportBlockSizeResolver =
98
+ (): BlockSizeResolver => ({
99
+ get: () =>
100
+ typeof window === "undefined"
101
+ ? 0
102
+ : window.innerHeight,
103
+ subscribe: (listener) => {
104
+ if (typeof window === "undefined") {
105
+ return () => {}
106
+ }
107
+
108
+ window.addEventListener("resize", listener)
109
+
110
+ return () => {
111
+ window.removeEventListener("resize", listener)
112
+ }
113
+ },
114
+ })
115
+
116
+ /**
117
+ * `localStorage` under a caller-supplied key.
118
+ *
119
+ * Reads and writes are wrapped: `localStorage` throws in a
120
+ * sandboxed iframe and in some privacy modes, and a grid that
121
+ * cannot remember a choice should still honour the one just made.
122
+ */
123
+ export const localStorageColumnPersistence = (
124
+ key: string,
125
+ ): ColumnPersistence => ({
126
+ read: () => {
127
+ try {
128
+ return window.localStorage.getItem(key)
129
+ } catch {
130
+ return null
131
+ }
132
+ },
133
+ write: (value) => {
134
+ try {
135
+ window.localStorage.setItem(key, value)
136
+ } catch {
137
+ // Private-mode Safari and friends. Nothing to do.
138
+ }
139
+ },
140
+ })
141
+
142
+ /**
143
+ * A stored choice, or `"auto"` for anything unreadable.
144
+ *
145
+ * Every failure lands on `"auto"` on purpose: a corrupt key, a
146
+ * browser that refuses storage, a number somebody typed into
147
+ * devtools. The default is the mode that works everywhere, so a bad
148
+ * value costs a preference rather than a page.
149
+ */
150
+ export const readStoredChoice = ({
151
+ maxColumns = DEFAULT_MAX_MANUAL_COLUMNS,
152
+ persistence,
153
+ }: {
154
+ maxColumns?: number
155
+ persistence: ColumnPersistence | undefined
156
+ }): ColumnChoice => {
157
+ const stored = persistence?.read() ?? null
158
+
159
+ if (stored === null || stored === "auto") {
160
+ return "auto"
161
+ }
162
+
163
+ const parsed = Number(stored)
164
+
165
+ if (!Number.isInteger(parsed)) {
166
+ return "auto"
167
+ }
168
+
169
+ if (parsed < 1 || parsed > maxColumns) {
170
+ return "auto"
171
+ }
172
+
173
+ return parsed
174
+ }
175
+
176
+ export const useAdaptiveColumns = <
177
+ ContainerElement extends HTMLElement = HTMLDivElement,
178
+ >({
179
+ blockSizeResolver,
180
+ chromeBlockSize,
181
+ columnInlineSize,
182
+ gutterInlineSize,
183
+ itemBlockSize,
184
+ itemCount,
185
+ maxAutoColumns = DEFAULT_MAX_AUTO_COLUMNS,
186
+ maxManualColumns = DEFAULT_MAX_MANUAL_COLUMNS,
187
+ minColumnInlineSize = DEFAULT_MIN_COLUMN_INLINE_SIZE_PX,
188
+ persistence,
189
+ singleInlineSize,
190
+ storageKey,
191
+ }: {
192
+ /** @see BlockSizeResolver. Defaults to the viewport. */
193
+ blockSizeResolver?: BlockSizeResolver
194
+ /** Everything down the page that is not this grid, in CSS px. */
195
+ chromeBlockSize?: number
196
+ /** Override `contentInlineSize.column`. */
197
+ columnInlineSize?: string
198
+ /** Override `contentInlineSize.gutter`. */
199
+ gutterInlineSize?: string
200
+ /** One item's block size in CSS px. Measure it; err high. */
201
+ itemBlockSize: number
202
+ /** How many items the grid is being asked to fit. */
203
+ itemCount: number
204
+ /** The most columns the automatic answer will take. */
205
+ maxAutoColumns?: number
206
+ /** The most a person may ask for. One above the auto cap. */
207
+ maxManualColumns?: number
208
+ /** The narrowest a column may be, in CSS px. */
209
+ minColumnInlineSize?: number
210
+ /**
211
+ * Where a manual choice is remembered. Defaults to
212
+ * `localStorage` when `storageKey` is given, and to nothing at
213
+ * all when it is not — a grid with no key keeps a choice for the
214
+ * session and writes nowhere.
215
+ */
216
+ persistence?: ColumnPersistence
217
+ /** Override `contentInlineSize.single`. */
218
+ singleInlineSize?: string
219
+ /**
220
+ * The `localStorage` key a manual choice is remembered under.
221
+ *
222
+ * **Caller-supplied and namespaced by the app** — one origin
223
+ * often serves several things that would each like a `columns`
224
+ * key, and a shared library has no business picking that name.
225
+ */
226
+ storageKey?: string
227
+ }): {
228
+ /** What the automatic rule wants right now. */
229
+ autoColumns: number
230
+ /** What to draw. */
231
+ columns: number
232
+ /**
233
+ * The cap for the content around the grid, as a CSS length. Hand
234
+ * it to a shell's main region.
235
+ */
236
+ contentMaxInlineSize: string
237
+ /**
238
+ * Attach to the element whose inline size the grid may spend.
239
+ *
240
+ * ⚠️ Never the element `contentMaxInlineSize` is applied to.
241
+ */
242
+ containerRef: RefObject<ContainerElement | null>
243
+ /** What was picked, `"auto"` included. */
244
+ choice: ColumnChoice
245
+ setChoice: (choice: ColumnChoice) => void
246
+ } => {
247
+ const containerRef = useRef<ContainerElement>(null)
248
+
249
+ // Built once, so the `resize` subscription and the storage
250
+ // handle survive re-renders and StrictMode's double mount — the
251
+ // same lazy-`useState` seam construction `ColorSchemeSwitcher`
252
+ // uses.
253
+ const [seams] = useState(() => ({
254
+ blockSize:
255
+ blockSizeResolver ?? viewportBlockSizeResolver(),
256
+ persistence:
257
+ persistence ??
258
+ (storageKey === undefined
259
+ ? undefined
260
+ : localStorageColumnPersistence(storageKey)),
261
+ }))
262
+
263
+ const availableBlockSize = useSyncExternalStore(
264
+ seams.blockSize.subscribe,
265
+ seams.blockSize.get,
266
+ // Server-side there is no window and no scrolling to predict.
267
+ () => 0,
268
+ )
269
+
270
+ /**
271
+ * Zero until the container has been measured, which resolves to
272
+ * one column.
273
+ *
274
+ * The safe direction to be wrong in for one frame: starting
275
+ * narrow and widening moves content down the page, where
276
+ * starting wide and narrowing snatches it sideways. In practice
277
+ * `ResizeObserver` delivers its first record before paint, so
278
+ * there is usually nothing to see.
279
+ */
280
+ const [availableInlineSize, setAvailableInlineSize] =
281
+ useState(0)
282
+
283
+ useEffect(() => {
284
+ const container = containerRef.current
285
+
286
+ if (!container) {
287
+ return
288
+ }
289
+
290
+ const observer = new ResizeObserver(([entry]) => {
291
+ const next =
292
+ entry?.contentRect.width ??
293
+ container.getBoundingClientRect().width
294
+
295
+ // Same size, same state — a drag fires this many times a
296
+ // second and every one of them would otherwise re-render
297
+ // every item in the grid.
298
+ setAvailableInlineSize((current) =>
299
+ current === next ? current : next,
300
+ )
301
+ })
302
+
303
+ observer.observe(container)
304
+
305
+ return () => {
306
+ observer.disconnect()
307
+ }
308
+ }, [])
309
+
310
+ const [choice, setStoredChoice] = useState<ColumnChoice>(
311
+ () =>
312
+ readStoredChoice({
313
+ maxColumns: maxManualColumns,
314
+ persistence: seams.persistence,
315
+ }),
316
+ )
317
+
318
+ const setChoice = useCallback(
319
+ (next: ColumnChoice) => {
320
+ setStoredChoice(next)
321
+
322
+ // A storage failure must not swallow the click. The
323
+ // preference is a nicety; the column count just asked for is
324
+ // not.
325
+ seams.persistence?.write(String(next))
326
+ },
327
+ [seams],
328
+ )
329
+
330
+ const autoColumns = chooseColumns({
331
+ availableBlockSize,
332
+ availableInlineSize,
333
+ chromeBlockSize,
334
+ itemBlockSize,
335
+ itemCount,
336
+ maxColumns: maxAutoColumns,
337
+ minColumnInlineSize,
338
+ })
339
+
340
+ const columns = choice === "auto" ? autoColumns : choice
341
+
342
+ return {
343
+ autoColumns,
344
+ choice,
345
+ columns,
346
+ containerRef,
347
+ contentMaxInlineSize: getContentMaxInlineSize({
348
+ columnInlineSize,
349
+ columns,
350
+ gutterInlineSize,
351
+ singleInlineSize,
352
+ }),
353
+ setChoice,
354
+ }
355
+ }
@@ -7,19 +7,10 @@ import type {
7
7
  ReactNode,
8
8
  } from "react"
9
9
 
10
- import {
11
- CONTROL_SIZE_CLASS,
12
- ICON_CONTROL_SIZE_CLASS,
13
- } from "../controlStyles.ts"
10
+ import { getControlClassName } from "../controlStyles.ts"
14
11
  import type { IntentAppearance } from "../intentStyles.ts"
15
- import {
16
- DISABLED_CLASS,
17
- FOCUS_RING_CLASS,
18
- INTENT_APPEARANCE_CLASS,
19
- INTENT_HOVER_CLASS,
20
- } from "../intentStyles.ts"
12
+ import { DISABLED_CLASS } from "../intentStyles.ts"
21
13
  import { Spinner } from "../Spinner/Spinner.tsx"
22
- import { toClassName } from "../toClassName.ts"
23
14
 
24
15
  export type ButtonProps = Omit<
25
16
  ComponentPropsWithRef<"button">,
@@ -97,18 +88,15 @@ export const Button = ({
97
88
  <button
98
89
  {...buttonProps}
99
90
  aria-busy={isLoading || undefined}
100
- className={toClassName(
101
- "inline-flex cursor-pointer items-center justify-center rounded-md border font-medium whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard select-none",
102
- sizing === "icon"
103
- ? ICON_CONTROL_SIZE_CLASS[size]
104
- : CONTROL_SIZE_CLASS[size],
105
- INTENT_APPEARANCE_CLASS[intent][appearance],
106
- INTENT_HOVER_CLASS[intent][appearance],
107
- FOCUS_RING_CLASS,
108
- DISABLED_CLASS,
109
- isFullWidth && "w-full",
91
+ className={getControlClassName({
92
+ appearance,
110
93
  className,
111
- )}
94
+ disabledClass: DISABLED_CLASS,
95
+ intent,
96
+ isFullWidth,
97
+ size,
98
+ sizing,
99
+ })}
112
100
  disabled={isDisabled || isLoading}
113
101
  type={type}
114
102
  >
@@ -0,0 +1,75 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ButtonLinkStories from "./ButtonLink.stories";
4
+
5
+ <Meta of={ButtonLinkStories} />
6
+
7
+ # ButtonLink
8
+
9
+ **Navigation that looks like a button.** It takes `Button`'s visual props and paints
10
+ identically to it — and it is an `<a href>`.
11
+
12
+ That combination is the whole component. Plex Channels' "Configure" reads as the primary
13
+ action on its card, so it has to look like a solid button; it goes to another page, so it
14
+ has to be a link. Today it is a `<button onClick={() => navigate("#/settings")}>`, which
15
+ cannot be middle-clicked, ctrl-clicked, opened in a new tab, or copied — and mux-magic
16
+ solves the same problem in the other direction, with 14 raw `<a href="/…">` that force a
17
+ full page reload through its own SPA router.
18
+
19
+ <Canvas of={ButtonLinkStories.Default} />
20
+
21
+ ## Props
22
+
23
+ <Controls />
24
+
25
+ ## It is the same paint, not a copy of it
26
+
27
+ `Button` and `ButtonLink` both assemble their class list through `getControlClassName`, so
28
+ `INTENT_APPEARANCE_CLASS`, `CONTROL_SIZE_CLASS`, and `FOCUS_RING_CLASS` are read once
29
+ rather than duplicated. `ButtonLink.test.tsx` then compares the two elements' **computed**
30
+ styles in the real browser, which is the version of that claim a refactor cannot quietly
31
+ break.
32
+
33
+ <Canvas of={ButtonLinkStories.BesideAButton} />
34
+
35
+ <Canvas of={ButtonLinkStories.AllVariants} />
36
+
37
+ <Canvas of={ButtonLinkStories.AllSizes} />
38
+
39
+ ## Three things `Button` has that this does not
40
+
41
+ | Missing | Why |
42
+ | --- | --- |
43
+ | `isLoading` | A navigation has no pending state **this component** owns. The wait belongs to the destination — a router transition, a `Suspense` boundary, a `ProgressBar` on the page being entered. A spinner here would be the component lying about work it is not doing. |
44
+ | `type` | There is no form to submit. |
45
+ | `sizing="icon"` | An icon-only navigation is an `IconButton` problem — a glyph is not an accessible name — and solving it here would mean a second name-enforcing type. |
46
+
47
+ ## Accessibility contract
48
+
49
+ - The accessible name is the children. `iconStart`/`iconEnd` are decoration inside the same
50
+ anchor.
51
+ - `isDisabled` **drops `href`** and sets `aria-disabled` with an explicit `role="link"`,
52
+ rather than shipping a focusable `<a>` that silently ignores clicks. It is painted
53
+ through `ARIA_DISABLED_CLASS`, which also switches off pointer events — without a real
54
+ `:disabled`, CSS `:hover` still matches, so the hover fill has to be taken off the table
55
+ rather than merely overridden.
56
+ - `isExternal` sets `target="_blank"`, `rel="noopener noreferrer"`, and a visually-hidden
57
+ *"(opens in a new tab)"*.
58
+ - Contrast on the disabled treatment leans on WCAG 1.4.3's exemption for *inactive*
59
+ controls, exactly as `Button`'s does.
60
+
61
+ <Canvas of={ButtonLinkStories.AllStates} />
62
+
63
+ <Canvas of={ButtonLinkStories.ConfigureCard} />
64
+
65
+ <Canvas of={ButtonLinkStories.External} />
66
+
67
+ ## Routing
68
+
69
+ `ButtonLink` renders through whatever link component the app injected at its root, and a
70
+ plain `<a>` when nothing is injected — or when the destination is external or a fragment,
71
+ which no router should intercept. See **Guides/Routing**.
72
+
73
+ <Canvas of={ButtonLinkStories.Routed} />
74
+
75
+ <Canvas of={ButtonLinkStories.Responsive} />