@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
@@ -116,9 +116,11 @@ export const FieldGroup = ({
116
116
 
117
117
  {children}
118
118
 
119
+ {/* `text-sm` since 2026-08-10, matching `Field` — same reasoning,
120
+ and the two must not disagree about how big a description is. */}
119
121
  {description === undefined ? null : (
120
122
  <p
121
- className="text-content-secondary text-xs"
123
+ className="text-content-secondary text-sm"
122
124
  id={descriptionId}
123
125
  >
124
126
  {description}
@@ -127,7 +129,7 @@ export const FieldGroup = ({
127
129
 
128
130
  {error === undefined ? null : (
129
131
  <p
130
- className="text-intent-danger-content text-xs"
132
+ className="text-intent-danger-content text-sm"
131
133
  id={errorId}
132
134
  >
133
135
  {error}
@@ -67,6 +67,53 @@ test("a full pane is scrolled to the bottom", async () => {
67
67
  })
68
68
  })
69
69
 
70
+ /**
71
+ * The browser's own contribution to defect 1, and the reason this is
72
+ * asserted rather than commented.
73
+ *
74
+ * Chromium picks an anchor node inside a scroll container and moves
75
+ * `scrollTop` to hold it still across a relayout. For a log pane that
76
+ * is the browser undoing the follow: `@charcuterie/tokens` ships
77
+ * Victor Mono `font-display: swap`, so a pane mounted before the face
78
+ * arrives is laid out twice, and the anchor drags a followed pane a
79
+ * pixel off the end. Measured on this story with the font request
80
+ * held back — 722 (at the end) with anchoring off, 721 (a pixel
81
+ * short) with it on.
82
+ *
83
+ * Whether the swap beats the mount is a race, so it presented as
84
+ * visual-regression flake on exactly one story rather than as a bug.
85
+ * The behavioural assertions above cannot catch it: their four pixels
86
+ * of slack exist for fractional device pixel ratios, and the drift
87
+ * fits inside them. So this asserts the opt-out itself — invisible in
88
+ * a screenshot, invisible in the DOM, and one `--write --unsafe` pass
89
+ * from being tidied away.
90
+ */
91
+ test("a following pane opts out of the browser's scroll anchoring", async () => {
92
+ const { canvas } = await mountStory(Interactive)
93
+
94
+ const pane = expectAgentDrivable(canvas, {
95
+ name: "Bay 8 rip log",
96
+ role: "log",
97
+ })
98
+
99
+ await expect(getComputedStyle(pane).overflowAnchor).toBe(
100
+ "none",
101
+ )
102
+
103
+ // And it hands anchoring back when the user takes over. `maxLines`
104
+ // drops old lines off the top of a live log, and anchoring is what
105
+ // stops that shoving the line they scrolled up to read.
106
+ pane.scrollTop = 0
107
+
108
+ pane.dispatchEvent(new Event("scroll", { bubbles: true }))
109
+
110
+ await waitFor(() => {
111
+ expect(getComputedStyle(pane).overflowAnchor).toBe(
112
+ "auto",
113
+ )
114
+ })
115
+ })
116
+
70
117
  /**
71
118
  * The same defect, rebuilt out of two components that are each
72
119
  * individually right — and the reason `LogViewer` watches its own box
@@ -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="max-h-64 overflow-y-auto rounded-md border border-border-subtle bg-surface-sunken p-2 font-mono text-content-secondary text-xs"
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)
@@ -90,7 +90,12 @@ export const SortableTableHeader = ({
90
90
  >
91
91
  <button
92
92
  className={toClassName(
93
- "flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-3 py-2 text-start font-medium text-content-secondary text-xs uppercase tracking-wide transition-colors duration-(--duration-fast) ease-standard",
93
+ // `text-sm` since 2026-08-10. This was the single most
94
+ // fragile combination in the package: `text-xs` at 12px,
95
+ // uppercased and letterspaced, which strips the word shapes
96
+ // readers use at small sizes. A column header is the label
97
+ // for everything under it, so it is content.
98
+ "flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-3 py-2 text-start font-medium text-content-secondary text-sm uppercase tracking-wide transition-colors duration-(--duration-fast) ease-standard",
94
99
  "hover:text-content-primary",
95
100
  FOCUS_RING_CLASS,
96
101
  )}
@@ -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} />