@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,260 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { intentArgType } from "../argTypes.storyHelpers.ts"
5
+ import {
6
+ StoryCell,
7
+ StoryGrid,
8
+ StoryRow,
9
+ StorySection,
10
+ } from "../board.storyHelpers.tsx"
11
+ import {
12
+ ArrowLeftIcon,
13
+ ExternalIcon,
14
+ } from "../icons.storyHelpers.tsx"
15
+ import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
16
+ import type { RouterLinkProps } from "../RouterLink/routerLink.ts"
17
+ import { TextLink } from "./TextLink.tsx"
18
+
19
+ /**
20
+ * A stand-in for react-router's `<Link>` — the marker attribute is
21
+ * how the boards (and `TextLink.test.tsx`) show which destinations
22
+ * the injected router was handed and which ones went straight to the
23
+ * platform.
24
+ */
25
+ const SoftRouterLink = ({
26
+ href,
27
+ ...linkProps
28
+ }: RouterLinkProps): ReactNode => (
29
+ <a {...linkProps} data-router="soft" href={href} />
30
+ )
31
+
32
+ const INTENTS = [
33
+ "neutral",
34
+ "accent",
35
+ "success",
36
+ "warning",
37
+ "danger",
38
+ "info",
39
+ ] as const
40
+
41
+ const meta = {
42
+ title: "Components/TextLink",
43
+ component: TextLink,
44
+ parameters: { layout: "padded" },
45
+ argTypes: {
46
+ intent: intentArgType,
47
+ },
48
+ // The component's own defaults, restated. Storybook has not
49
+ // seeded `args` from a docgen `defaultValue` since v7, so without
50
+ // this the props table prints `"inline"` in the Default column
51
+ // while the radio beside it has nothing selected.
52
+ args: {
53
+ appearance: "inline",
54
+ href: "/library",
55
+ intent: "accent",
56
+ isDisabled: false,
57
+ isExternal: false,
58
+ },
59
+ } satisfies Meta<typeof TextLink>
60
+
61
+ export default meta
62
+
63
+ type Story = StoryObj<typeof meta>
64
+
65
+ export const Default: Story = {
66
+ args: { children: "the rip queue" },
67
+ }
68
+
69
+ /**
70
+ * The two modes, in the layouts that tell them apart — an inline link
71
+ * has to be seen *inside* a sentence, and a standalone one has to be
72
+ * seen on its own line with an icon beside it.
73
+ */
74
+ export const AllAppearances: Story = {
75
+ args: { children: "the rip queue" },
76
+ render: (linkProps) => (
77
+ <StorySection title="Inline lives in a sentence and stays underlined. Standalone is its own element and underlines on hover.">
78
+ <StoryGrid columns={2}>
79
+ <StoryCell label="inline">
80
+ <p className="max-w-prose text-content-secondary text-md">
81
+ Discs land in{" "}
82
+ <TextLink {...linkProps} appearance="inline" />{" "}
83
+ as soon as the drive reports them, and stay
84
+ there until a profile finishes.
85
+ </p>
86
+ </StoryCell>
87
+
88
+ <StoryCell label="standalone">
89
+ <TextLink
90
+ {...linkProps}
91
+ appearance="standalone"
92
+ iconStart={<ArrowLeftIcon />}
93
+ >
94
+ Back to all discs
95
+ </TextLink>
96
+ </StoryCell>
97
+ </StoryGrid>
98
+ </StorySection>
99
+ ),
100
+ }
101
+
102
+ /**
103
+ * The back-link seven repos hand-roll, each with its own `←`, its own
104
+ * hover rule, and its own idea of whether focus is visible.
105
+ *
106
+ * The glyph is still the app's — the library ships no icons — but the
107
+ * anchor, the focus ring, and the hover are the same three everywhere
108
+ * now.
109
+ */
110
+ export const BackLink: Story = {
111
+ args: {
112
+ appearance: "standalone",
113
+ children: "Back to all discs",
114
+ href: "/discs",
115
+ iconStart: <ArrowLeftIcon />,
116
+ },
117
+ }
118
+
119
+ export const AllIntents: Story = {
120
+ args: { children: "the rip queue" },
121
+ render: (linkProps) => (
122
+ <StorySection title="Six intents. Navigation is almost always accent or neutral — danger belongs on a Button, because deleting is an action.">
123
+ <StoryGrid columns={3}>
124
+ {INTENTS.map((intent) => (
125
+ <StoryCell key={intent} label={intent}>
126
+ <TextLink {...linkProps} intent={intent}>
127
+ {intent}
128
+ </TextLink>
129
+ </StoryCell>
130
+ ))}
131
+ </StoryGrid>
132
+ </StorySection>
133
+ ),
134
+ }
135
+
136
+ /**
137
+ * Hover, focus, and the disabled treatment, forced by the
138
+ * pseudo-states addon rather than driven — a board that has to be
139
+ * *driven* to show its states is a test wearing a demo's clothes.
140
+ */
141
+ export const AllStates: Story = {
142
+ args: { children: "the rip queue" },
143
+ parameters: {
144
+ pseudo: {
145
+ focusVisible: ["#focus-target"],
146
+ hover: ["#hover-inline", "#hover-standalone"],
147
+ },
148
+ },
149
+ render: (linkProps) => (
150
+ <StoryGrid columns={3}>
151
+ <StoryCell label="inline">
152
+ <TextLink {...linkProps} />
153
+ </StoryCell>
154
+
155
+ <StoryCell label="inline · hover (forced)">
156
+ <TextLink {...linkProps} id="hover-inline" />
157
+ </StoryCell>
158
+
159
+ <StoryCell label="inline · focus-visible (forced)">
160
+ <TextLink {...linkProps} id="focus-target" />
161
+ </StoryCell>
162
+
163
+ <StoryCell label="standalone">
164
+ <TextLink {...linkProps} appearance="standalone" />
165
+ </StoryCell>
166
+
167
+ <StoryCell label="standalone · hover (forced)">
168
+ <TextLink
169
+ {...linkProps}
170
+ appearance="standalone"
171
+ id="hover-standalone"
172
+ />
173
+ </StoryCell>
174
+
175
+ <StoryCell label="disabled">
176
+ <TextLink {...linkProps} isDisabled />
177
+ </StoryCell>
178
+ </StoryGrid>
179
+ ),
180
+ }
181
+
182
+ /**
183
+ * `isExternal` is three things at once: `target="_blank"`,
184
+ * `rel="noopener noreferrer"`, and a visually-hidden *"(opens in a
185
+ * new tab)"* so the surprise is announced rather than only
186
+ * discovered.
187
+ *
188
+ * The `↗` is the app's, passed as `iconEnd` — the fleet's existing
189
+ * convention, and the library ships no glyphs.
190
+ */
191
+ export const External: Story = {
192
+ args: {
193
+ children: "the MakeMKV forum thread",
194
+ href: "https://forum.makemkv.com",
195
+ iconEnd: <ExternalIcon />,
196
+ isExternal: true,
197
+ },
198
+ }
199
+
200
+ /**
201
+ * The seam, and the three destinations it deliberately does **not**
202
+ * take.
203
+ *
204
+ * With a `RouterLinkProvider` at the root, an in-app path renders
205
+ * through the injected component and navigates softly. An external
206
+ * URL and a same-page fragment fall back to a plain `<a>` — a router
207
+ * pushing `https://…` onto its history stack lands the SPA on a route
208
+ * that does not exist, and `#credits` is the browser's own
209
+ * scroll-to-id, not a route change.
210
+ */
211
+ export const Routed: Story = {
212
+ args: { appearance: "standalone" },
213
+ decorators: [
214
+ (Story) => (
215
+ <RouterLinkProvider link={SoftRouterLink}>
216
+ <Story />
217
+ </RouterLinkProvider>
218
+ ),
219
+ ],
220
+ render: (linkProps) => (
221
+ <StorySection title="Injected router on the left; the two the seam hands back to the platform on the right.">
222
+ <StoryRow>
223
+ <TextLink {...linkProps} href="/library">
224
+ Routed to /library
225
+ </TextLink>
226
+
227
+ <TextLink
228
+ {...linkProps}
229
+ href="https://forum.makemkv.com"
230
+ iconEnd={<ExternalIcon />}
231
+ isExternal
232
+ >
233
+ External to makemkv.com
234
+ </TextLink>
235
+
236
+ <TextLink {...linkProps} href="#credits">
237
+ Fragment to #credits
238
+ </TextLink>
239
+ </StoryRow>
240
+ </StorySection>
241
+ ),
242
+ }
243
+
244
+ export const InProse: Story = {
245
+ args: { children: "the rip queue" },
246
+ render: (linkProps) => (
247
+ <StorySection title="An inline link inherits the type it sits in and wraps with it — the reason it is not an inline-flex box.">
248
+ <StoryRow>
249
+ <p className="max-w-[18rem] text-content-secondary text-sm">
250
+ A long paragraph, set small, in which{" "}
251
+ <TextLink {...linkProps}>
252
+ a link long enough to wrap across two lines
253
+ </TextLink>{" "}
254
+ keeps its underline on both of them and never
255
+ takes the line height with it.
256
+ </p>
257
+ </StoryRow>
258
+ </StorySection>
259
+ ),
260
+ }
@@ -0,0 +1,230 @@
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 "./TextLink.stories.tsx"
12
+
13
+ const {
14
+ AllAppearances,
15
+ AllStates,
16
+ BackLink,
17
+ Default,
18
+ External,
19
+ Routed,
20
+ } = composeStories(stories)
21
+
22
+ /**
23
+ * Nothing here **clicks** a link. A real `<a href>` in a real browser
24
+ * navigates, and navigating the test page away is not a failure mode
25
+ * worth reproducing — so the contract asserted is the one that
26
+ * matters: the element, its `href`, and its place in the tab order.
27
+ * That the browser then navigates is the browser's job, and getting
28
+ * it for free is the entire argument for rendering an anchor.
29
+ */
30
+ test("is drivable by role and name", async () => {
31
+ const { canvas } = await mountStory(Default)
32
+
33
+ const link = expectAgentDrivable(canvas, {
34
+ name: "the rip queue",
35
+ role: "link",
36
+ })
37
+
38
+ await expect(link).toHaveAttribute("href", "/library")
39
+ })
40
+
41
+ test("Tab reaches it", async () => {
42
+ const { canvas } = await mountStory(Default)
43
+
44
+ const link = canvas.getByRole("link", {
45
+ name: "the rip queue",
46
+ })
47
+
48
+ await userEvent.tab()
49
+
50
+ await expect(link).toHaveFocus()
51
+ })
52
+
53
+ /**
54
+ * The whole difference between the two appearances, measured rather
55
+ * than eyeballed: an inline link is `display: inline` so it wraps
56
+ * with the prose around it, and it carries a permanent underline
57
+ * because colour alone cannot mark a link inside a paragraph
58
+ * (WCAG 1.4.1). A standalone one is a flex box with a gap for its
59
+ * icon, and underlines only on hover.
60
+ */
61
+ test("inline flows with prose; standalone is its own box", async () => {
62
+ const { canvas, canvasElement } =
63
+ await mountStory(AllAppearances)
64
+
65
+ const inline = canvas.getByRole("link", {
66
+ name: "the rip queue",
67
+ })
68
+
69
+ const standalone = canvas.getByRole("link", {
70
+ name: "Back to all discs",
71
+ })
72
+
73
+ await expect(getComputedStyle(inline).display).toBe(
74
+ "inline",
75
+ )
76
+
77
+ await expect(
78
+ getComputedStyle(inline).textDecorationLine,
79
+ ).toBe("underline")
80
+
81
+ // `display` is asserted as *containing* flex rather than equalling
82
+ // `inline-flex`: this cell is itself a flex container, and CSS
83
+ // blockifies a flex item's outer display, so `inline-flex` computes
84
+ // to `flex` here and would compute to `inline-flex` one story over.
85
+ // `align-items` is the property that survives blockification and is
86
+ // the one doing the work — it is what puts a back-arrow on the
87
+ // text's centre line.
88
+ await expect(
89
+ getComputedStyle(standalone).display,
90
+ ).toContain("flex")
91
+
92
+ await expect(
93
+ getComputedStyle(standalone).alignItems,
94
+ ).toBe("center")
95
+
96
+ await expect(
97
+ getComputedStyle(standalone).textDecorationLine,
98
+ ).toBe("none")
99
+
100
+ await expectNoAxeViolations(canvasElement)
101
+ })
102
+
103
+ test("a back-link is one anchor, not an icon beside a link", async () => {
104
+ const { canvas } = await mountStory(BackLink)
105
+
106
+ // The `←` is *inside* the anchor, so the arrow is part of the tap
107
+ // target rather than a decoration sitting next to it — which is
108
+ // what makes the hand-rolled version in seven repos a 40px target
109
+ // instead of a 12px one.
110
+ const link = expectAgentDrivable(canvas, {
111
+ name: "Back to all discs",
112
+ role: "link",
113
+ })
114
+
115
+ await expect(link.querySelector("svg")).not.toBeNull()
116
+ await expect(link).toHaveAttribute("href", "/discs")
117
+ })
118
+
119
+ /**
120
+ * `target="_blank"` without `rel="noopener"` hands the destination a
121
+ * live `window.opener` into the app, and a new tab with no warning is
122
+ * a WCAG 3.2.5 surprise. Both are the component's job, not the
123
+ * caller's.
124
+ */
125
+ test("an external link opens safely and says so", async () => {
126
+ const { canvas, canvasElement } =
127
+ await mountStory(External)
128
+
129
+ const link = expectAgentDrivable(canvas, {
130
+ name: /the MakeMKV forum thread/,
131
+ role: "link",
132
+ })
133
+
134
+ await expect(link).toHaveAttribute("target", "_blank")
135
+ await expect(link).toHaveAttribute(
136
+ "rel",
137
+ "noopener noreferrer",
138
+ )
139
+ await expect(link).toHaveAccessibleName(
140
+ /opens in a new tab/,
141
+ )
142
+
143
+ await expectNoAxeViolations(canvasElement)
144
+ })
145
+
146
+ /**
147
+ * A disabled link is not a platform concept, so the component builds
148
+ * one out of the two things that are: **no `href`** — which is what
149
+ * takes an anchor out of the tab order and makes it inert — and
150
+ * `aria-disabled`, which is what keeps it announced as a link rather
151
+ * than disappearing into a `<span>`.
152
+ *
153
+ * The alternative every hand-rolled version ships is a focusable
154
+ * `<a href>` with a click handler that returns early: reachable,
155
+ * pressable, and silently does nothing.
156
+ */
157
+ test("a disabled link has no href, is skipped by Tab, and still names itself", async () => {
158
+ const { canvas, canvasElement } = await mountStory(
159
+ composeStory(
160
+ {
161
+ args: {
162
+ children: "the rip queue",
163
+ isDisabled: true,
164
+ },
165
+ },
166
+ meta,
167
+ ),
168
+ )
169
+
170
+ const link = canvas.getByRole("link", {
171
+ name: "the rip queue",
172
+ })
173
+
174
+ await expect(link).not.toHaveAttribute("href")
175
+ await expect(link).toHaveAttribute(
176
+ "aria-disabled",
177
+ "true",
178
+ )
179
+
180
+ await userEvent.tab()
181
+
182
+ await expect(link).not.toHaveFocus()
183
+
184
+ await expectNoAxeViolations(canvasElement)
185
+ })
186
+
187
+ /**
188
+ * The seam, and the two destinations it hands back to the platform.
189
+ */
190
+ test("an injected router takes in-app paths and nothing else", async () => {
191
+ const { canvas } = await mountStory(Routed)
192
+
193
+ await expect(
194
+ canvas.getByRole("link", {
195
+ name: "Routed to /library",
196
+ }),
197
+ ).toHaveAttribute("data-router", "soft")
198
+
199
+ await expect(
200
+ canvas.getByRole("link", {
201
+ name: /External to makemkv\.com/,
202
+ }),
203
+ ).not.toHaveAttribute("data-router")
204
+
205
+ await expect(
206
+ canvas.getByRole("link", {
207
+ name: "Fragment to #credits",
208
+ }),
209
+ ).not.toHaveAttribute("data-router")
210
+ })
211
+
212
+ test("with no provider it is still a real anchor", async () => {
213
+ const { canvas } = await mountStory(Default)
214
+
215
+ const link = canvas.getByRole("link", {
216
+ name: "the rip queue",
217
+ })
218
+
219
+ // The default seam is `AnchorLink`, so an app with no router
220
+ // installed gets a working link rather than a runtime error or a
221
+ // dead `<span>`.
222
+ await expect(link.tagName).toBe("A")
223
+ await expect(link).not.toHaveAttribute("data-router")
224
+ })
225
+
226
+ test("the states board is axe-clean in every state it forces", async () => {
227
+ const { canvasElement } = await mountStory(AllStates)
228
+
229
+ await expectNoAxeViolations(canvasElement)
230
+ })
@@ -0,0 +1,194 @@
1
+ import type { IntentName } from "@charcuterie/tokens"
2
+ import type {
3
+ ComponentPropsWithRef,
4
+ ReactNode,
5
+ } from "react"
6
+
7
+ import {
8
+ FOCUS_RING_CLASS,
9
+ INTENT_CONTENT_CLASS,
10
+ } from "../intentStyles.ts"
11
+ import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
12
+ import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
13
+ import { toClassName } from "../toClassName.ts"
14
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
15
+
16
+ /**
17
+ * Two ways a link sits in a layout, and the difference is real rather
18
+ * than decorative.
19
+ *
20
+ * - `inline` lives **inside a sentence**. It is `display: inline`, so
21
+ * it wraps across lines with the prose around it, it inherits the
22
+ * surrounding font size rather than declaring one, and it is
23
+ * underlined — because inside a paragraph, colour alone is the
24
+ * thing WCAG 1.4.1 says cannot carry the meaning on its own.
25
+ * - `standalone` is **its own element**: a back-link, a nav item, a
26
+ * "view all" beside a heading. It is an inline flex box so an
27
+ * `iconStart` sits on the text's centre line with a real gap, and
28
+ * it underlines on hover instead of permanently — a column of
29
+ * permanently-underlined nav items reads as a mistake.
30
+ */
31
+ export type TextLinkAppearance = "inline" | "standalone"
32
+
33
+ export type TextLinkProps = Omit<
34
+ ComponentPropsWithRef<"a">,
35
+ "href"
36
+ > & {
37
+ appearance?: TextLinkAppearance
38
+ /**
39
+ * Required, and required even when disabled — a navigation that
40
+ * cannot say where it goes is a button wearing a link's clothes.
41
+ */
42
+ href: string
43
+ iconEnd?: ReactNode
44
+ /**
45
+ * The back-arrow, supplied by the app. The library ships no glyphs
46
+ * — see
47
+ * `docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md`
48
+ * — and the seven repos that hand-roll a `← Back` today already own
49
+ * the character they want.
50
+ */
51
+ iconStart?: ReactNode
52
+ intent?: IntentName
53
+ isDisabled?: boolean
54
+ isExternal?: boolean
55
+ /** What `isExternal` announces. Not shown. */
56
+ newTabLabel?: string
57
+ }
58
+
59
+ const TEXT_LINK_APPEARANCE_CLASS: Record<
60
+ TextLinkAppearance,
61
+ string
62
+ > = {
63
+ // No `text-*` of its own: an inline link that resized itself would
64
+ // be a hole in whatever type scale the paragraph is set in.
65
+ inline:
66
+ "underline decoration-1 underline-offset-2 hover:decoration-2",
67
+ standalone:
68
+ "inline-flex items-center gap-1 font-medium no-underline underline-offset-2 hover:underline",
69
+ }
70
+
71
+ /**
72
+ * Hover changes the **underline**, never the colour, and there is no
73
+ * hover-tinted background either.
74
+ *
75
+ * A text link has no box to fill, so `INTENT_HOVER_CLASS` — which
76
+ * lands on the intent's tinted surface — would paint a coloured
77
+ * rectangle around a word mid-sentence. And there is no
78
+ * `intent.*.content-hover` token to shift the text to: the ramp goes
79
+ * `surface` / `surface-hover` / `border` / `content` / `solid` /
80
+ * `solid-hover` / `on-solid`, so a hover colour here would have to be
81
+ * invented, and an invented colour is one the contrast gate has never
82
+ * measured. Underline weight is free of both problems and shifts no
83
+ * layout.
84
+ */
85
+ const TEXT_LINK_BASE_CLASS = "cursor-pointer rounded-xs"
86
+
87
+ /**
88
+ * The disabled paint, keyed off `aria-disabled` because an `<a>` has
89
+ * no `:disabled` to match. See `ARIA_DISABLED_CLASS` — this is its
90
+ * text-shaped twin, minus the box treatment a run of prose has no
91
+ * room for.
92
+ */
93
+ const TEXT_LINK_ARIA_DISABLED_CLASS =
94
+ "aria-disabled:pointer-events-none aria-disabled:text-content-disabled aria-disabled:no-underline"
95
+
96
+ /**
97
+ * Navigation that looks like a link.
98
+ *
99
+ * The fleet has **seven repos** hand-rolling a back-link — each with
100
+ * its own `←`, its own hover rule, and its own idea of whether focus
101
+ * is visible — and points-market's header title is a clickable
102
+ * `<div>`. This is the thing to reach for instead, and the reason the
103
+ * split with `ButtonLink` exists at all: the two differ in **paint,
104
+ * not semantics**. Both render a real `<a href>`; picking between
105
+ * them is picking how it looks, which is a decision an agent can make
106
+ * correctly from the name alone
107
+ * ([decision](../../../docs/decisions/2026-08-10-buttons-are-actions-links-are-navigation.md)).
108
+ *
109
+ * What it does not do is as load-bearing as what it does:
110
+ *
111
+ * - **No `onClick`-only mode.** If it does not navigate, it is a
112
+ * `Button` with `appearance="ghost"`, not a link.
113
+ * - **No `size`.** Text takes the size of the text around it. A
114
+ * standalone link that wants `text-sm` gets it from the block it
115
+ * sits in, or from `className`.
116
+ * - **No colour change on hover.** See `TEXT_LINK_BASE_CLASS`.
117
+ *
118
+ * `isDisabled` drops `href` rather than shipping a focusable anchor
119
+ * that ignores clicks; `role="link"` + `aria-disabled` keeps it
120
+ * announced. Rarer here than on `ButtonLink` — a dead link in a
121
+ * sentence is usually better written as plain text.
122
+ */
123
+ export const TextLink = ({
124
+ appearance = "inline",
125
+ children,
126
+ className,
127
+ href,
128
+ iconEnd,
129
+ iconStart,
130
+ intent = "accent",
131
+ isDisabled = false,
132
+ isExternal = false,
133
+ newTabLabel = "(opens in a new tab)",
134
+ ...anchorProps
135
+ }: TextLinkProps): ReactNode => {
136
+ const RouterLink = useRouterLink()
137
+
138
+ // An external destination or a fragment is never the router's, and
139
+ // a disabled one goes nowhere at all — each falls back to the plain
140
+ // anchor below rather than through the injected component.
141
+ const LinkElement =
142
+ isExternal || !getIsRoutedHref(href) ? "a" : RouterLink
143
+
144
+ const linkClassName = toClassName(
145
+ TEXT_LINK_BASE_CLASS,
146
+ TEXT_LINK_APPEARANCE_CLASS[appearance],
147
+ INTENT_CONTENT_CLASS[intent],
148
+ FOCUS_RING_CLASS,
149
+ TEXT_LINK_ARIA_DISABLED_CLASS,
150
+ className,
151
+ )
152
+
153
+ const content = (
154
+ <>
155
+ {iconStart}
156
+
157
+ {children}
158
+
159
+ {iconEnd}
160
+
161
+ {isExternal ? (
162
+ <VisuallyHidden>{newTabLabel}</VisuallyHidden>
163
+ ) : null}
164
+ </>
165
+ )
166
+
167
+ if (isDisabled) {
168
+ return (
169
+ <a
170
+ {...anchorProps}
171
+ aria-disabled="true"
172
+ className={linkClassName}
173
+ role="link"
174
+ >
175
+ {content}
176
+ </a>
177
+ )
178
+ }
179
+
180
+ return (
181
+ <LinkElement
182
+ {...anchorProps}
183
+ className={linkClassName}
184
+ href={href}
185
+ // `noreferrer` alongside `noopener` on purpose: `noopener`
186
+ // closes the `window.opener` hole, and `noreferrer` is what
187
+ // keeps a private app's URL out of the destination's logs.
188
+ rel={isExternal ? "noopener noreferrer" : undefined}
189
+ target={isExternal ? "_blank" : undefined}
190
+ >
191
+ {content}
192
+ </LinkElement>
193
+ )
194
+ }
@@ -168,7 +168,11 @@ export const Tooltip = ({
168
168
  // 2.9:1. The `solid`/`on-solid` roles exist precisely to
169
169
  // be a guaranteed couple, and the contrast gate checks
170
170
  // them as one.
171
- "z-[var(--layer-tooltip)] max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-xs shadow-medium",
171
+ // `text-sm`, not `text-xs`: a tooltip body is the only
172
+ // copy explaining the control it points at, so it is
173
+ // content rather than fine print. It was the whole
174
+ // tooltip at 12px before 2026-08-10.
175
+ "z-[var(--layer-tooltip)] max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-sm shadow-medium",
172
176
  className,
173
177
  )}
174
178
  // Portalled to `document.body`, the same reversal as the