@charcuterie/ui 3.30.0 → 3.32.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 +14 -0
- package/dist/ActionTiles/ActionTiles.d.ts +115 -0
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -0
- package/dist/ActionTiles/ActionTiles.js +115 -0
- package/dist/ActionTiles/ActionTiles.js.map +1 -0
- package/dist/RadioGroup/RadioGroup.d.ts +10 -0
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +12 -39
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +3 -33
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- 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 +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tileStyles.d.ts +83 -0
- package/dist/tileStyles.d.ts.map +1 -0
- package/dist/tileStyles.js +98 -0
- package/dist/tileStyles.js.map +1 -0
- package/package.json +1 -1
- package/src/ActionTiles/ActionTiles.mdx +131 -0
- package/src/ActionTiles/ActionTiles.stories.tsx +311 -0
- package/src/ActionTiles/ActionTiles.test.tsx +292 -0
- package/src/ActionTiles/ActionTiles.tsx +292 -0
- package/src/RadioGroup/RadioGroup.mdx +7 -0
- package/src/RadioGroup/RadioGroup.tsx +16 -42
- package/src/RadioGroup/RadioGroupOption.tsx +8 -38
- 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 +13 -3
- package/src/sourceRules.test.ts +9 -1
- package/src/tileStyles.ts +118 -0
package/src/Tabs/Tabs.tsx
CHANGED
|
@@ -1,33 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
selectTabIndex,
|
|
3
|
-
useRovingFocus,
|
|
4
|
-
useSinglePicker,
|
|
5
|
-
useUniqueId,
|
|
6
|
-
} from "@charcuterie/logic"
|
|
7
1
|
import type { ReactNode } from "react"
|
|
8
|
-
import { useCallback, useEffect, useRef } from "react"
|
|
9
2
|
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
import { PanelTabs } from "./PanelTabs.tsx"
|
|
4
|
+
import { RoutedTabs } from "./RoutedTabs.tsx"
|
|
5
|
+
import type {
|
|
6
|
+
TabItem,
|
|
7
|
+
TabLinkItem,
|
|
8
|
+
TabsActivation,
|
|
9
|
+
TabsOrientation,
|
|
10
|
+
} from "./tabItems.ts"
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* without any command in `RovingFocus` having to know what
|
|
23
|
-
* "disabled" means.
|
|
24
|
-
*/
|
|
25
|
-
isDisabled?: boolean
|
|
26
|
-
key: string
|
|
27
|
-
label: ReactNode
|
|
12
|
+
type TabsCommonProps = {
|
|
13
|
+
className?: string
|
|
14
|
+
/** The tab bar's accessible name. Required. */
|
|
15
|
+
label: string
|
|
16
|
+
orientation?: TabsOrientation
|
|
28
17
|
}
|
|
29
18
|
|
|
30
|
-
export type
|
|
19
|
+
export type TabsPanelProps = TabsCommonProps & {
|
|
31
20
|
/** **Initial** only. Charcuterie owns it from then on. */
|
|
32
21
|
activeKey?: string
|
|
33
22
|
/**
|
|
@@ -42,292 +31,101 @@ export type TabsProps = {
|
|
|
42
31
|
* composed rather than one kind doing both jobs badly.
|
|
43
32
|
*/
|
|
44
33
|
activation?: TabsActivation
|
|
45
|
-
className?: string
|
|
46
|
-
/** The tablist's accessible name. Required. */
|
|
47
|
-
label: string
|
|
48
34
|
onChange?: (activeKey: string | null) => void
|
|
49
|
-
|
|
50
|
-
tabs: TabItem[]
|
|
35
|
+
tabs: readonly TabItem[]
|
|
51
36
|
}
|
|
52
37
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
38
|
+
export type TabsLinkProps = TabsCommonProps & {
|
|
39
|
+
/**
|
|
40
|
+
* The address the app is at — usually `location.pathname`. The tab
|
|
41
|
+
* whose `href` matches wears `aria-current="page"`.
|
|
42
|
+
*
|
|
43
|
+
* **Required, and it is the discriminant.** `Nav` leaves the
|
|
44
|
+
* equivalent optional because a top-level bar is honest about
|
|
45
|
+
* naming nothing on a screen it does not contain. A routed tab bar
|
|
46
|
+
* is drawn *inside* the section it divides, so "none of these" is
|
|
47
|
+
* not a state it has; an app that wants it passes a path that
|
|
48
|
+
* matches no tab.
|
|
49
|
+
*/
|
|
50
|
+
activeHref: string
|
|
51
|
+
tabs: readonly TabLinkItem[]
|
|
62
52
|
}
|
|
63
53
|
|
|
64
54
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
55
|
+
* The union splits on `activeHref`, which only the routed member
|
|
56
|
+
* has. `tabs` alone could not carry the split — TypeScript does not
|
|
57
|
+
* narrow a union by the shape of an array's elements, so a mixed bar
|
|
58
|
+
* would type-check and then render half a navigation.
|
|
59
|
+
*/
|
|
60
|
+
export type TabsProps = TabsLinkProps | TabsPanelProps
|
|
61
|
+
|
|
62
|
+
const getIsRoutedProps = (
|
|
63
|
+
props: TabsProps,
|
|
64
|
+
): props is TabsLinkProps => "activeHref" in props
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One choice out of a handful, all of them on screen at once — and
|
|
68
|
+
* the choice is either a **panel** this component reveals or a
|
|
69
|
+
* **place** the router goes to.
|
|
74
70
|
*
|
|
75
|
-
* ```
|
|
76
|
-
*
|
|
77
|
-
*
|
|
71
|
+
* ```tsx
|
|
72
|
+
* // A section of this screen. Nothing outside knows about it.
|
|
73
|
+
* <Tabs label="Bay 3" tabs={[{ key: "log", label: "Log", content: … }]} />
|
|
78
74
|
*
|
|
79
|
-
* //
|
|
80
|
-
*
|
|
75
|
+
* // A section that has an address.
|
|
76
|
+
* <Tabs
|
|
77
|
+
* activeHref={pathname}
|
|
78
|
+
* label="Bay 3 sections"
|
|
79
|
+
* tabs={[{ href: "/projects/1/tasks", label: "Tasks" }]}
|
|
80
|
+
* />
|
|
81
|
+
* <Outlet />
|
|
81
82
|
* ```
|
|
82
83
|
*
|
|
83
|
-
*
|
|
84
|
+
* ## Which one, and the question is not "how does it look"
|
|
84
85
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
86
|
+
* **Does this section have an address?** If reloading the page, or
|
|
87
|
+
* opening it in a second tab, or pressing Back has to land the
|
|
88
|
+
* reader back on the same section, it is routed and every tab is an
|
|
89
|
+
* `<a href>`. If the section is a disclosure inside one screen —
|
|
90
|
+
* Settings' groups, a card's detail — it is a panel and every tab is
|
|
91
|
+
* a `<button>`.
|
|
91
92
|
*
|
|
92
|
-
*
|
|
93
|
-
* `
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* sibling `SegmentedControl` is exactly that consumer, and it now
|
|
98
|
-
* shares this component's core rather than hand-rolling a fourth
|
|
99
|
-
* one.
|
|
100
|
-
* [Decision](../../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
|
|
93
|
+
* The two are drawn identically on purpose, through
|
|
94
|
+
* `toTabTriggerClass`. Docket had one of each on adjacent screens
|
|
95
|
+
* wearing different paint, because the routed one had to be built
|
|
96
|
+
* out of `Nav` and `Nav`'s current item is a filled pill
|
|
97
|
+
* ([decision](../../../../docs/decisions/2026-08-31-a-routed-tab-is-a-tab-with-an-href.md)).
|
|
101
98
|
*
|
|
102
|
-
*
|
|
99
|
+
* ## `Nav` is still the app's destinations
|
|
103
100
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
101
|
+
* This is **sub**-navigation: the sections of the screen you are
|
|
102
|
+
* already on. The app's own destinations — the header row, the side
|
|
103
|
+
* rail, the narrow view's menu — are `Nav` + `useNavLayout`, and
|
|
104
|
+
* that has not moved
|
|
105
|
+
* ([decision](../../../../docs/decisions/2026-08-27-nav-is-the-one-navigation-component-and-navbar-is-deprecated.md)).
|
|
106
|
+
* A routed `Tabs` renders no rail, no fold and no menu, because a
|
|
107
|
+
* bar dividing one screen has nowhere to fold to.
|
|
111
108
|
*
|
|
112
|
-
*
|
|
109
|
+
* ## The routed bar is not a `tablist`, and that is deliberate
|
|
113
110
|
*
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
111
|
+
* `role="tab"` on an anchor overrides the link role, so a screen
|
|
112
|
+
* reader announces a disclosure and the address changes instead. See
|
|
113
|
+
* `TabLink`. The routed bar is a `<nav>` of links with
|
|
114
|
+
* `aria-current="page"`; the panel bar below keeps the full APG
|
|
115
|
+
* pattern, `aria-controls` and all.
|
|
116
|
+
*
|
|
117
|
+
* ## The panel bar: kept at P0 as the state layer's falsification test
|
|
118
|
+
*
|
|
119
|
+
* Not on duplication grounds — the fifteen ad-hoc tab bars that used
|
|
120
|
+
* to justify it were in the withdrawn evidence. What it has to prove
|
|
121
|
+
* is that two kinds compose: one says which tab is **chosen**, the
|
|
122
|
+
* other says which tab is **tabbable**, and a tab bar needs both at
|
|
123
|
+
* once *and* needs them to disagree on purpose while a user arrows
|
|
124
|
+
* around in `manual` mode. `PanelTabs` is where that lives.
|
|
119
125
|
*/
|
|
120
|
-
export const Tabs = (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
onChange,
|
|
126
|
-
orientation = "horizontal",
|
|
127
|
-
tabs,
|
|
128
|
-
}: TabsProps): ReactNode => {
|
|
129
|
-
const baseId = useUniqueId()
|
|
130
|
-
|
|
131
|
-
const tablistRef = useRef<HTMLDivElement>(null)
|
|
132
|
-
|
|
133
|
-
const tabElements = useRef(
|
|
134
|
-
new Map<string, HTMLButtonElement>(),
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
const initialKey =
|
|
138
|
-
activeKey ??
|
|
139
|
-
tabs.find((one) => !one.isDisabled)?.key ??
|
|
140
|
-
null
|
|
141
|
-
|
|
142
|
-
const selection = useSinglePicker<string>({
|
|
143
|
-
onChange,
|
|
144
|
-
selectedValue: initialKey,
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
const focus = useRovingFocus<string>({
|
|
148
|
-
activeValue: initialKey,
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
const { select } = selection
|
|
152
|
-
|
|
153
|
-
const { activeValue, setActiveValue } = focus
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Selected **or** pending, and the fallback is load-bearing.
|
|
157
|
-
*
|
|
158
|
-
* Members register from an effect, so on the first paint
|
|
159
|
-
* `selectedValue` is still null and the intent lives in
|
|
160
|
-
* `pendingValue` — a tab bar reading only the former renders with
|
|
161
|
-
* no tab selected and no panel shown, then corrects itself a
|
|
162
|
-
* frame later. It is a flash on a fast machine and a visible
|
|
163
|
-
* blank on a kiosk Pi.
|
|
164
|
-
*
|
|
165
|
-
* The core distinguishes the two on purpose, which is why this
|
|
166
|
-
* belongs here rather than in the selector — unlike
|
|
167
|
-
* `RovingFocus`, where the equivalent first-paint hole was in
|
|
168
|
-
* `selectTabIndex` itself and got fixed there.
|
|
169
|
-
*/
|
|
170
|
-
const shownKey =
|
|
171
|
-
selection.selectedValue ?? selection.pendingValue
|
|
172
|
-
|
|
173
|
-
const selectTab = useCallback(
|
|
174
|
-
(key: string) => {
|
|
175
|
-
// Both, because a pointer press moves focus *and* chooses.
|
|
176
|
-
// Only selecting would leave `automatic`'s effect below to
|
|
177
|
-
// put the old panel straight back.
|
|
178
|
-
setActiveValue(key)
|
|
179
|
-
|
|
180
|
-
select(key)
|
|
181
|
-
},
|
|
182
|
-
[select, setActiveValue],
|
|
126
|
+
export const Tabs = (props: TabsProps): ReactNode =>
|
|
127
|
+
getIsRoutedProps(props) ? (
|
|
128
|
+
<RoutedTabs {...props} />
|
|
129
|
+
) : (
|
|
130
|
+
<PanelTabs {...props} />
|
|
183
131
|
)
|
|
184
|
-
|
|
185
|
-
useEffect(() => {
|
|
186
|
-
// Automatic activation, and the only behaviour that differs
|
|
187
|
-
// between the two modes. `select` is idempotent, so this is a
|
|
188
|
-
// no-op on every render where focus did not actually move.
|
|
189
|
-
if (
|
|
190
|
-
activation === "automatic" &&
|
|
191
|
-
activeValue !== null
|
|
192
|
-
) {
|
|
193
|
-
select(activeValue)
|
|
194
|
-
}
|
|
195
|
-
}, [activation, activeValue, select])
|
|
196
|
-
|
|
197
|
-
useEffect(() => {
|
|
198
|
-
const tablist = tablistRef.current
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
!tablist ||
|
|
202
|
-
activeValue === null ||
|
|
203
|
-
!tablist.contains(document.activeElement)
|
|
204
|
-
) {
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
tabElements.current.get(activeValue)?.focus()
|
|
209
|
-
}, [activeValue])
|
|
210
|
-
|
|
211
|
-
const keys = ORIENTATION_KEYS[orientation]
|
|
212
|
-
|
|
213
|
-
return (
|
|
214
|
-
<div
|
|
215
|
-
className={toClassName(
|
|
216
|
-
"flex flex-col gap-3",
|
|
217
|
-
className,
|
|
218
|
-
)}
|
|
219
|
-
>
|
|
220
|
-
<div
|
|
221
|
-
aria-label={label}
|
|
222
|
-
aria-orientation={orientation}
|
|
223
|
-
className={toClassName(
|
|
224
|
-
"flex gap-1 border-border-subtle",
|
|
225
|
-
// A tab bar **scrolls**; it does not wrap and it does not
|
|
226
|
-
// paint outside its container. Without an `overflow`, a
|
|
227
|
-
// bar narrower than its tabs simply spilled across
|
|
228
|
-
// whatever sat beside it — visible in the `Responsive`
|
|
229
|
-
// board since M4, and invisible to a test asserting only
|
|
230
|
-
// that `scrollWidth > clientWidth`, which is true of a
|
|
231
|
-
// *spilling* bar too.
|
|
232
|
-
//
|
|
233
|
-
// The scrollbar is hidden rather than thin: a classic
|
|
234
|
-
// scrollbar takes layout space inside the scroll
|
|
235
|
-
// container, so the narrow bar would grow ~15px taller
|
|
236
|
-
// than the wide one and the underline would stop lining
|
|
237
|
-
// up across a board. Keyboard users never need it —
|
|
238
|
-
// `RovingFocus` calls `.focus()`, and the browser scrolls
|
|
239
|
-
// a focused tab into view.
|
|
240
|
-
"[scrollbar-width:none]",
|
|
241
|
-
// An `overflow` other than `visible` clips its
|
|
242
|
-
// descendants' **outlines**, and this component's focus
|
|
243
|
-
// ring is an outline sitting 2px *outside* the tab. The
|
|
244
|
-
// tab fills the bar's content box, so the ring would be
|
|
245
|
-
// clipped away on exactly the axis that now scrolls.
|
|
246
|
-
// Re-pointing the shared token inward keeps one ring
|
|
247
|
-
// definition — `FOCUS_RING_CLASS` already reads this
|
|
248
|
-
// variable — rather than appending a competing
|
|
249
|
-
// `outline-offset` utility and letting Tailwind's
|
|
250
|
-
// ordering decide the winner.
|
|
251
|
-
"[--focus-ring-offset:calc(var(--focus-ring-width)*-1)]",
|
|
252
|
-
orientation === "horizontal"
|
|
253
|
-
? "flex-row overflow-x-auto border-b"
|
|
254
|
-
: "flex-col items-stretch overflow-y-auto border-e",
|
|
255
|
-
)}
|
|
256
|
-
onKeyDown={(keyEvent) => {
|
|
257
|
-
const commands: Record<string, () => void> = {
|
|
258
|
-
[keys.next]: focus.next,
|
|
259
|
-
[keys.previous]: focus.previous,
|
|
260
|
-
End: focus.last,
|
|
261
|
-
Home: focus.first,
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const command = commands[keyEvent.key]
|
|
265
|
-
|
|
266
|
-
if (command) {
|
|
267
|
-
// Arrow keys scroll the page by default, and a tab bar
|
|
268
|
-
// that scrolls the window while it moves focus is the
|
|
269
|
-
// hand-rolled version of this component.
|
|
270
|
-
keyEvent.preventDefault()
|
|
271
|
-
|
|
272
|
-
command()
|
|
273
|
-
|
|
274
|
-
return
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// `manual`'s commit. In `automatic` the panel is showing
|
|
278
|
-
// already, so this is the no-op that makes Enter behave
|
|
279
|
-
// identically in both modes.
|
|
280
|
-
if (
|
|
281
|
-
(keyEvent.key === "Enter" ||
|
|
282
|
-
keyEvent.key === " ") &&
|
|
283
|
-
activeValue !== null
|
|
284
|
-
) {
|
|
285
|
-
keyEvent.preventDefault()
|
|
286
|
-
|
|
287
|
-
select(activeValue)
|
|
288
|
-
}
|
|
289
|
-
}}
|
|
290
|
-
ref={tablistRef}
|
|
291
|
-
role="tablist"
|
|
292
|
-
>
|
|
293
|
-
{tabs.map((tab, index) => (
|
|
294
|
-
<TabTrigger
|
|
295
|
-
id={`${baseId}-tab-${index}`}
|
|
296
|
-
isSelected={shownKey === tab.key}
|
|
297
|
-
key={tab.key}
|
|
298
|
-
onSelect={selectTab}
|
|
299
|
-
panelId={`${baseId}-panel-${index}`}
|
|
300
|
-
registerFocus={focus.register}
|
|
301
|
-
orientation={orientation}
|
|
302
|
-
registerSelection={selection.register}
|
|
303
|
-
tab={tab}
|
|
304
|
-
// The roving-tabindex rule read from the core rather
|
|
305
|
-
// than restated here: exactly one member is in the tab
|
|
306
|
-
// order, so Tab enters and leaves the bar once while
|
|
307
|
-
// the arrow keys move inside it.
|
|
308
|
-
tabIndex={selectTabIndex(focus, tab.key)}
|
|
309
|
-
trackElement={(key, element) => {
|
|
310
|
-
if (element) {
|
|
311
|
-
tabElements.current.set(key, element)
|
|
312
|
-
} else {
|
|
313
|
-
tabElements.current.delete(key)
|
|
314
|
-
}
|
|
315
|
-
}}
|
|
316
|
-
/>
|
|
317
|
-
))}
|
|
318
|
-
</div>
|
|
319
|
-
|
|
320
|
-
{tabs.map((tab, index) => (
|
|
321
|
-
<div
|
|
322
|
-
aria-labelledby={`${baseId}-tab-${index}`}
|
|
323
|
-
hidden={shownKey !== tab.key}
|
|
324
|
-
id={`${baseId}-panel-${index}`}
|
|
325
|
-
key={tab.key}
|
|
326
|
-
role="tabpanel"
|
|
327
|
-
>
|
|
328
|
-
{tab.content}
|
|
329
|
-
</div>
|
|
330
|
-
))}
|
|
331
|
-
</div>
|
|
332
|
-
)
|
|
333
|
-
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import type { NavItem } from "../Nav/navItems.ts"
|
|
4
|
+
|
|
5
|
+
export type TabsActivation = "automatic" | "manual"
|
|
6
|
+
|
|
7
|
+
export type TabsOrientation = "horizontal" | "vertical"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A tab that reveals a panel this component renders. The section
|
|
11
|
+
* lives in memory, so leaving the screen and coming back starts over
|
|
12
|
+
* — which is correct for a disclosure and wrong for a place.
|
|
13
|
+
*/
|
|
14
|
+
export type TabItem = {
|
|
15
|
+
content: ReactNode
|
|
16
|
+
/**
|
|
17
|
+
* A disabled tab is simply **not registered** with the roving
|
|
18
|
+
* group. Registration is membership, so the arrow keys skip it
|
|
19
|
+
* without any command in `RovingFocus` having to know what
|
|
20
|
+
* "disabled" means.
|
|
21
|
+
*/
|
|
22
|
+
isDisabled?: boolean
|
|
23
|
+
key: string
|
|
24
|
+
label: ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A tab that is a **place** — it has an address, and choosing it
|
|
29
|
+
* changes the address.
|
|
30
|
+
*
|
|
31
|
+
* It is a `NavItem` with `isDisabled` added, and that is not
|
|
32
|
+
* convenience: it means `resolveActiveKey` decides which routed tab
|
|
33
|
+
* is current with the exact rule `Nav` uses, rather than a second
|
|
34
|
+
* implementation of segment matching that drifts from it. `label`
|
|
35
|
+
* narrows to `string` for the same reason it does on `NavItem` — it
|
|
36
|
+
* is the accessible name of a link.
|
|
37
|
+
*/
|
|
38
|
+
export type TabLinkItem = NavItem & {
|
|
39
|
+
isDisabled?: boolean
|
|
40
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
2
|
+
import { toClassName } from "../toClassName.ts"
|
|
3
|
+
import type { TabsOrientation } from "./tabItems.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The selected marker runs along the edge the tab list itself
|
|
7
|
+
* sits on — under a horizontal bar, down the inline-end of a
|
|
8
|
+
* vertical rail — and the negative margin pulls it over the
|
|
9
|
+
* list's own border so the two read as one line rather than two.
|
|
10
|
+
*
|
|
11
|
+
* Logical properties throughout, which is the house rule and also
|
|
12
|
+
* the only way a vertical rail lands on the correct side in RTL.
|
|
13
|
+
*/
|
|
14
|
+
const ORIENTATION_EDGE_CLASS: Record<
|
|
15
|
+
TabsOrientation,
|
|
16
|
+
{ base: string; selected: string }
|
|
17
|
+
> = {
|
|
18
|
+
horizontal: {
|
|
19
|
+
base: "-mb-px border-b-2",
|
|
20
|
+
selected: "border-b-intent-accent-solid",
|
|
21
|
+
},
|
|
22
|
+
vertical: {
|
|
23
|
+
base: "-me-px border-e-2 text-start",
|
|
24
|
+
selected: "border-e-intent-accent-solid",
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One tab's paint, shared by the `<button>` a panel tab renders and
|
|
30
|
+
* the `<a href>` a routed tab renders.
|
|
31
|
+
*
|
|
32
|
+
* **This file is the reason the two cannot drift.** Docket's project
|
|
33
|
+
* bar and Docket's Settings bar are the same shape doing the same
|
|
34
|
+
* job, and before the routed mode existed the first was a `Nav`
|
|
35
|
+
* wearing a filled pill while the second was a `Tabs` wearing this
|
|
36
|
+
* underline — two answers to one question, in one app, on adjacent
|
|
37
|
+
* screens
|
|
38
|
+
* ([decision](../../../../docs/decisions/2026-08-31-a-routed-tab-is-a-tab-with-an-href.md)).
|
|
39
|
+
*
|
|
40
|
+
* The caller adds the one class that is genuinely its own: a button
|
|
41
|
+
* needs `cursor-pointer`, an anchor needs `no-underline`.
|
|
42
|
+
*/
|
|
43
|
+
export const toTabTriggerClass = ({
|
|
44
|
+
isDisabled,
|
|
45
|
+
isSelected,
|
|
46
|
+
orientation,
|
|
47
|
+
}: {
|
|
48
|
+
isDisabled: boolean
|
|
49
|
+
isSelected: boolean
|
|
50
|
+
orientation: TabsOrientation
|
|
51
|
+
}): string => {
|
|
52
|
+
const edge = ORIENTATION_EDGE_CLASS[orientation]
|
|
53
|
+
|
|
54
|
+
return toClassName(
|
|
55
|
+
"border-transparent px-3 py-2 font-medium text-sm whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard",
|
|
56
|
+
edge.base,
|
|
57
|
+
// Two entries rather than one interpolated string:
|
|
58
|
+
// `tailwindCandidates.test.ts` rejects a template literal
|
|
59
|
+
// in a className outright, because Tailwind's scanner
|
|
60
|
+
// cannot see a class that only exists at runtime.
|
|
61
|
+
isSelected && edge.selected,
|
|
62
|
+
isSelected
|
|
63
|
+
? "text-content-primary"
|
|
64
|
+
: "text-content-secondary hover:text-content-primary",
|
|
65
|
+
isDisabled &&
|
|
66
|
+
"cursor-not-allowed text-content-disabled hover:text-content-disabled",
|
|
67
|
+
FOCUS_RING_CLASS,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The list's own box — the border the selected marker sits on, and
|
|
73
|
+
* the scroll rule.
|
|
74
|
+
*
|
|
75
|
+
* A tab bar **scrolls**; it does not wrap and it does not paint
|
|
76
|
+
* outside its container. Without an `overflow`, a bar narrower than
|
|
77
|
+
* its tabs simply spilled across whatever sat beside it — visible in
|
|
78
|
+
* the `Responsive` board since M4, and invisible to a test asserting
|
|
79
|
+
* only that `scrollWidth > clientWidth`, which is true of a
|
|
80
|
+
* *spilling* bar too.
|
|
81
|
+
*
|
|
82
|
+
* The scrollbar is hidden rather than thin: a classic scrollbar takes
|
|
83
|
+
* layout space inside the scroll container, so the narrow bar would
|
|
84
|
+
* grow ~15px taller than the wide one and the underline would stop
|
|
85
|
+
* lining up across a board. Keyboard users never need it — a panel
|
|
86
|
+
* bar's `RovingFocus` calls `.focus()`, a routed bar's links are in
|
|
87
|
+
* the tab order, and the browser scrolls a focused element into view.
|
|
88
|
+
*/
|
|
89
|
+
export const toTabListClass = (
|
|
90
|
+
orientation: TabsOrientation,
|
|
91
|
+
): string =>
|
|
92
|
+
toClassName(
|
|
93
|
+
"flex gap-1 border-border-subtle",
|
|
94
|
+
"[scrollbar-width:none]",
|
|
95
|
+
// An `overflow` other than `visible` clips its descendants'
|
|
96
|
+
// **outlines**, and this component's focus ring is an outline
|
|
97
|
+
// sitting 2px *outside* the tab. The tab fills the bar's content
|
|
98
|
+
// box, so the ring would be clipped away on exactly the axis that
|
|
99
|
+
// now scrolls. Re-pointing the shared token inward keeps one ring
|
|
100
|
+
// definition — `FOCUS_RING_CLASS` already reads this variable —
|
|
101
|
+
// rather than appending a competing `outline-offset` utility and
|
|
102
|
+
// letting Tailwind's ordering decide the winner.
|
|
103
|
+
"[--focus-ring-offset:calc(var(--focus-ring-width)*-1)]",
|
|
104
|
+
orientation === "horizontal"
|
|
105
|
+
? "flex-row overflow-x-auto border-b"
|
|
106
|
+
: "flex-col items-stretch overflow-y-auto border-e",
|
|
107
|
+
)
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,11 @@ export type {
|
|
|
37
37
|
AccordionProps,
|
|
38
38
|
} from "./Accordion/Accordion.tsx"
|
|
39
39
|
export { Accordion } from "./Accordion/Accordion.tsx"
|
|
40
|
+
export type {
|
|
41
|
+
ActionTileItem,
|
|
42
|
+
ActionTilesProps,
|
|
43
|
+
} from "./ActionTiles/ActionTiles.tsx"
|
|
44
|
+
export { ActionTiles } from "./ActionTiles/ActionTiles.tsx"
|
|
40
45
|
export type { AdaptiveGridProps } from "./AdaptiveGrid/AdaptiveGrid.tsx"
|
|
41
46
|
export { AdaptiveGrid } from "./AdaptiveGrid/AdaptiveGrid.tsx"
|
|
42
47
|
export type { ColumnChoice } from "./AdaptiveGrid/chooseColumns.ts"
|
|
@@ -427,12 +432,17 @@ export {
|
|
|
427
432
|
getIsConnectionBusy,
|
|
428
433
|
} from "./statusIntent.ts"
|
|
429
434
|
export type {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
TabsOrientation,
|
|
435
|
+
TabsLinkProps,
|
|
436
|
+
TabsPanelProps,
|
|
433
437
|
TabsProps,
|
|
434
438
|
} from "./Tabs/Tabs.tsx"
|
|
435
439
|
export { Tabs } from "./Tabs/Tabs.tsx"
|
|
440
|
+
export type {
|
|
441
|
+
TabItem,
|
|
442
|
+
TabLinkItem,
|
|
443
|
+
TabsActivation,
|
|
444
|
+
TabsOrientation,
|
|
445
|
+
} from "./Tabs/tabItems.ts"
|
|
436
446
|
export type {
|
|
437
447
|
TextLinkAppearance,
|
|
438
448
|
TextLinkProps,
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -537,7 +537,15 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
537
537
|
// active-path rule an app's tests want to assert directly, and
|
|
538
538
|
// `useNavLayout.ts`, because the app has to read the width rule to
|
|
539
539
|
// know which corner to render into.
|
|
540
|
-
|
|
540
|
+
//
|
|
541
|
+
// `ActionTiles` is +1 -> 62. It is `RadioGroup itemShape="tile"`
|
|
542
|
+
// with the radio taken away: the box is shared through
|
|
543
|
+
// `tileStyles.ts`, and nothing else is. The 2026-08-25 record that
|
|
544
|
+
// made the choice tile a `RadioGroup` shape named this case under
|
|
545
|
+
// what it deliberately does not cover — a tile that navigates is
|
|
546
|
+
// not a radio, and giving one `aria-checked` would be worse than
|
|
547
|
+
// the paint it replaced.
|
|
548
|
+
expect(componentNames.length).toBe(62)
|
|
541
549
|
|
|
542
550
|
for (const name of componentNames) {
|
|
543
551
|
expect(barrel).toContain(`export { ${name} }`)
|