@lovett/ui 0.0.11 → 0.2.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/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
package/src/tabs.tsx
CHANGED
|
@@ -9,11 +9,99 @@
|
|
|
9
9
|
* section scroll-spy + section-nav-as-tabs on profile pages. <Tabs>
|
|
10
10
|
* is the simpler horizontal-button-row for top-of-content tab navigation.
|
|
11
11
|
*
|
|
12
|
+
* ## Three variants, and when the third one is the right answer
|
|
13
|
+
*
|
|
14
|
+
* `underline` (default) and `pill` are top-of-content navigation: the strip
|
|
15
|
+
* sits above a page's worth of content and its width is whatever the label
|
|
16
|
+
* run comes to. `segmented` is for a tab strip that has to hold a COLUMN —
|
|
17
|
+
* a boxed track with equal-width segments that fills its container, so it
|
|
18
|
+
* reads as one control rather than a run of words drifting left. It is the
|
|
19
|
+
* same box as `SegmentedChoice` (inset track, opaque raised active segment,
|
|
20
|
+
* accent border, `--shadow-sm`, concentric `--radius-md` outer /
|
|
21
|
+
* `--radius-sm` inner) and the same 36px overall height, so a segmented
|
|
22
|
+
* strip in one pane sits flush with a search + filter row in the next.
|
|
23
|
+
*
|
|
24
|
+
* `SegmentedChoice` is NOT interchangeable with it: that primitive is
|
|
25
|
+
* `role="group"` + `aria-pressed`, which is correct for a filter and wrong
|
|
26
|
+
* for a real tablist. When the segments switch panels, they are tabs, and
|
|
27
|
+
* they belong here where the APG keyboard model already lives.
|
|
28
|
+
*
|
|
29
|
+
* The active segment is an opaque step UP onto `--surface-card`, per
|
|
30
|
+
* CLAUDE.md §2 — never an alpha tint on a translucent track, which
|
|
31
|
+
* composites to a raised element darker than the well it sits in.
|
|
32
|
+
*
|
|
12
33
|
* Token discipline: all colors via public tokens. Active tab uses the
|
|
13
34
|
* accent token; inactive uses text-tertiary.
|
|
35
|
+
*
|
|
36
|
+
* ## Keyboard (WAI-ARIA APG "Tabs")
|
|
37
|
+
*
|
|
38
|
+
* `role="tablist"` is a promise about keyboard behaviour, and until this it
|
|
39
|
+
* was not kept: every tab was a separate tab stop, and the arrow keys did
|
|
40
|
+
* nothing. The pattern is implemented here in full —
|
|
41
|
+
*
|
|
42
|
+
* - **Roving tabindex.** Exactly one tab is in the document's tab ring
|
|
43
|
+
* (`tabIndex={0}`); the rest are `-1`. Tab enters the tablist once and
|
|
44
|
+
* leaves it once, which is the whole point of the role. Disabled tabs are
|
|
45
|
+
* never the roving one, and if the active tab is disabled (or is not in
|
|
46
|
+
* `tabs` at all) the first enabled tab holds the stop rather than nothing
|
|
47
|
+
* holding it.
|
|
48
|
+
* - **ArrowLeft / ArrowRight move and WRAP**; **Home / End** jump to the
|
|
49
|
+
* first / last enabled tab.
|
|
50
|
+
* - **AUTOMATIC activation** — moving selects. Chosen deliberately: every
|
|
51
|
+
* consumer in this workspace switches a panel that is already rendered
|
|
52
|
+
* client-side, so selection is free and manual activation (move with
|
|
53
|
+
* arrows, commit with Enter/Space) would just be a second keystroke for
|
|
54
|
+
* no benefit. Manual activation is for tabs whose panels cost a fetch.
|
|
55
|
+
*
|
|
56
|
+
* LTR only. Nothing in this file reads `dir` or uses logical properties, so
|
|
57
|
+
* ArrowRight means "next" — mirroring it here would be half of an RTL story
|
|
58
|
+
* the rest of the component does not tell.
|
|
59
|
+
*
|
|
60
|
+
* ## Panels, ids, and the `aria-controls` promise
|
|
61
|
+
*
|
|
62
|
+
* `<Tabs>` renders the tab STRIP and nothing else — every consumer in the
|
|
63
|
+
* workspace renders its own panel. So `aria-controls` is opt-in via
|
|
64
|
+
* `Tab.panelId`, and is emitted only when the consumer says which element it
|
|
65
|
+
* points at. Emitting a generated `aria-controls` that resolves to nothing is
|
|
66
|
+
* a broken promise to assistive tech, not a partial credit (it is exactly the
|
|
67
|
+
* defect `SegmentedPill` was carrying).
|
|
68
|
+
*
|
|
69
|
+
* The other direction is exposed rather than generated: each tab's `id` is
|
|
70
|
+
* `<idPrefix>-tab-<tab.id>`, and `idPrefix` is a prop, so a consumer can
|
|
71
|
+
* point its panel's `aria-labelledby` at a tab it knows the id of. The panel
|
|
72
|
+
* itself is the consumer's to render, with `role="tabpanel"`,
|
|
73
|
+
* `aria-labelledby` back at the tab, and `tabIndex={0}` so a keyboard user
|
|
74
|
+
* can reach panel content that holds no focusable of its own.
|
|
14
75
|
*/
|
|
15
76
|
|
|
16
|
-
import type
|
|
77
|
+
import { useId, useRef, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
|
78
|
+
|
|
79
|
+
type TabsVariant = 'underline' | 'pill' | 'segmented'
|
|
80
|
+
|
|
81
|
+
/** The track the segments sit in, per variant. */
|
|
82
|
+
const TRACK_CLASS: Readonly<Record<TabsVariant, (stretch: boolean) => string>> = {
|
|
83
|
+
underline: (stretch) =>
|
|
84
|
+
`${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 border-b`,
|
|
85
|
+
pill: (stretch) => `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 p-1`,
|
|
86
|
+
segmented: (stretch) =>
|
|
87
|
+
`${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-[var(--space-1)] border p-[var(--space-1)]`,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const TRACK_STYLE: Readonly<Record<TabsVariant, CSSProperties>> = {
|
|
91
|
+
underline: { borderColor: 'rgb(var(--border))' },
|
|
92
|
+
pill: {
|
|
93
|
+
background: 'rgb(var(--surface-overlay-soft))',
|
|
94
|
+
border: '1px solid rgb(var(--border))',
|
|
95
|
+
borderRadius: 'var(--radius-full)',
|
|
96
|
+
},
|
|
97
|
+
// Outer --radius-md (12) minus the 4px of track padding leaves --radius-sm
|
|
98
|
+
// (8) for the segment: concentric, per the radius rule.
|
|
99
|
+
segmented: {
|
|
100
|
+
background: 'rgb(var(--surface-inset))',
|
|
101
|
+
borderColor: 'rgb(var(--border))',
|
|
102
|
+
borderRadius: 'var(--radius-md)',
|
|
103
|
+
},
|
|
104
|
+
}
|
|
17
105
|
|
|
18
106
|
export interface Tab {
|
|
19
107
|
id: string
|
|
@@ -26,6 +114,13 @@ export interface Tab {
|
|
|
26
114
|
/** Accessible name for ICON-ONLY tabs (label rendered empty) — e.g. the
|
|
27
115
|
* responsive platform switcher's compact tier (ADR-135). */
|
|
28
116
|
ariaLabel?: string
|
|
117
|
+
/**
|
|
118
|
+
* `id` of the `role="tabpanel"` element this tab controls, when the
|
|
119
|
+
* consumer renders one. Supplying it emits `aria-controls`; omitting it
|
|
120
|
+
* emits nothing, because an `aria-controls` that resolves to no element is
|
|
121
|
+
* worse than none at all.
|
|
122
|
+
*/
|
|
123
|
+
panelId?: string
|
|
29
124
|
}
|
|
30
125
|
|
|
31
126
|
export interface TabsProps {
|
|
@@ -34,10 +129,21 @@ export interface TabsProps {
|
|
|
34
129
|
onTabChange: (id: string) => void
|
|
35
130
|
/** Compact (12px label) or default (13px). */
|
|
36
131
|
size?: 'sm' | 'md'
|
|
37
|
-
/**
|
|
38
|
-
|
|
132
|
+
/**
|
|
133
|
+
* `underline` (default) and `pill` are top-of-content nav; `segmented` is
|
|
134
|
+
* the boxed track for a strip that has to fill a column — see the
|
|
135
|
+
* docblock. `segmented` is almost always paired with `stretch`.
|
|
136
|
+
*/
|
|
137
|
+
variant?: TabsVariant
|
|
39
138
|
/** Stretch the tablist to full width with equal-width, centred tabs. */
|
|
40
139
|
stretch?: boolean
|
|
140
|
+
/**
|
|
141
|
+
* Namespace for the generated tab `id`s, which are
|
|
142
|
+
* `<idPrefix>-tab-<tab.id>`. Pass a stable string when a panel needs to
|
|
143
|
+
* name its tab in `aria-labelledby`; leave it out and a `useId()` prefix is
|
|
144
|
+
* minted, which is unique but not knowable from outside.
|
|
145
|
+
*/
|
|
146
|
+
idPrefix?: string
|
|
41
147
|
className?: string
|
|
42
148
|
}
|
|
43
149
|
|
|
@@ -48,34 +154,96 @@ export function Tabs({
|
|
|
48
154
|
size = 'md',
|
|
49
155
|
variant = 'underline',
|
|
50
156
|
stretch = false,
|
|
157
|
+
idPrefix,
|
|
51
158
|
className,
|
|
52
159
|
}: TabsProps) {
|
|
53
160
|
const textSize = size === 'sm' ? 'text-[12px]' : 'text-[13px]'
|
|
161
|
+
|
|
162
|
+
const reactId = useId()
|
|
163
|
+
// `useId()` returns a colon-wrapped token in React 18 and a underscore-
|
|
164
|
+
// wrapped one in 19; neither is a valid bare CSS selector, and these ids end
|
|
165
|
+
// up in `aria-labelledby` where a consumer may well want to query them.
|
|
166
|
+
const prefix = idPrefix ?? `tabs-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`
|
|
167
|
+
const tabDomId = (id: string) => `${prefix}-tab-${id}`
|
|
168
|
+
|
|
169
|
+
// Movement runs over the ENABLED tabs only: a disabled button cannot take
|
|
170
|
+
// focus, so stepping onto one would silently strand the keyboard.
|
|
171
|
+
const enabled = tabs.filter((tab) => tab.disabled !== true)
|
|
172
|
+
const rovingId = enabled.some((tab) => tab.id === activeTabId)
|
|
173
|
+
? activeTabId
|
|
174
|
+
: enabled[0]?.id
|
|
175
|
+
|
|
176
|
+
const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
|
|
177
|
+
|
|
178
|
+
const activate = (next: Tab | undefined) => {
|
|
179
|
+
if (next === undefined) return
|
|
180
|
+
// Focus first: the button is already mounted, and moving focus before the
|
|
181
|
+
// state change means a consumer that re-renders synchronously cannot land
|
|
182
|
+
// us on `<body>`.
|
|
183
|
+
buttonRefs.current.get(next.id)?.focus()
|
|
184
|
+
if (next.id !== activeTabId) onTabChange(next.id)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, tab: Tab) => {
|
|
188
|
+
const index = enabled.findIndex((item) => item.id === tab.id)
|
|
189
|
+
if (index === -1) return
|
|
190
|
+
const count = enabled.length
|
|
191
|
+
switch (event.key) {
|
|
192
|
+
case 'ArrowRight':
|
|
193
|
+
event.preventDefault()
|
|
194
|
+
activate(enabled[(index + 1) % count])
|
|
195
|
+
break
|
|
196
|
+
case 'ArrowLeft':
|
|
197
|
+
event.preventDefault()
|
|
198
|
+
activate(enabled[(index - 1 + count) % count])
|
|
199
|
+
break
|
|
200
|
+
case 'Home':
|
|
201
|
+
event.preventDefault()
|
|
202
|
+
activate(enabled[0])
|
|
203
|
+
break
|
|
204
|
+
case 'End':
|
|
205
|
+
event.preventDefault()
|
|
206
|
+
activate(enabled[count - 1])
|
|
207
|
+
break
|
|
208
|
+
default:
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
54
213
|
return (
|
|
55
214
|
<div
|
|
56
215
|
role="tablist"
|
|
57
|
-
className={
|
|
58
|
-
|
|
59
|
-
(variant === 'underline'
|
|
60
|
-
? `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 border-b`
|
|
61
|
-
: `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 p-1`)
|
|
62
|
-
}
|
|
63
|
-
style={
|
|
64
|
-
variant === 'underline'
|
|
65
|
-
? { borderColor: 'rgb(var(--border))' }
|
|
66
|
-
: {
|
|
67
|
-
background: 'rgb(var(--surface-overlay-soft))',
|
|
68
|
-
border: '1px solid rgb(var(--border))',
|
|
69
|
-
borderRadius: 'var(--radius-full)',
|
|
70
|
-
}
|
|
71
|
-
}
|
|
216
|
+
className={className ?? TRACK_CLASS[variant](stretch)}
|
|
217
|
+
style={TRACK_STYLE[variant]}
|
|
72
218
|
>
|
|
73
219
|
{tabs.map((tab) => {
|
|
74
220
|
const isActive = tab.id === activeTabId
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
221
|
+
const grow = stretch ? ' flex-1 justify-center' : ''
|
|
222
|
+
// `segmented` sits in a 4px-padded track, so its segment is 26px —
|
|
223
|
+
// 26 + 4 + 4 + 1 + 1 = the 36px the field-list header already stands
|
|
224
|
+
// at. The other two variants keep their 36px button.
|
|
225
|
+
const base =
|
|
226
|
+
variant === 'segmented'
|
|
227
|
+
? // `min-w-0` is load-bearing: a flex item's default `min-width:
|
|
228
|
+
// auto` refuses to shrink below its content, so at the activity
|
|
229
|
+
// pane's 280px floor the three segments stopped being equal and
|
|
230
|
+
// the last one ran out through the track's border. With it they
|
|
231
|
+
// stay equal and the label truncates instead.
|
|
232
|
+
`inline-flex min-w-0 items-center gap-1.5 h-[26px] px-[var(--space-2)] rounded-[var(--radius-sm)] border ${textSize} font-semibold transition-[background-color,color,border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]${grow}`
|
|
233
|
+
: `inline-flex items-center gap-1.5 px-3 h-9 ${textSize} font-medium transition-colors${grow}`
|
|
234
|
+
// Segmented carries its colours as CLASSES, not inline style, so the
|
|
235
|
+
// focus ring can beat the active segment's `--shadow-sm`; an inline
|
|
236
|
+
// box-shadow would win over `focus-visible:` unconditionally and the
|
|
237
|
+
// one variant with no underline would be the one with no focus ring.
|
|
238
|
+
const segmentedState = isActive
|
|
239
|
+
? 'border-[rgb(var(--accent))] bg-[rgb(var(--surface-card))] text-[rgb(var(--accent-ink))] [box-shadow:var(--shadow-sm)]'
|
|
240
|
+
: 'border-transparent text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--foreground))]'
|
|
241
|
+
const className =
|
|
242
|
+
variant === 'underline'
|
|
243
|
+
? `${base} border-b-2 -mb-px`
|
|
244
|
+
: variant === 'segmented'
|
|
245
|
+
? `${base} ${segmentedState}`
|
|
246
|
+
: base
|
|
79
247
|
|
|
80
248
|
const activeStyle =
|
|
81
249
|
variant === 'underline'
|
|
@@ -106,24 +274,37 @@ export function Tabs({
|
|
|
106
274
|
return (
|
|
107
275
|
<button
|
|
108
276
|
key={tab.id}
|
|
277
|
+
ref={(node) => {
|
|
278
|
+
if (node === null) buttonRefs.current.delete(tab.id)
|
|
279
|
+
else buttonRefs.current.set(tab.id, node)
|
|
280
|
+
}}
|
|
281
|
+
id={tabDomId(tab.id)}
|
|
109
282
|
role="tab"
|
|
110
283
|
type="button"
|
|
111
284
|
aria-selected={isActive}
|
|
112
285
|
aria-label={tab.ariaLabel}
|
|
286
|
+
{...(tab.panelId === undefined ? {} : { 'aria-controls': tab.panelId })}
|
|
287
|
+
// Roving tabindex — one stop for the whole strip.
|
|
288
|
+
tabIndex={tab.id === rovingId ? 0 : -1}
|
|
113
289
|
disabled={tab.disabled}
|
|
290
|
+
onKeyDown={(event) => handleKeyDown(event, tab)}
|
|
114
291
|
onClick={() => onTabChange(tab.id)}
|
|
115
292
|
className={`${className} ${tab.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
|
|
116
|
-
|
|
293
|
+
{...(variant === 'segmented'
|
|
294
|
+
? {}
|
|
295
|
+
: { style: isActive ? activeStyle : inactiveStyle })}
|
|
117
296
|
>
|
|
118
297
|
{tab.icon && (
|
|
119
298
|
<span className="flex-shrink-0" aria-hidden>
|
|
120
299
|
{tab.icon}
|
|
121
300
|
</span>
|
|
122
301
|
)}
|
|
123
|
-
{
|
|
302
|
+
<span className={variant === 'segmented' ? 'min-w-0 truncate' : undefined}>
|
|
303
|
+
{tab.label}
|
|
304
|
+
</span>
|
|
124
305
|
{tab.count !== undefined && (
|
|
125
306
|
<span
|
|
126
|
-
className="ml-0.5 inline-flex min-w-[1.1rem] items-center justify-center rounded-full px-1 text-[10.5px] font-semibold tabular-nums"
|
|
307
|
+
className="ml-0.5 inline-flex min-w-[1.1rem] shrink-0 items-center justify-center rounded-full px-1 text-[10.5px] font-semibold tabular-nums"
|
|
127
308
|
style={
|
|
128
309
|
isActive && variant === 'pill'
|
|
129
310
|
? {
|