@charcuterie/ui 3.30.0 → 3.31.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/dist/Tabs/PanelTabs.d.ts +56 -0
- package/dist/Tabs/PanelTabs.d.ts.map +1 -0
- package/dist/Tabs/PanelTabs.js +163 -0
- package/dist/Tabs/PanelTabs.js.map +1 -0
- package/dist/Tabs/RoutedTabs.d.ts +19 -0
- package/dist/Tabs/RoutedTabs.d.ts.map +1 -0
- package/dist/Tabs/RoutedTabs.js +28 -0
- package/dist/Tabs/RoutedTabs.js.map +1 -0
- package/dist/Tabs/TabLink.d.ts +34 -0
- package/dist/Tabs/TabLink.d.ts.map +1 -0
- package/dist/Tabs/TabLink.js +66 -0
- package/dist/Tabs/TabLink.js.map +1 -0
- package/dist/Tabs/TabTrigger.d.ts +1 -1
- package/dist/Tabs/TabTrigger.d.ts.map +1 -1
- package/dist/Tabs/TabTrigger.js +6 -30
- package/dist/Tabs/TabTrigger.js.map +1 -1
- package/dist/Tabs/Tabs.d.ts +80 -64
- package/dist/Tabs/Tabs.d.ts.map +1 -1
- package/dist/Tabs/Tabs.js +53 -182
- package/dist/Tabs/Tabs.js.map +1 -1
- package/dist/Tabs/tabItems.d.ts +36 -0
- package/dist/Tabs/tabItems.d.ts.map +1 -0
- package/dist/Tabs/tabItems.js +2 -0
- package/dist/Tabs/tabItems.js.map +1 -0
- package/dist/Tabs/tabStyles.d.ts +41 -0
- package/dist/Tabs/tabStyles.d.ts.map +1 -0
- package/dist/Tabs/tabStyles.js +79 -0
- package/dist/Tabs/tabStyles.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Tabs/PanelTabs.tsx +260 -0
- package/src/Tabs/RoutedTabs.tsx +58 -0
- package/src/Tabs/TabLink.tsx +132 -0
- package/src/Tabs/TabTrigger.tsx +10 -39
- package/src/Tabs/Tabs.mdx +70 -0
- package/src/Tabs/Tabs.stories.tsx +133 -1
- package/src/Tabs/Tabs.test.tsx +169 -0
- package/src/Tabs/Tabs.tsx +94 -296
- package/src/Tabs/tabItems.ts +40 -0
- package/src/Tabs/tabStyles.ts +107 -0
- package/src/index.ts +8 -3
package/src/Tabs/Tabs.mdx
CHANGED
|
@@ -19,6 +19,76 @@ It did not come out ugly. The composition is two hooks and one decision.
|
|
|
19
19
|
|
|
20
20
|
<Controls />
|
|
21
21
|
|
|
22
|
+
## Two bars, and the question is not "how does it look"
|
|
23
|
+
|
|
24
|
+
**Does this section have an address?**
|
|
25
|
+
|
|
26
|
+
| | Panel tab | Routed tab |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| The item carries | `content` | `href` |
|
|
29
|
+
| Each trigger is | `<button role="tab">` | `<a href>` |
|
|
30
|
+
| The bar is | `role="tablist"` | `<nav aria-label>` |
|
|
31
|
+
| The section is drawn by | this component | the router, through your `<Outlet />` |
|
|
32
|
+
| Which one is on | `Tabs` owns it | the address does, via `activeHref` |
|
|
33
|
+
| Reload, Back, new tab | start over | land on the same section |
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
// A disclosure inside one screen. Nothing outside knows about it.
|
|
37
|
+
<Tabs label="Bay 3" tabs={[{ key: "log", label: "Log", content: <Log /> }]} />
|
|
38
|
+
|
|
39
|
+
// A section that has an address.
|
|
40
|
+
<Tabs
|
|
41
|
+
activeHref={pathname}
|
|
42
|
+
label="Bay 3 sections"
|
|
43
|
+
tabs={[{ href: "/projects/1/tasks", label: "Tasks" }]}
|
|
44
|
+
/>
|
|
45
|
+
<Outlet />
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`activeHref` is the discriminant, and it is required in the routed mode. `tabs` alone
|
|
49
|
+
could not carry the split: TypeScript does not narrow a union by the shape of an array's
|
|
50
|
+
elements, so a bar mixing the two would type-check and then render half a navigation.
|
|
51
|
+
|
|
52
|
+
<Canvas of={TabsStories.Routed} />
|
|
53
|
+
|
|
54
|
+
### The paint is shared, and that is the point
|
|
55
|
+
|
|
56
|
+
Docket had one of each on adjacent screens wearing **different paint** — a filled pill on
|
|
57
|
+
the project bar, this underline on Settings — because a routed section switcher had to be
|
|
58
|
+
built out of `Nav`, and `Nav`'s current item is a pill. `toTabTriggerClass` is now the one
|
|
59
|
+
definition both triggers read, and `Tabs.test.tsx` compares the two computed styles
|
|
60
|
+
property by property, because a screenshot cannot tell them apart on purpose.
|
|
61
|
+
[Decision](../../../docs/decisions/2026-08-31-a-routed-tab-is-a-tab-with-an-href.md).
|
|
62
|
+
|
|
63
|
+
<Canvas of={TabsStories.RoutedAllVariants} />
|
|
64
|
+
|
|
65
|
+
### The routed bar is not a `tablist`
|
|
66
|
+
|
|
67
|
+
`role="tab"` on an anchor **overrides** the link role. A screen reader announces a
|
|
68
|
+
disclosure, the reader waits for a panel to appear beside it, and instead the address
|
|
69
|
+
changes and the page under them is replaced. `aria-controls` has nothing to point at
|
|
70
|
+
either — the section's content comes from the router, somewhere this component cannot see.
|
|
71
|
+
|
|
72
|
+
So the routed bar is what it actually is: a named `<nav>` of real links with
|
|
73
|
+
`aria-current="page"` on the one you are on. Which one that is comes from `Nav`'s
|
|
74
|
+
`resolveActiveKey`, imported rather than restated — whole-segment matching, deepest match
|
|
75
|
+
wins, the root is exact, an external item is never current, and the query string is not
|
|
76
|
+
part of the answer.
|
|
77
|
+
|
|
78
|
+
<Canvas of={TabsStories.RoutedAllStates} />
|
|
79
|
+
|
|
80
|
+
A **disabled** routed tab is not a link at all. `<a>` has no `disabled` attribute, so the
|
|
81
|
+
tempting version — a real `href` plus `aria-disabled` — greys out and still navigates on
|
|
82
|
+
Enter and on a middle click; it renders as a `<span aria-disabled>` instead.
|
|
83
|
+
|
|
84
|
+
### This is sub-navigation. `Nav` is still the app's destinations
|
|
85
|
+
|
|
86
|
+
The header row, the side rail and the Narrow View's menu are `Nav` + `useNavLayout`, and
|
|
87
|
+
that has not moved.
|
|
88
|
+
[Decision](../../../docs/decisions/2026-08-27-nav-is-the-one-navigation-component-and-navbar-is-deprecated.md).
|
|
89
|
+
A routed `Tabs` divides **one screen** into sections, so it renders no rail, no fold and
|
|
90
|
+
no menu — a bar inside a screen has nowhere to fold to.
|
|
91
|
+
|
|
22
92
|
## The composition
|
|
23
93
|
|
|
24
94
|
```ts
|
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
StoryGrid,
|
|
9
9
|
} from "../board.storyHelpers.tsx"
|
|
10
10
|
import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
|
|
11
|
-
import type { TabItem } from "./Tabs.tsx"
|
|
12
11
|
import { Tabs } from "./Tabs.tsx"
|
|
12
|
+
import type { TabItem, TabLinkItem } from "./tabItems.ts"
|
|
13
13
|
|
|
14
14
|
const BAY_TABS: TabItem[] = [
|
|
15
15
|
{
|
|
@@ -185,3 +185,135 @@ export const Manual: Story = {
|
|
|
185
185
|
tabs: BAY_TABS,
|
|
186
186
|
},
|
|
187
187
|
}
|
|
188
|
+
|
|
189
|
+
const BAY_ROUTE_TABS: TabLinkItem[] = [
|
|
190
|
+
{
|
|
191
|
+
href: "/bays/3",
|
|
192
|
+
label: "Overview",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
href: "/bays/3/titles",
|
|
196
|
+
label: "Titles",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
href: "/bays/3/tracks",
|
|
200
|
+
label: "Tracks",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
href: "/bays/3/log",
|
|
204
|
+
label: "Log",
|
|
205
|
+
},
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A tab that is a **place**. Every trigger is a real `<a href>`, so
|
|
210
|
+
* middle-click, ctrl-click, "open in a new tab" and "copy link
|
|
211
|
+
* address" all work — and reloading the page comes back to the same
|
|
212
|
+
* section, which is the whole reason to route one.
|
|
213
|
+
*
|
|
214
|
+
* The bar is a named `<nav>`, not a `tablist`: `role="tab"` on an
|
|
215
|
+
* anchor overrides the link role, so a screen reader would announce
|
|
216
|
+
* a disclosure and then the address would change instead. The
|
|
217
|
+
* *paint* is shared with the panel bar above through
|
|
218
|
+
* `toTabTriggerClass`, which is what this mode was added to fix.
|
|
219
|
+
*
|
|
220
|
+
* The sections themselves are rendered by the router — an
|
|
221
|
+
* `<Outlet />` under the bar — so this component draws no panels
|
|
222
|
+
* here at all.
|
|
223
|
+
*/
|
|
224
|
+
export const Routed: Story = {
|
|
225
|
+
args: { label: "Bay 3", tabs: BAY_TABS },
|
|
226
|
+
render: () => (
|
|
227
|
+
<Tabs
|
|
228
|
+
activeHref="/bays/3/log"
|
|
229
|
+
label="Bay 3 sections"
|
|
230
|
+
tabs={BAY_ROUTE_TABS}
|
|
231
|
+
/>
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Routed and panel bars side by side — the comparison this mode
|
|
237
|
+
* exists for. They are one shape doing one job, and before `href`
|
|
238
|
+
* existed the routed one had to be built out of `Nav`, whose current
|
|
239
|
+
* item is a filled pill.
|
|
240
|
+
*
|
|
241
|
+
* The third cell is the matching rule, which is `Nav`'s
|
|
242
|
+
* `resolveActiveKey` rather than a second implementation: the
|
|
243
|
+
* deepest matching path wins, so `/tasks` stands down while the
|
|
244
|
+
* reader is on `/tasks` *and* nothing lights up twice.
|
|
245
|
+
*/
|
|
246
|
+
export const RoutedAllVariants: Story = {
|
|
247
|
+
args: { label: "Bay 3", tabs: BAY_TABS },
|
|
248
|
+
render: () => (
|
|
249
|
+
<StoryGrid columns={2}>
|
|
250
|
+
<StoryCell label="routed · horizontal">
|
|
251
|
+
<Tabs
|
|
252
|
+
activeHref="/bays/3/titles"
|
|
253
|
+
label="Routed horizontal"
|
|
254
|
+
tabs={BAY_ROUTE_TABS}
|
|
255
|
+
/>
|
|
256
|
+
</StoryCell>
|
|
257
|
+
|
|
258
|
+
<StoryCell label="panel · horizontal">
|
|
259
|
+
<Tabs label="Panel horizontal" tabs={BAY_TABS} />
|
|
260
|
+
</StoryCell>
|
|
261
|
+
|
|
262
|
+
<StoryCell label="routed · vertical">
|
|
263
|
+
<Tabs
|
|
264
|
+
activeHref="/bays/3/tracks"
|
|
265
|
+
label="Routed vertical"
|
|
266
|
+
orientation="vertical"
|
|
267
|
+
tabs={BAY_ROUTE_TABS}
|
|
268
|
+
/>
|
|
269
|
+
</StoryCell>
|
|
270
|
+
|
|
271
|
+
<StoryCell label="routed · a child route is still the section">
|
|
272
|
+
<Tabs
|
|
273
|
+
activeHref="/bays/3/titles/4"
|
|
274
|
+
label="Routed on a child route"
|
|
275
|
+
tabs={BAY_ROUTE_TABS}
|
|
276
|
+
/>
|
|
277
|
+
</StoryCell>
|
|
278
|
+
</StoryGrid>
|
|
279
|
+
),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const ROUTED_STATE_TABS: TabLinkItem[] = [
|
|
283
|
+
...BAY_ROUTE_TABS,
|
|
284
|
+
{
|
|
285
|
+
href: "/bays/3/reports",
|
|
286
|
+
isDisabled: true,
|
|
287
|
+
label: "Reports",
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
href: "https://mkdocs.octen.dev/workspace/docket/",
|
|
291
|
+
isExternal: true,
|
|
292
|
+
label: "Handbook",
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A disabled routed tab is **not a link at all** — a `<span>` with
|
|
298
|
+
* `aria-disabled`, because an anchor has no `disabled` attribute and
|
|
299
|
+
* `aria-disabled` alone still navigates on Enter. That is the
|
|
300
|
+
* version of this that looks right and is not.
|
|
301
|
+
*
|
|
302
|
+
* An external tab is a plain anchor with `target="_blank"` and
|
|
303
|
+
* `rel="noopener noreferrer"`, and it is **never current** — no
|
|
304
|
+
* address inside this app can be a place outside it.
|
|
305
|
+
*
|
|
306
|
+
* `activeHref` here is a screen the bar does not contain, which is
|
|
307
|
+
* the third state: **nothing** is current. A bar that guessed the
|
|
308
|
+
* closest tab instead would be lying on every modal route.
|
|
309
|
+
*/
|
|
310
|
+
export const RoutedAllStates: Story = {
|
|
311
|
+
args: { label: "Bay 3", tabs: BAY_TABS },
|
|
312
|
+
render: () => (
|
|
313
|
+
<Tabs
|
|
314
|
+
activeHref="/settings"
|
|
315
|
+
label="Bay 3 states"
|
|
316
|
+
tabs={ROUTED_STATE_TABS}
|
|
317
|
+
/>
|
|
318
|
+
),
|
|
319
|
+
}
|
package/src/Tabs/Tabs.test.tsx
CHANGED
|
@@ -13,6 +13,9 @@ const {
|
|
|
13
13
|
Default,
|
|
14
14
|
Manual,
|
|
15
15
|
Responsive,
|
|
16
|
+
Routed,
|
|
17
|
+
RoutedAllStates,
|
|
18
|
+
RoutedAllVariants,
|
|
16
19
|
} = composeStories(stories)
|
|
17
20
|
|
|
18
21
|
test("selecting a tab wires the panel back to it", async () => {
|
|
@@ -281,3 +284,169 @@ test("automatic activation selects as focus moves, and wraps", async () => {
|
|
|
281
284
|
expect(tabs[0]).toHaveAttribute("aria-selected", "true")
|
|
282
285
|
})
|
|
283
286
|
})
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The routed bar is a **navigation**, not a tablist, and this is the
|
|
290
|
+
* assertion that says so. `role="tab"` on an anchor overrides the
|
|
291
|
+
* link role: a screen reader announces a disclosure, the reader
|
|
292
|
+
* waits for a panel, and the address changes under them instead.
|
|
293
|
+
*
|
|
294
|
+
* A screenshot cannot tell the two apart — the paint is deliberately
|
|
295
|
+
* identical — so nothing but this catches a regression to it.
|
|
296
|
+
*/
|
|
297
|
+
test("a routed tab is a link in a nav, never a tab in a tablist", async () => {
|
|
298
|
+
const { canvas, canvasElement } = await mountStory(Routed)
|
|
299
|
+
|
|
300
|
+
const nav = expectAgentDrivable(canvas, {
|
|
301
|
+
name: "Bay 3 sections",
|
|
302
|
+
role: "navigation",
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await expect(
|
|
306
|
+
canvas.queryByRole("tablist"),
|
|
307
|
+
).not.toBeInTheDocument()
|
|
308
|
+
|
|
309
|
+
await expect(
|
|
310
|
+
canvas.queryByRole("tab"),
|
|
311
|
+
).not.toBeInTheDocument()
|
|
312
|
+
|
|
313
|
+
// No panels either: the sections are the router's, rendered
|
|
314
|
+
// through an `<Outlet />` this component cannot see.
|
|
315
|
+
await expect(
|
|
316
|
+
canvas.queryByRole("tabpanel"),
|
|
317
|
+
).not.toBeInTheDocument()
|
|
318
|
+
|
|
319
|
+
const links = Array.from(
|
|
320
|
+
nav.querySelectorAll<HTMLAnchorElement>("a[href]"),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
await expect(links).toHaveLength(4)
|
|
324
|
+
|
|
325
|
+
await expectNoAxeViolations(canvasElement)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test("exactly one routed tab is current, and it is the deepest match", async () => {
|
|
329
|
+
const { canvas } = await mountStory(RoutedAllVariants)
|
|
330
|
+
|
|
331
|
+
// `/tasks` and the project root both match `/…/tasks`, and only
|
|
332
|
+
// the deeper one may light up — a bar with two current items has
|
|
333
|
+
// stopped saying anything. The rule is `resolveActiveKey`, shared
|
|
334
|
+
// with `Nav` rather than reimplemented here.
|
|
335
|
+
const nav = expectAgentDrivable(canvas, {
|
|
336
|
+
name: "Routed horizontal",
|
|
337
|
+
role: "navigation",
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const current = nav.querySelectorAll(
|
|
341
|
+
'[aria-current="page"]',
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
await expect(current).toHaveLength(1)
|
|
345
|
+
|
|
346
|
+
await expect(current[0]).toHaveTextContent("Titles")
|
|
347
|
+
|
|
348
|
+
// A child route is still inside the section it belongs to.
|
|
349
|
+
const child = expectAgentDrivable(canvas, {
|
|
350
|
+
name: "Routed on a child route",
|
|
351
|
+
role: "navigation",
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const childCurrent = child.querySelectorAll(
|
|
355
|
+
'[aria-current="page"]',
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
await expect(childCurrent).toHaveLength(1)
|
|
359
|
+
|
|
360
|
+
await expect(childCurrent[0]).toHaveTextContent("Titles")
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A disabled anchor is the defect this guards. `<a>` has no
|
|
365
|
+
* `disabled` attribute, so the tempting version — a real `href` plus
|
|
366
|
+
* `aria-disabled` — greys out and still navigates on Enter and on a
|
|
367
|
+
* middle click.
|
|
368
|
+
*/
|
|
369
|
+
test("a disabled routed tab has no href, and an external one is never current", async () => {
|
|
370
|
+
const { canvas, canvasElement } =
|
|
371
|
+
await mountStory(RoutedAllStates)
|
|
372
|
+
|
|
373
|
+
const nav = expectAgentDrivable(canvas, {
|
|
374
|
+
name: "Bay 3 states",
|
|
375
|
+
role: "navigation",
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const reports = canvas.getByText("Reports")
|
|
379
|
+
|
|
380
|
+
await expect(reports).toHaveAttribute(
|
|
381
|
+
"aria-disabled",
|
|
382
|
+
"true",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
await expect(reports.closest("a")).toBeNull()
|
|
386
|
+
|
|
387
|
+
const handbook = expectAgentDrivable(canvas, {
|
|
388
|
+
name: /Handbook/u,
|
|
389
|
+
role: "link",
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
await expect(handbook).toHaveAttribute("target", "_blank")
|
|
393
|
+
|
|
394
|
+
await expect(handbook).toHaveAttribute(
|
|
395
|
+
"rel",
|
|
396
|
+
"noopener noreferrer",
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
// Never current: no address inside this app can be a place
|
|
400
|
+
// outside it, and a plain string compare would match one.
|
|
401
|
+
await expect(handbook).not.toHaveAttribute("aria-current")
|
|
402
|
+
|
|
403
|
+
// `activeHref` points at `/overview`, which no tab owns — so the
|
|
404
|
+
// bar honestly marks nothing rather than guessing the closest.
|
|
405
|
+
await expect(
|
|
406
|
+
nav.querySelectorAll('[aria-current="page"]'),
|
|
407
|
+
).toHaveLength(0)
|
|
408
|
+
|
|
409
|
+
await expectNoAxeViolations(canvasElement)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The reason both modes live in one component: a routed tab and a
|
|
414
|
+
* panel tab are one shape doing one job, and Docket shipped them on
|
|
415
|
+
* adjacent screens wearing different paint because the routed one
|
|
416
|
+
* had to be built out of `Nav`.
|
|
417
|
+
*/
|
|
418
|
+
test("a routed tab and a panel tab are painted identically", async () => {
|
|
419
|
+
const { canvas } = await mountStory(RoutedAllVariants)
|
|
420
|
+
|
|
421
|
+
const routedCurrent = expectAgentDrivable(canvas, {
|
|
422
|
+
name: "Routed horizontal",
|
|
423
|
+
role: "navigation",
|
|
424
|
+
}).querySelector('[aria-current="page"]')
|
|
425
|
+
|
|
426
|
+
const panelSelected = expectAgentDrivable(canvas, {
|
|
427
|
+
name: "Panel horizontal",
|
|
428
|
+
role: "tablist",
|
|
429
|
+
}).querySelector('[aria-selected="true"]')
|
|
430
|
+
|
|
431
|
+
const routedStyle = globalThis.getComputedStyle(
|
|
432
|
+
routedCurrent as HTMLElement,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
const panelStyle = globalThis.getComputedStyle(
|
|
436
|
+
panelSelected as HTMLElement,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
for (const property of [
|
|
440
|
+
"borderBottomColor",
|
|
441
|
+
"borderBottomWidth",
|
|
442
|
+
"color",
|
|
443
|
+
"fontSize",
|
|
444
|
+
"fontWeight",
|
|
445
|
+
"paddingInlineStart",
|
|
446
|
+
"paddingBlockStart",
|
|
447
|
+
] as const) {
|
|
448
|
+
await expect(routedStyle[property]).toBe(
|
|
449
|
+
panelStyle[property],
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
})
|