@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.
- 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.js +1 -1
- package/dist/Dialog/Dialog.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/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/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/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 +1 -1
- 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/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/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/reactRouter/ReactRouterLink.tsx +30 -0
- package/src/reactRouter/index.ts +29 -0
- 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
|
+
}
|