@charcuterie/ui 2.6.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 +13 -1
- 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/dist/styles.css +88 -8
- 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
- package/src/styles.css +88 -8
|
@@ -199,7 +199,41 @@ export const LogViewer = ({
|
|
|
199
199
|
// `off` unless asked. See the note above; this is the
|
|
200
200
|
// difference between an accessible log and an unusable page.
|
|
201
201
|
aria-live={isAnnounced ? "polite" : "off"}
|
|
202
|
-
className=
|
|
202
|
+
className={toClassName(
|
|
203
|
+
"max-h-64 overflow-y-auto rounded-md border border-border-subtle bg-surface-sunken p-2 font-mono text-content-secondary text-xs",
|
|
204
|
+
// A sixth defect, and this one is nobody's code: scroll
|
|
205
|
+
// anchoring is the browser quietly *undoing* the follow
|
|
206
|
+
// above, and only sometimes.
|
|
207
|
+
//
|
|
208
|
+
// Chromium picks an anchor node in a scroll container and
|
|
209
|
+
// adjusts `scrollTop` to hold it still across a relayout.
|
|
210
|
+
// That is right for an article and wrong for a log: the
|
|
211
|
+
// whole contract here is "the bottom stays the bottom".
|
|
212
|
+
// `@charcuterie/tokens` ships Victor Mono `font-display:
|
|
213
|
+
// swap`, so a pane that mounts before the face arrives is
|
|
214
|
+
// laid out in the fallback and re-laid-out a moment later —
|
|
215
|
+
// and the anchor drags a followed pane off the end.
|
|
216
|
+
// Measured on the 60-line `Interactive` story, holding the
|
|
217
|
+
// font request back:
|
|
218
|
+
//
|
|
219
|
+
// anchoring on, font before mount : scrollTop 722, at the end
|
|
220
|
+
// anchoring on, font after mount : scrollTop 721, a pixel short
|
|
221
|
+
// anchoring off, either : scrollTop 722, at the end
|
|
222
|
+
//
|
|
223
|
+
// Whether the swap beats the mount is a race, so the pane
|
|
224
|
+
// followed correctly on some renders and not others. It
|
|
225
|
+
// read as visual-regression flake for a week — one story,
|
|
226
|
+
// `components-logviewer--interactive`, shifted a pixel —
|
|
227
|
+
// because a race is what it is. The DOM test missed it: its
|
|
228
|
+
// four pixels of slack are there for fractional device
|
|
229
|
+
// pixel ratios, and one pixel fits inside them.
|
|
230
|
+
//
|
|
231
|
+
// Only while following. A user who scrolled up to read an
|
|
232
|
+
// error *wants* the anchor — `maxLines` drops old lines off
|
|
233
|
+
// the top, and anchoring is what stops that shoving the
|
|
234
|
+
// line they are reading up the pane.
|
|
235
|
+
isFollowing ? "[overflow-anchor:none]" : null,
|
|
236
|
+
)}
|
|
203
237
|
onScroll={(scrollEvent) => {
|
|
204
238
|
const pane = scrollEvent.currentTarget
|
|
205
239
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import type { RouterLinkProps } from "./routerLink.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A plain `<a href>`, and the default every link component falls
|
|
7
|
+
* back to.
|
|
8
|
+
*
|
|
9
|
+
* It is the context's default value rather than a `null` check at
|
|
10
|
+
* each call site, which is what makes "no router installed" a
|
|
11
|
+
* configuration rather than a code path: `TextLink` and `ButtonLink`
|
|
12
|
+
* render `useRouterLink()` unconditionally and behave identically
|
|
13
|
+
* with and without a provider.
|
|
14
|
+
*
|
|
15
|
+
* It is also what a routed component falls back *to* for a
|
|
16
|
+
* destination the router must not intercept — see `getIsRoutedHref`.
|
|
17
|
+
*/
|
|
18
|
+
export const AnchorLink = ({
|
|
19
|
+
href,
|
|
20
|
+
...anchorProps
|
|
21
|
+
}: RouterLinkProps): ReactNode => (
|
|
22
|
+
<a {...anchorProps} href={href} />
|
|
23
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
<Meta title="Guides/Routing" />
|
|
4
|
+
|
|
5
|
+
# Routing
|
|
6
|
+
|
|
7
|
+
`TextLink` and `ButtonLink` render a real `<a href>`. That is already correct with no setup
|
|
8
|
+
at all — it navigates, it can be middle-clicked, ctrl-clicked, opened in a new tab and
|
|
9
|
+
copied — it just reloads the page. **One provider at the app root** upgrades every link in
|
|
10
|
+
the tree to the app's own soft navigation, with no change at any call site.
|
|
11
|
+
|
|
12
|
+
## Why the library does not just import a router
|
|
13
|
+
|
|
14
|
+
The fleet runs five answers at once: react-router v8 (mux-magic), react-router-dom v7
|
|
15
|
+
(bambuddy), wouter-preact (spoolbuddy), two hand-rolled routers, and five apps with no
|
|
16
|
+
router at all. A `react-router` dependency in `@charcuterie/ui` would be a package every
|
|
17
|
+
consumer installs and most of them do not use — so the router is **injected**, the same way
|
|
18
|
+
`ColorSchemeSwitcher` takes its `resolver` / `persistence` / `apply` seams, and the default
|
|
19
|
+
is a working plain anchor rather than a throw.
|
|
20
|
+
|
|
21
|
+
## react-router (v7 or v8)
|
|
22
|
+
|
|
23
|
+
`react-router` is an **optional peer dependency**, and the adapter is a separate entry
|
|
24
|
+
point — so an app that never imports this path never resolves it.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
yarn add @charcuterie/ui react-router
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// src/main.tsx
|
|
32
|
+
import { RouterLinkProvider } from "@charcuterie/ui"
|
|
33
|
+
import { ReactRouterLink } from "@charcuterie/ui/react-router"
|
|
34
|
+
import { StrictMode } from "react"
|
|
35
|
+
import { createRoot } from "react-dom/client"
|
|
36
|
+
import {
|
|
37
|
+
createBrowserRouter,
|
|
38
|
+
RouterProvider,
|
|
39
|
+
} from "react-router"
|
|
40
|
+
|
|
41
|
+
import { routes } from "./routes.tsx"
|
|
42
|
+
|
|
43
|
+
const router = createBrowserRouter(routes)
|
|
44
|
+
|
|
45
|
+
createRoot(document.getElementById("root")).render(
|
|
46
|
+
<StrictMode>
|
|
47
|
+
<RouterLinkProvider link={ReactRouterLink}>
|
|
48
|
+
<RouterProvider router={router} />
|
|
49
|
+
</RouterLinkProvider>
|
|
50
|
+
</StrictMode>,
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`RouterLinkProvider` goes **outside** `RouterProvider` here on purpose: it is plain React
|
|
55
|
+
context and needs no router of its own, and putting it outermost means a link rendered by a
|
|
56
|
+
route element and a link rendered by an app-level chrome component both see it.
|
|
57
|
+
|
|
58
|
+
Already on `react-router-dom` v7, as bambuddy is? Nothing changes. `react-router-dom` v7 is
|
|
59
|
+
a re-export shim over `react-router`, and the adapter imports `Link` from the latter, so one
|
|
60
|
+
import covers both installs.
|
|
61
|
+
|
|
62
|
+
That is the whole integration. Every link in the app is now soft:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { ButtonLink, TextLink } from "@charcuterie/ui"
|
|
66
|
+
|
|
67
|
+
export const ChannelCard = ({ channel }) => (
|
|
68
|
+
<Card>
|
|
69
|
+
<TextLink appearance="standalone" href="/channels">
|
|
70
|
+
Back to all channels
|
|
71
|
+
</TextLink>
|
|
72
|
+
|
|
73
|
+
<ButtonLink href={`/channels/${channel.id}/settings`}>
|
|
74
|
+
Configure
|
|
75
|
+
</ButtonLink>
|
|
76
|
+
</Card>
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Any other router
|
|
81
|
+
|
|
82
|
+
The seam's contract is **a component that takes `href`**, so an adapter is a rename. There
|
|
83
|
+
is nothing to publish for wouter, TanStack Router, or a hand-rolled router — four lines in
|
|
84
|
+
the app is the whole thing.
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// src/routing/WouterLink.tsx
|
|
88
|
+
import type { RouterLinkComponent } from "@charcuterie/ui"
|
|
89
|
+
import { Link } from "wouter-preact"
|
|
90
|
+
|
|
91
|
+
export const WouterLink: RouterLinkComponent = ({
|
|
92
|
+
href,
|
|
93
|
+
...linkProps
|
|
94
|
+
}) => <Link {...linkProps} to={href} />
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// src/main.tsx
|
|
99
|
+
import { RouterLinkProvider } from "@charcuterie/ui"
|
|
100
|
+
|
|
101
|
+
import { WouterLink } from "./routing/WouterLink.tsx"
|
|
102
|
+
|
|
103
|
+
<RouterLinkProvider link={WouterLink}>
|
|
104
|
+
<App />
|
|
105
|
+
</RouterLinkProvider>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A hand-rolled router — plex-channels' and gallery-downloader's shape — injects its own
|
|
109
|
+
push:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
// src/routing/HashRouterLink.tsx
|
|
113
|
+
import type { RouterLinkComponent } from "@charcuterie/ui"
|
|
114
|
+
|
|
115
|
+
export const HashRouterLink: RouterLinkComponent = ({
|
|
116
|
+
href,
|
|
117
|
+
...linkProps
|
|
118
|
+
}) => (
|
|
119
|
+
<a
|
|
120
|
+
{...linkProps}
|
|
121
|
+
href={href}
|
|
122
|
+
onClick={(clickEvent) => {
|
|
123
|
+
// Let the browser have every click that is *asking* for the
|
|
124
|
+
// browser: a new tab, a new window, a download, a middle-click.
|
|
125
|
+
// Swallowing these is exactly what a `<button onClick>` gets
|
|
126
|
+
// wrong, and re-implementing them is what this component is
|
|
127
|
+
// avoiding.
|
|
128
|
+
if (
|
|
129
|
+
clickEvent.defaultPrevented ||
|
|
130
|
+
clickEvent.button !== 0 ||
|
|
131
|
+
clickEvent.altKey ||
|
|
132
|
+
clickEvent.ctrlKey ||
|
|
133
|
+
clickEvent.metaKey ||
|
|
134
|
+
clickEvent.shiftKey
|
|
135
|
+
) {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clickEvent.preventDefault()
|
|
140
|
+
|
|
141
|
+
window.location.hash = href
|
|
142
|
+
}}
|
|
143
|
+
/>
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## What the seam will not route
|
|
148
|
+
|
|
149
|
+
Three destinations always fall through to a plain `<a>`, whatever is injected:
|
|
150
|
+
|
|
151
|
+
| Destination | Why |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| `isExternal` | A router pushing `https://…` onto its history stack lands the SPA on a route that does not exist. |
|
|
154
|
+
| Another scheme or origin — `https://`, `mailto:`, `tel:`, `//cdn…` | Same. `getIsRoutedHref` is the test, and it is exported. |
|
|
155
|
+
| A fragment — `#credits` | The browser's own scroll-to-id is what a reader expects; a router turns it into a route change. |
|
|
156
|
+
|
|
157
|
+
It is a test on the **destination** rather than a prop, deliberately: a component that
|
|
158
|
+
routed whatever it was given would make `isExternal` a correctness switch instead of a
|
|
159
|
+
presentation one, and an app that forgot it would ship a broken link rather than a slightly
|
|
160
|
+
plain one.
|
|
161
|
+
|
|
162
|
+
## Testing an app that uses this
|
|
163
|
+
|
|
164
|
+
Nothing to mock. Without a provider the components render `AnchorLink`, so a component test
|
|
165
|
+
asserts `getByRole("link", { name: … })` and its `href` with no router in the tree at all.
|
|
166
|
+
With a provider, inject a stand-in that marks what it was handed — `TextLink.stories.tsx`'s
|
|
167
|
+
`Routed` story is exactly that, and `TextLink.test.tsx` reads it.
|
|
168
|
+
|
|
169
|
+
## API
|
|
170
|
+
|
|
171
|
+
| Export | From | What |
|
|
172
|
+
| --- | --- | --- |
|
|
173
|
+
| `RouterLinkProvider` | `@charcuterie/ui` | The seam. `link` is the app's link component. |
|
|
174
|
+
| `useRouterLink()` | `@charcuterie/ui` | The injected component, or `AnchorLink`. For an app building its own link-shaped component. |
|
|
175
|
+
| `RouterLinkComponent` / `RouterLinkProps` | `@charcuterie/ui` | The contract: `ComponentPropsWithRef<"a">` with `href` required. |
|
|
176
|
+
| `AnchorLink` | `@charcuterie/ui` | The plain `<a href>` default. |
|
|
177
|
+
| `getIsRoutedHref(href)` | `@charcuterie/ui` | The destination test above. |
|
|
178
|
+
| `ReactRouterLink` | `@charcuterie/ui/react-router` | The one shipped adapter. Optional peer. |
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { createContext, useContext } from "react"
|
|
3
|
+
|
|
4
|
+
import { AnchorLink } from "./AnchorLink.tsx"
|
|
5
|
+
import type { RouterLinkComponent } from "./routerLink.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The router seam: one injection at the app root, and every
|
|
9
|
+
* `TextLink` / `ButtonLink` in the tree navigates the app's own way.
|
|
10
|
+
*
|
|
11
|
+
* ### Why the library cannot just pick a router
|
|
12
|
+
*
|
|
13
|
+
* The fleet has five answers at once — react-router v8 (mux-magic),
|
|
14
|
+
* react-router-dom v7 (bambuddy), wouter-preact (spoolbuddy), two
|
|
15
|
+
* hand-rolled routers, and five apps with no router at all. A
|
|
16
|
+
* dependency on any one of them would be wrong for four of the five,
|
|
17
|
+
* and `@charcuterie/ui` taking a router as a runtime dependency is a
|
|
18
|
+
* thing every consumer inherits.
|
|
19
|
+
*
|
|
20
|
+
* So the shape is the one `ColorSchemeSwitcher` already uses for the
|
|
21
|
+
* browser: **the seam has a working default**. Nothing injected means
|
|
22
|
+
* `AnchorLink` — a real `<a href>` that navigates by reloading, which
|
|
23
|
+
* is correct, just not soft. An app that wants soft navigation
|
|
24
|
+
* injects its router's link once:
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* import { RouterLinkProvider } from "@charcuterie/ui"
|
|
28
|
+
* import { ReactRouterLink } from "@charcuterie/ui/react-router"
|
|
29
|
+
*
|
|
30
|
+
* <RouterLinkProvider link={ReactRouterLink}>
|
|
31
|
+
* <App />
|
|
32
|
+
* </RouterLinkProvider>
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* A router with no adapter here needs no adapter *from* here — the
|
|
36
|
+
* contract is a component taking `href`, so a four-line wrapper in
|
|
37
|
+
* the app is the whole integration. `Guides/Routing` shows both.
|
|
38
|
+
*
|
|
39
|
+
* ### It is deliberately not a hook the app calls
|
|
40
|
+
*
|
|
41
|
+
* `useNavigate` + `onClick` is what mux-magic and plex-channels do
|
|
42
|
+
* today, and it is how a nav item stops being a link: no middle-click,
|
|
43
|
+
* no ctrl-click, no "open in new tab", no status bar, nothing to copy.
|
|
44
|
+
* Injecting a *component* keeps the `<a href>` — the router only gets
|
|
45
|
+
* to intercept the plain-left-click that it can actually improve.
|
|
46
|
+
*/
|
|
47
|
+
const RouterLinkContext =
|
|
48
|
+
createContext<RouterLinkComponent>(AnchorLink)
|
|
49
|
+
|
|
50
|
+
export const useRouterLink = (): RouterLinkComponent =>
|
|
51
|
+
useContext(RouterLinkContext)
|
|
52
|
+
|
|
53
|
+
export const RouterLinkProvider = ({
|
|
54
|
+
children,
|
|
55
|
+
link,
|
|
56
|
+
}: {
|
|
57
|
+
children: ReactNode
|
|
58
|
+
/**
|
|
59
|
+
* The app's link component, taking `href`. `ReactRouterLink` from
|
|
60
|
+
* `@charcuterie/ui/react-router`, or the app's own four-line
|
|
61
|
+
* wrapper around whatever it routes with.
|
|
62
|
+
*/
|
|
63
|
+
link: RouterLinkComponent
|
|
64
|
+
}): ReactNode => (
|
|
65
|
+
<RouterLinkContext.Provider value={link}>
|
|
66
|
+
{children}
|
|
67
|
+
</RouterLinkContext.Provider>
|
|
68
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of the thing a router injects, and the one question the
|
|
3
|
+
* link components ask before they use it.
|
|
4
|
+
*
|
|
5
|
+
* Types live here rather than beside either implementation so
|
|
6
|
+
* `AnchorLink` (the default) and `RouterLinkProvider` (which defaults
|
|
7
|
+
* to it) can both name them without importing each other.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ComponentPropsWithRef,
|
|
12
|
+
ComponentType,
|
|
13
|
+
} from "react"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `href`, not `to`.
|
|
17
|
+
*
|
|
18
|
+
* The library is router-agnostic and the platform's word for a
|
|
19
|
+
* destination is `href` — so the contract is the DOM's, and each
|
|
20
|
+
* adapter renames it on the way in (`ReactRouterLink` maps `href` to
|
|
21
|
+
* react-router's `to`). Injecting a component whose prop is `to`
|
|
22
|
+
* would make `@charcuterie/ui`'s own API a mirror of one router's.
|
|
23
|
+
*/
|
|
24
|
+
export type RouterLinkProps = Omit<
|
|
25
|
+
ComponentPropsWithRef<"a">,
|
|
26
|
+
"href"
|
|
27
|
+
> & {
|
|
28
|
+
href: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type RouterLinkComponent =
|
|
32
|
+
ComponentType<RouterLinkProps>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether this destination is the injected router's to handle.
|
|
36
|
+
*
|
|
37
|
+
* A router's `<Link>` intercepts the click and pushes history, which
|
|
38
|
+
* is right for `/settings` and wrong for three cases it cannot serve:
|
|
39
|
+
*
|
|
40
|
+
* - **Another origin or another scheme** — `https://…`, `mailto:`,
|
|
41
|
+
* `tel:`, protocol-relative `//cdn…`. Pushing those onto the
|
|
42
|
+
* history stack navigates the SPA to a route that does not exist.
|
|
43
|
+
* - **A same-page fragment** — `#credits`. The browser's own
|
|
44
|
+
* scroll-to-id is the behaviour a reader expects; a router turns
|
|
45
|
+
* it into a route change.
|
|
46
|
+
*
|
|
47
|
+
* Everything else — `/x`, `x`, `?sort=name` — is the router's.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately a *destination* test rather than a prop: a component
|
|
50
|
+
* that routed whatever it was given would make `isExternal` a
|
|
51
|
+
* correctness switch instead of a presentation one, and an app that
|
|
52
|
+
* forgot it would ship a broken link rather than a slightly plain
|
|
53
|
+
* one.
|
|
54
|
+
*/
|
|
55
|
+
export const getIsRoutedHref = (href: string): boolean =>
|
|
56
|
+
!/^(?:[a-z][a-z\d+\-.]*:|\/\/|#)/i.test(href)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as TextLinkStories from "./TextLink.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={TextLinkStories} />
|
|
6
|
+
|
|
7
|
+
# TextLink
|
|
8
|
+
|
|
9
|
+
**Navigation that looks like a link.** Reach for it whenever pressing the thing takes the
|
|
10
|
+
user somewhere — another route, another page, another site. If it changes something on the
|
|
11
|
+
page instead, that is a `Button`
|
|
12
|
+
([decision](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-10-buttons-are-actions-links-are-navigation.md)).
|
|
13
|
+
|
|
14
|
+
Measured across the fleet before this shipped: **seven repos** hand-roll a back-link with a
|
|
15
|
+
literal `←`, each with its own hover rule and its own idea of whether focus is visible — and
|
|
16
|
+
points-market, which already consumes this library, reaches for a ghost `Button` for its
|
|
17
|
+
header home link because there was nothing else to reach for. All of them are an `<a href>`
|
|
18
|
+
here, so middle-click, ctrl-click, "open in new tab", "copy link address", and the browser's
|
|
19
|
+
status bar work without anybody implementing them.
|
|
20
|
+
|
|
21
|
+
<Canvas of={TextLinkStories.Default} />
|
|
22
|
+
|
|
23
|
+
## Props
|
|
24
|
+
|
|
25
|
+
<Controls />
|
|
26
|
+
|
|
27
|
+
## Two appearances
|
|
28
|
+
|
|
29
|
+
<Canvas of={TextLinkStories.AllAppearances} />
|
|
30
|
+
|
|
31
|
+
| | `inline` | `standalone` |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| Where it lives | inside a sentence | on its own — a back-link, a nav item, a "view all" |
|
|
34
|
+
| Display | `inline`, so it wraps with the prose | `inline-flex`, with a gap for an icon |
|
|
35
|
+
| Underline | always | on hover |
|
|
36
|
+
| Type size | inherited — never its own | inherited |
|
|
37
|
+
|
|
38
|
+
The permanent underline on `inline` is not a taste call: inside a paragraph, colour alone
|
|
39
|
+
cannot be the only thing marking a link (WCAG 1.4.1). A standalone nav column has position
|
|
40
|
+
and spacing doing that work, so it underlines on hover instead — a column of permanently
|
|
41
|
+
underlined items reads as a mistake.
|
|
42
|
+
|
|
43
|
+
There is deliberately **no `size` prop**. Text takes the size of the text around it; a link
|
|
44
|
+
that resized itself would be a hole in whatever type scale it landed in.
|
|
45
|
+
|
|
46
|
+
<Canvas of={TextLinkStories.BackLink} />
|
|
47
|
+
|
|
48
|
+
<Canvas of={TextLinkStories.InProse} />
|
|
49
|
+
|
|
50
|
+
## What it fixes
|
|
51
|
+
|
|
52
|
+
| Fleet behaviour today | Here |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `<div onClick>` or `<button onClick={() => navigate(…)}>` as a nav item | a real `<a href>`, with everything the browser gives one |
|
|
55
|
+
| A `←` and its label as two siblings, so only the label is the tap target | one anchor, arrow inside it |
|
|
56
|
+
| `outline-none` with nothing put back | `focus-visible` at the variant's own ring width |
|
|
57
|
+
| `target="_blank"` with no `rel` and no warning | `isExternal` does all three |
|
|
58
|
+
| Seven spellings of the same hover | one |
|
|
59
|
+
|
|
60
|
+
## Accessibility contract
|
|
61
|
+
|
|
62
|
+
- The accessible name is the children. An `iconStart`/`iconEnd` is decoration inside the
|
|
63
|
+
same anchor, so the name stays the words.
|
|
64
|
+
- `isExternal` adds a visually-hidden **"(opens in a new tab)"** to the name, because a new
|
|
65
|
+
tab with no warning is a WCAG 3.2.5 surprise — and `rel="noopener noreferrer"`, because
|
|
66
|
+
`target="_blank"` alone hands the destination a live `window.opener` into the app.
|
|
67
|
+
- `isDisabled` **drops `href`** and sets `aria-disabled` with an explicit `role="link"`. An
|
|
68
|
+
anchor with no `href` is inert and out of the tab order by the platform's own rules,
|
|
69
|
+
which is the honest version of a state the platform does not have; the role keeps it
|
|
70
|
+
announced as an unavailable link rather than vanishing into a `<span>`. Reach for it
|
|
71
|
+
rarely — a dead link in a sentence usually reads better as plain text.
|
|
72
|
+
- Hover changes the **underline**, never the colour. There is no `intent.*.content-hover`
|
|
73
|
+
token, and an invented colour is one the contrast gate has never measured.
|
|
74
|
+
|
|
75
|
+
<Canvas of={TextLinkStories.AllStates} />
|
|
76
|
+
|
|
77
|
+
<Canvas of={TextLinkStories.External} />
|
|
78
|
+
|
|
79
|
+
## Icons are the app's
|
|
80
|
+
|
|
81
|
+
The library ships **zero** SVGs and no symbol glyphs in a default
|
|
82
|
+
([decision](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)) —
|
|
83
|
+
`←` and `↗` measure blank in this repo's headless Chromium and on the kiosk Pis, so the
|
|
84
|
+
component takes a `ReactNode` and the app passes the arrow it already owns.
|
|
85
|
+
|
|
86
|
+
## Routing
|
|
87
|
+
|
|
88
|
+
`TextLink` renders through whatever link component the app injected at its root, and a
|
|
89
|
+
plain `<a>` when nothing is injected. See **Guides/Routing**.
|
|
90
|
+
|
|
91
|
+
<Canvas of={TextLinkStories.Routed} />
|
|
92
|
+
|
|
93
|
+
<Canvas of={TextLinkStories.AllIntents} />
|