@charcuterie/ui 3.29.1 → 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/Main/Main.d.ts +35 -1
- package/dist/Main/Main.d.ts.map +1 -1
- package/dist/Main/Main.js +38 -3
- package/dist/Main/Main.js.map +1 -1
- package/dist/Main/scrollMemory.d.ts +40 -0
- package/dist/Main/scrollMemory.d.ts.map +1 -0
- package/dist/Main/scrollMemory.js +63 -0
- package/dist/Main/scrollMemory.js.map +1 -0
- package/dist/Main/useScrollMemory.d.ts +29 -0
- package/dist/Main/useScrollMemory.d.ts.map +1 -0
- package/dist/Main/useScrollMemory.js +131 -0
- package/dist/Main/useScrollMemory.js.map +1 -0
- 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/Main/Main.mdx +53 -0
- package/src/Main/Main.stories.tsx +152 -0
- package/src/Main/Main.test.tsx +195 -4
- package/src/Main/Main.tsx +58 -1
- package/src/Main/scrollMemory.test.ts +59 -0
- package/src/Main/scrollMemory.ts +73 -0
- package/src/Main/useScrollMemory.ts +164 -0
- 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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
recallScrollOffset,
|
|
5
|
+
rememberScrollOffset,
|
|
6
|
+
} from "./scrollMemory.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How long a restore keeps trying while the content is still
|
|
10
|
+
* arriving.
|
|
11
|
+
*
|
|
12
|
+
* The offset cannot be applied until the scrollport is tall enough
|
|
13
|
+
* to hold it, and on a client-rendered page it is not: the commit
|
|
14
|
+
* that changes the route draws a skeleton, and the rows land a
|
|
15
|
+
* fetch later. So the restore re-applies as the content grows, and
|
|
16
|
+
* gives up after this. A page that takes longer than three seconds
|
|
17
|
+
* to fill has already been read from the top, and moving it then
|
|
18
|
+
* is a worse answer than leaving it where the reader put it.
|
|
19
|
+
*/
|
|
20
|
+
const RESTORE_WINDOW_MS = 3000
|
|
21
|
+
|
|
22
|
+
/** A scroll the reader asked for. It ends the restore at once. */
|
|
23
|
+
const READER_INTENT_EVENTS = [
|
|
24
|
+
"keydown",
|
|
25
|
+
"pointerdown",
|
|
26
|
+
"touchstart",
|
|
27
|
+
"wheel",
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remember where this history entry was scrolled to, and put it
|
|
32
|
+
* back when the reader returns to it.
|
|
33
|
+
*
|
|
34
|
+
* `scrollMemory.ts` says why the browser will not. This is the
|
|
35
|
+
* part that has to survive the two things a real page does:
|
|
36
|
+
*
|
|
37
|
+
* 1. **The content arrives late.** An offset applied against a
|
|
38
|
+
* 600px skeleton clamps to `0` and looks like it did nothing.
|
|
39
|
+
* A `ResizeObserver` on the scrollport and on its content
|
|
40
|
+
* column re-applies the offset every time either grows, until
|
|
41
|
+
* the offset lands or the window closes.
|
|
42
|
+
* 2. **The reader scrolls during all that.** Any wheel, touch,
|
|
43
|
+
* key or pointer press ends the restore immediately. The
|
|
44
|
+
* alternative is a page that jumps out from under somebody who
|
|
45
|
+
* has already started reading.
|
|
46
|
+
*
|
|
47
|
+
* The save is a passive `scroll` listener, suppressed while a
|
|
48
|
+
* restore is in flight. Without that suppression the feature eats
|
|
49
|
+
* itself: writing `scrollTop` on a still-short page clamps to `0`
|
|
50
|
+
* and fires a `scroll` event reading `0`, which would be saved
|
|
51
|
+
* over the very offset being restored.
|
|
52
|
+
*
|
|
53
|
+
* @param scrollKey Identifies the history entry — react-router's
|
|
54
|
+
* `useLocation().key`. `undefined` turns the memory off, which is
|
|
55
|
+
* what an app with no router gets.
|
|
56
|
+
*/
|
|
57
|
+
export const useScrollMemory = (
|
|
58
|
+
scrollKey: string | undefined,
|
|
59
|
+
) => {
|
|
60
|
+
const elementRef = useRef<HTMLElement | null>(null)
|
|
61
|
+
const isRestoringRef = useRef(false)
|
|
62
|
+
|
|
63
|
+
// A ref rather than a query for the `<main>` id: the effect
|
|
64
|
+
// below runs in the same commit that mounts the node, and only a
|
|
65
|
+
// ref can say which node that was.
|
|
66
|
+
//
|
|
67
|
+
// `unknown` in, narrowed here, so the returned function is both a
|
|
68
|
+
// `RefCallback<HTMLElement>` and something `mergeRefs` accepts —
|
|
69
|
+
// that library's merge is typed for the prop-merging path, where
|
|
70
|
+
// every value is `unknown`.
|
|
71
|
+
const setElement = useCallback((node: unknown) => {
|
|
72
|
+
elementRef.current =
|
|
73
|
+
node instanceof HTMLElement ? node : null
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
const element = elementRef.current
|
|
78
|
+
|
|
79
|
+
if (!element || scrollKey === undefined) {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const offset = recallScrollOffset(scrollKey)
|
|
84
|
+
|
|
85
|
+
// The arrow defers the read of `applyOffset`, so the three
|
|
86
|
+
// pieces can be declared in the order they depend on each
|
|
87
|
+
// other rather than in the order they are used.
|
|
88
|
+
const observer = new ResizeObserver(() => {
|
|
89
|
+
applyOffset()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const deadline = globalThis.setTimeout(() => {
|
|
93
|
+
stopRestoring()
|
|
94
|
+
}, RESTORE_WINDOW_MS)
|
|
95
|
+
|
|
96
|
+
const stopRestoring = () => {
|
|
97
|
+
if (!isRestoringRef.current) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
isRestoringRef.current = false
|
|
102
|
+
observer.disconnect()
|
|
103
|
+
globalThis.clearTimeout(deadline)
|
|
104
|
+
|
|
105
|
+
for (const eventName of READER_INTENT_EVENTS) {
|
|
106
|
+
element.removeEventListener(
|
|
107
|
+
eventName,
|
|
108
|
+
stopRestoring,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const applyOffset = () => {
|
|
114
|
+
if (!isRestoringRef.current) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
element.scrollTop = offset
|
|
119
|
+
|
|
120
|
+
// Landed. Anything further would only fight the reader.
|
|
121
|
+
if (element.scrollTop === offset) {
|
|
122
|
+
stopRestoring()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const onScroll = () => {
|
|
127
|
+
if (isRestoringRef.current) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
rememberScrollOffset(scrollKey, element.scrollTop)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
isRestoringRef.current = true
|
|
135
|
+
|
|
136
|
+
for (const eventName of READER_INTENT_EVENTS) {
|
|
137
|
+
element.addEventListener(eventName, stopRestoring, {
|
|
138
|
+
passive: true,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The scrollport's own height changes with the window; its
|
|
143
|
+
// content column's changes when the rows land. Both move the
|
|
144
|
+
// ceiling the offset is clamped against.
|
|
145
|
+
observer.observe(element)
|
|
146
|
+
|
|
147
|
+
for (const child of Array.from(element.children)) {
|
|
148
|
+
observer.observe(child)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
element.addEventListener("scroll", onScroll, {
|
|
152
|
+
passive: true,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
applyOffset()
|
|
156
|
+
|
|
157
|
+
return () => {
|
|
158
|
+
stopRestoring()
|
|
159
|
+
element.removeEventListener("scroll", onScroll)
|
|
160
|
+
}
|
|
161
|
+
}, [scrollKey])
|
|
162
|
+
|
|
163
|
+
return setElement
|
|
164
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import {
|
|
2
|
+
selectTabIndex,
|
|
3
|
+
useRovingFocus,
|
|
4
|
+
useSinglePicker,
|
|
5
|
+
useUniqueId,
|
|
6
|
+
} from "@charcuterie/logic"
|
|
7
|
+
import type { ReactNode } from "react"
|
|
8
|
+
import { useCallback, useEffect, useRef } from "react"
|
|
9
|
+
|
|
10
|
+
import { toClassName } from "../toClassName.ts"
|
|
11
|
+
import type { TabsPanelProps } from "./Tabs.tsx"
|
|
12
|
+
import { TabTrigger } from "./TabTrigger.tsx"
|
|
13
|
+
import type { TabsOrientation } from "./tabItems.ts"
|
|
14
|
+
import { toTabListClass } from "./tabStyles.ts"
|
|
15
|
+
|
|
16
|
+
const ORIENTATION_KEYS: Record<
|
|
17
|
+
TabsOrientation,
|
|
18
|
+
{ next: string; previous: string }
|
|
19
|
+
> = {
|
|
20
|
+
horizontal: {
|
|
21
|
+
next: "ArrowRight",
|
|
22
|
+
previous: "ArrowLeft",
|
|
23
|
+
},
|
|
24
|
+
vertical: { next: "ArrowDown", previous: "ArrowUp" },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The panel bar — every tab a `<button>`, every section a
|
|
29
|
+
* `role="tabpanel"` this component renders and hides. See `Tabs`
|
|
30
|
+
* for which of the two bars a screen wants.
|
|
31
|
+
*
|
|
32
|
+
* The composition is two hooks and one decision:
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* const selection = useSinglePicker({ selectedValue: initialKey })
|
|
36
|
+
* const focus = useRovingFocus({ activeValue: initialKey })
|
|
37
|
+
*
|
|
38
|
+
* // `automatic` is this line, and `manual` is its absence.
|
|
39
|
+
* if (activation === "automatic") selection.select(focus.activeValue)
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* ### Selection, not visibility — and the difference is not cosmetic
|
|
43
|
+
*
|
|
44
|
+
* M4 built this on `VisibilityGroup`, and the panels do end up
|
|
45
|
+
* one-at-a-time either way, because the two cores are the same
|
|
46
|
+
* shape. But the thing that **registers** here is the tab, not the
|
|
47
|
+
* panel, and what a tab bar is asking is *which one did you pick*.
|
|
48
|
+
* `SinglePicker` is the kind that answers that; the panel's
|
|
49
|
+
* `hidden` is derived from the answer.
|
|
50
|
+
*
|
|
51
|
+
* Reading it the other way round cost something concrete: it made
|
|
52
|
+
* `aria-selected` a report about a *panel's* visibility, so a
|
|
53
|
+
* consumer wanting a tab bar with no panels at all — a segmented
|
|
54
|
+
* filter, a view switcher that swaps a route — had to model its
|
|
55
|
+
* choice as a group of visibilities it did not have. `Alert`'s
|
|
56
|
+
* sibling `SegmentedControl` is exactly that consumer, and it now
|
|
57
|
+
* shares this component's core rather than hand-rolling a fourth
|
|
58
|
+
* one.
|
|
59
|
+
* [Decision](../../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
|
|
60
|
+
*
|
|
61
|
+
* ### What it does not use, and why that is the right answer
|
|
62
|
+
*
|
|
63
|
+
* The plan expected `createLinkedIds` here as well. It does not
|
|
64
|
+
* fit, and forcing it would have been the wrong call: that kind
|
|
65
|
+
* exists for a **dynamic** trigger↔target pair — a `Popover` whose
|
|
66
|
+
* panel mounts and unmounts, where the multiset is what stops
|
|
67
|
+
* `aria-controls` pointing at a node React has already removed. A
|
|
68
|
+
* tab bar's pairing is static and known at render, so the ids come
|
|
69
|
+
* from one `useUniqueId` and cannot get out of step at all.
|
|
70
|
+
*
|
|
71
|
+
* ### Focus is moved deliberately, and only then
|
|
72
|
+
*
|
|
73
|
+
* `RovingFocus` is state; something still has to call `.focus()`.
|
|
74
|
+
* That happens only while the tablist already contains the focused
|
|
75
|
+
* element — otherwise mounting a tab bar, or any parent re-render
|
|
76
|
+
* that moved `activeValue`, yanks the caret out of whatever the
|
|
77
|
+
* user was actually doing.
|
|
78
|
+
*/
|
|
79
|
+
export const PanelTabs = ({
|
|
80
|
+
activation = "automatic",
|
|
81
|
+
activeKey,
|
|
82
|
+
className,
|
|
83
|
+
label,
|
|
84
|
+
onChange,
|
|
85
|
+
orientation = "horizontal",
|
|
86
|
+
tabs,
|
|
87
|
+
}: TabsPanelProps): ReactNode => {
|
|
88
|
+
const baseId = useUniqueId()
|
|
89
|
+
|
|
90
|
+
const tablistRef = useRef<HTMLDivElement>(null)
|
|
91
|
+
|
|
92
|
+
const tabElements = useRef(
|
|
93
|
+
new Map<string, HTMLButtonElement>(),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const initialKey =
|
|
97
|
+
activeKey ??
|
|
98
|
+
tabs.find((one) => !one.isDisabled)?.key ??
|
|
99
|
+
null
|
|
100
|
+
|
|
101
|
+
const selection = useSinglePicker<string>({
|
|
102
|
+
onChange,
|
|
103
|
+
selectedValue: initialKey,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const focus = useRovingFocus<string>({
|
|
107
|
+
activeValue: initialKey,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const { select } = selection
|
|
111
|
+
|
|
112
|
+
const { activeValue, setActiveValue } = focus
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Selected **or** pending, and the fallback is load-bearing.
|
|
116
|
+
*
|
|
117
|
+
* Members register from an effect, so on the first paint
|
|
118
|
+
* `selectedValue` is still null and the intent lives in
|
|
119
|
+
* `pendingValue` — a tab bar reading only the former renders with
|
|
120
|
+
* no tab selected and no panel shown, then corrects itself a
|
|
121
|
+
* frame later. It is a flash on a fast machine and a visible
|
|
122
|
+
* blank on a kiosk Pi.
|
|
123
|
+
*
|
|
124
|
+
* The core distinguishes the two on purpose, which is why this
|
|
125
|
+
* belongs here rather than in the selector — unlike
|
|
126
|
+
* `RovingFocus`, where the equivalent first-paint hole was in
|
|
127
|
+
* `selectTabIndex` itself and got fixed there.
|
|
128
|
+
*/
|
|
129
|
+
const shownKey =
|
|
130
|
+
selection.selectedValue ?? selection.pendingValue
|
|
131
|
+
|
|
132
|
+
const selectTab = useCallback(
|
|
133
|
+
(key: string) => {
|
|
134
|
+
// Both, because a pointer press moves focus *and* chooses.
|
|
135
|
+
// Only selecting would leave `automatic`'s effect below to
|
|
136
|
+
// put the old panel straight back.
|
|
137
|
+
setActiveValue(key)
|
|
138
|
+
|
|
139
|
+
select(key)
|
|
140
|
+
},
|
|
141
|
+
[select, setActiveValue],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
// Automatic activation, and the only behaviour that differs
|
|
146
|
+
// between the two modes. `select` is idempotent, so this is a
|
|
147
|
+
// no-op on every render where focus did not actually move.
|
|
148
|
+
if (
|
|
149
|
+
activation === "automatic" &&
|
|
150
|
+
activeValue !== null
|
|
151
|
+
) {
|
|
152
|
+
select(activeValue)
|
|
153
|
+
}
|
|
154
|
+
}, [activation, activeValue, select])
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const tablist = tablistRef.current
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
!tablist ||
|
|
161
|
+
activeValue === null ||
|
|
162
|
+
!tablist.contains(document.activeElement)
|
|
163
|
+
) {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
tabElements.current.get(activeValue)?.focus()
|
|
168
|
+
}, [activeValue])
|
|
169
|
+
|
|
170
|
+
const keys = ORIENTATION_KEYS[orientation]
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div
|
|
174
|
+
className={toClassName(
|
|
175
|
+
"flex flex-col gap-3",
|
|
176
|
+
className,
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<div
|
|
180
|
+
aria-label={label}
|
|
181
|
+
aria-orientation={orientation}
|
|
182
|
+
className={toTabListClass(orientation)}
|
|
183
|
+
onKeyDown={(keyEvent) => {
|
|
184
|
+
const commands: Record<string, () => void> = {
|
|
185
|
+
[keys.next]: focus.next,
|
|
186
|
+
[keys.previous]: focus.previous,
|
|
187
|
+
End: focus.last,
|
|
188
|
+
Home: focus.first,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const command = commands[keyEvent.key]
|
|
192
|
+
|
|
193
|
+
if (command) {
|
|
194
|
+
// Arrow keys scroll the page by default, and a tab bar
|
|
195
|
+
// that scrolls the window while it moves focus is the
|
|
196
|
+
// hand-rolled version of this component.
|
|
197
|
+
keyEvent.preventDefault()
|
|
198
|
+
|
|
199
|
+
command()
|
|
200
|
+
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// `manual`'s commit. In `automatic` the panel is showing
|
|
205
|
+
// already, so this is the no-op that makes Enter behave
|
|
206
|
+
// identically in both modes.
|
|
207
|
+
if (
|
|
208
|
+
(keyEvent.key === "Enter" ||
|
|
209
|
+
keyEvent.key === " ") &&
|
|
210
|
+
activeValue !== null
|
|
211
|
+
) {
|
|
212
|
+
keyEvent.preventDefault()
|
|
213
|
+
|
|
214
|
+
select(activeValue)
|
|
215
|
+
}
|
|
216
|
+
}}
|
|
217
|
+
ref={tablistRef}
|
|
218
|
+
role="tablist"
|
|
219
|
+
>
|
|
220
|
+
{tabs.map((tab, index) => (
|
|
221
|
+
<TabTrigger
|
|
222
|
+
id={`${baseId}-tab-${index}`}
|
|
223
|
+
isSelected={shownKey === tab.key}
|
|
224
|
+
key={tab.key}
|
|
225
|
+
onSelect={selectTab}
|
|
226
|
+
panelId={`${baseId}-panel-${index}`}
|
|
227
|
+
registerFocus={focus.register}
|
|
228
|
+
orientation={orientation}
|
|
229
|
+
registerSelection={selection.register}
|
|
230
|
+
tab={tab}
|
|
231
|
+
// The roving-tabindex rule read from the core rather
|
|
232
|
+
// than restated here: exactly one member is in the tab
|
|
233
|
+
// order, so Tab enters and leaves the bar once while
|
|
234
|
+
// the arrow keys move inside it.
|
|
235
|
+
tabIndex={selectTabIndex(focus, tab.key)}
|
|
236
|
+
trackElement={(key, element) => {
|
|
237
|
+
if (element) {
|
|
238
|
+
tabElements.current.set(key, element)
|
|
239
|
+
} else {
|
|
240
|
+
tabElements.current.delete(key)
|
|
241
|
+
}
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{tabs.map((tab, index) => (
|
|
248
|
+
<div
|
|
249
|
+
aria-labelledby={`${baseId}-tab-${index}`}
|
|
250
|
+
hidden={shownKey !== tab.key}
|
|
251
|
+
id={`${baseId}-panel-${index}`}
|
|
252
|
+
key={tab.key}
|
|
253
|
+
role="tabpanel"
|
|
254
|
+
>
|
|
255
|
+
{tab.content}
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getNavItemKey,
|
|
5
|
+
resolveActiveKey,
|
|
6
|
+
} from "../Nav/navItems.ts"
|
|
7
|
+
import { toClassName } from "../toClassName.ts"
|
|
8
|
+
import { TabLink } from "./TabLink.tsx"
|
|
9
|
+
import type { TabsLinkProps } from "./Tabs.tsx"
|
|
10
|
+
import { toTabListClass } from "./tabStyles.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The routed bar: a named `<nav>` of real links wearing the tab
|
|
14
|
+
* paint.
|
|
15
|
+
*
|
|
16
|
+
* There is **no state here at all**, and that is the difference that
|
|
17
|
+
* matters rather than a saving. A panel bar owns its choice because
|
|
18
|
+
* nothing else knows it; a routed bar's choice is the address, so
|
|
19
|
+
* owning a copy would give the app two answers that can disagree —
|
|
20
|
+
* the Back button being the first thing to prove it.
|
|
21
|
+
*
|
|
22
|
+
* `resolveActiveKey` is `Nav`'s rule, imported rather than restated:
|
|
23
|
+
* whole-segment matching, the deepest match wins, the root is exact,
|
|
24
|
+
* an external item is never current, and the query string is not
|
|
25
|
+
* part of the answer.
|
|
26
|
+
*/
|
|
27
|
+
export const RoutedTabs = ({
|
|
28
|
+
activeHref,
|
|
29
|
+
className,
|
|
30
|
+
label,
|
|
31
|
+
orientation = "horizontal",
|
|
32
|
+
tabs,
|
|
33
|
+
}: TabsLinkProps): ReactNode => {
|
|
34
|
+
const activeKey = resolveActiveKey(tabs, activeHref)
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<nav
|
|
38
|
+
aria-label={label}
|
|
39
|
+
className={toClassName(
|
|
40
|
+
toTabListClass(orientation),
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{tabs.map((tab) => {
|
|
45
|
+
const key = getNavItemKey(tab)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<TabLink
|
|
49
|
+
isCurrent={key === activeKey}
|
|
50
|
+
item={tab}
|
|
51
|
+
key={key}
|
|
52
|
+
orientation={orientation}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
})}
|
|
56
|
+
</nav>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
|
|
4
|
+
import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
|
|
7
|
+
import type {
|
|
8
|
+
TabLinkItem,
|
|
9
|
+
TabsOrientation,
|
|
10
|
+
} from "./tabItems.ts"
|
|
11
|
+
import { toTabTriggerClass } from "./tabStyles.ts"
|
|
12
|
+
|
|
13
|
+
export type TabLinkProps = {
|
|
14
|
+
isCurrent: boolean
|
|
15
|
+
item: TabLinkItem
|
|
16
|
+
orientation: TabsOrientation
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One routed tab — and it is a real `<a href>`, through the same
|
|
21
|
+
* `RouterLinkProvider` seam `NavLink` uses.
|
|
22
|
+
*
|
|
23
|
+
* ### It carries no `role="tab"`, and that is the whole point
|
|
24
|
+
*
|
|
25
|
+
* `role="tab"` on an anchor **overrides** the link role. A screen
|
|
26
|
+
* reader then announces "tab", the reader expects a panel to appear
|
|
27
|
+
* beside it, and instead the address changes and the page under it
|
|
28
|
+
* is replaced. The APG tab pattern describes a disclosure inside one
|
|
29
|
+
* document; it does not describe navigation, and `aria-controls`
|
|
30
|
+
* has nothing to point at here because the section's content comes
|
|
31
|
+
* from the router — an `<Outlet />` the app renders somewhere this
|
|
32
|
+
* component cannot see.
|
|
33
|
+
*
|
|
34
|
+
* So a routed bar is what it actually is: a list of links inside a
|
|
35
|
+
* named `<nav>`, with `aria-current="page"` on the one you are on.
|
|
36
|
+
* That is `Nav`'s answer, and reusing it rather than restating it is
|
|
37
|
+
* why `resolveActiveKey` decides the current item.
|
|
38
|
+
*
|
|
39
|
+
* What the tab pattern was *really* supplying here was the paint,
|
|
40
|
+
* and the paint is shared through `toTabTriggerClass` — so this
|
|
41
|
+
* looks identical to the `<button>` beside it in Storybook and is
|
|
42
|
+
* announced correctly.
|
|
43
|
+
*/
|
|
44
|
+
export const TabLink = ({
|
|
45
|
+
isCurrent,
|
|
46
|
+
item,
|
|
47
|
+
orientation,
|
|
48
|
+
}: TabLinkProps): ReactNode => {
|
|
49
|
+
const RouterLink = useRouterLink()
|
|
50
|
+
|
|
51
|
+
const { href, icon, isDisabled = false, label } = item
|
|
52
|
+
|
|
53
|
+
const isExternal = item.isExternal === true
|
|
54
|
+
|
|
55
|
+
const className = toTabTriggerClass({
|
|
56
|
+
isDisabled,
|
|
57
|
+
isSelected: isCurrent,
|
|
58
|
+
orientation,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const content = (
|
|
62
|
+
<>
|
|
63
|
+
{icon === undefined ? null : (
|
|
64
|
+
// Decoration beside the item's own text. An icon that
|
|
65
|
+
// announces itself turns "Settings" into "gear Settings".
|
|
66
|
+
<span
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
className="flex shrink-0 text-content-secondary"
|
|
69
|
+
>
|
|
70
|
+
{icon}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{label}
|
|
75
|
+
|
|
76
|
+
{isExternal ? (
|
|
77
|
+
<VisuallyHidden>
|
|
78
|
+
{" "}
|
|
79
|
+
(opens in a new tab)
|
|
80
|
+
</VisuallyHidden>
|
|
81
|
+
) : null}
|
|
82
|
+
</>
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if (isDisabled) {
|
|
86
|
+
// Not a link at all, rather than a link with a `disabled`
|
|
87
|
+
// attribute an anchor does not have. `aria-disabled` alone on a
|
|
88
|
+
// real `<a href>` still navigates on Enter, which is the version
|
|
89
|
+
// of this that looks right and is not.
|
|
90
|
+
return (
|
|
91
|
+
<span
|
|
92
|
+
aria-disabled="true"
|
|
93
|
+
className={toClassName(
|
|
94
|
+
"inline-flex shrink-0 items-center gap-2",
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
{content}
|
|
99
|
+
</span>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const LinkElement =
|
|
104
|
+
isExternal || !getIsRoutedHref(href) ? "a" : RouterLink
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<LinkElement
|
|
108
|
+
// The platform's own "you are here", and the reason the paint
|
|
109
|
+
// is never the only signal: an underline is invisible to a
|
|
110
|
+
// screen reader and to anyone who cannot tell the two colours
|
|
111
|
+
// apart.
|
|
112
|
+
aria-current={isCurrent ? "page" : undefined}
|
|
113
|
+
className={toClassName(
|
|
114
|
+
"inline-flex shrink-0 items-center gap-2 no-underline",
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
href={href}
|
|
118
|
+
// No `onClick`, deliberately — unlike `NavLink`, which has a
|
|
119
|
+
// panel to close behind the reader. A routed tab bar stays on
|
|
120
|
+
// screen across the navigation, so there is nothing to react
|
|
121
|
+
// to and a handler here would only be something to keep.
|
|
122
|
+
//
|
|
123
|
+
// `noreferrer` beside `noopener` on purpose: one closes the
|
|
124
|
+
// `window.opener` hole, the other stops this app's address
|
|
125
|
+
// leaking as the referrer to a product it does not own.
|
|
126
|
+
rel={isExternal ? "noopener noreferrer" : undefined}
|
|
127
|
+
target={isExternal ? "_blank" : undefined}
|
|
128
|
+
>
|
|
129
|
+
{content}
|
|
130
|
+
</LinkElement>
|
|
131
|
+
)
|
|
132
|
+
}
|
package/src/Tabs/TabTrigger.tsx
CHANGED
|
@@ -1,32 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from "react"
|
|
2
2
|
import { useEffect } from "react"
|
|
3
3
|
|
|
4
|
-
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
4
|
import { toClassName } from "../toClassName.ts"
|
|
6
|
-
import type {
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The selected marker runs along the edge the tab list itself
|
|
10
|
-
* sits on — under a horizontal bar, down the inline-end of a
|
|
11
|
-
* vertical rail — and the negative margin pulls it over the
|
|
12
|
-
* list's own border so the two read as one line rather than two.
|
|
13
|
-
*
|
|
14
|
-
* Logical properties throughout, which is the house rule and also
|
|
15
|
-
* the only way a vertical rail lands on the correct side in RTL.
|
|
16
|
-
*/
|
|
17
|
-
const ORIENTATION_EDGE_CLASS: Record<
|
|
5
|
+
import type {
|
|
6
|
+
TabItem,
|
|
18
7
|
TabsOrientation,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
horizontal: {
|
|
22
|
-
base: "-mb-px border-b-2",
|
|
23
|
-
selected: "border-b-intent-accent-solid",
|
|
24
|
-
},
|
|
25
|
-
vertical: {
|
|
26
|
-
base: "-me-px border-e-2 text-start",
|
|
27
|
-
selected: "border-e-intent-accent-solid",
|
|
28
|
-
},
|
|
29
|
-
}
|
|
8
|
+
} from "./tabItems.ts"
|
|
9
|
+
import { toTabTriggerClass } from "./tabStyles.ts"
|
|
30
10
|
|
|
31
11
|
export type TabTriggerProps = {
|
|
32
12
|
id: string
|
|
@@ -74,8 +54,6 @@ export const TabTrigger = ({
|
|
|
74
54
|
}: TabTriggerProps): ReactNode => {
|
|
75
55
|
const { isDisabled = false, key, label } = tab
|
|
76
56
|
|
|
77
|
-
const edge = ORIENTATION_EDGE_CLASS[orientation]
|
|
78
|
-
|
|
79
57
|
useEffect(
|
|
80
58
|
() => registerSelection(key),
|
|
81
59
|
[key, registerSelection],
|
|
@@ -94,19 +72,12 @@ export const TabTrigger = ({
|
|
|
94
72
|
aria-controls={panelId}
|
|
95
73
|
aria-selected={isSelected}
|
|
96
74
|
className={toClassName(
|
|
97
|
-
"cursor-pointer
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
isSelected && edge.selected,
|
|
104
|
-
isSelected
|
|
105
|
-
? "text-content-primary"
|
|
106
|
-
: "text-content-secondary hover:text-content-primary",
|
|
107
|
-
isDisabled &&
|
|
108
|
-
"cursor-not-allowed text-content-disabled hover:text-content-disabled",
|
|
109
|
-
FOCUS_RING_CLASS,
|
|
75
|
+
"cursor-pointer",
|
|
76
|
+
toTabTriggerClass({
|
|
77
|
+
isDisabled,
|
|
78
|
+
isSelected,
|
|
79
|
+
orientation,
|
|
80
|
+
}),
|
|
110
81
|
)}
|
|
111
82
|
disabled={isDisabled}
|
|
112
83
|
id={id}
|