@octanejs/base-ui 0.1.23 → 0.1.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/base-ui",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,10 +41,10 @@
41
41
  "@floating-ui/utils": "^0.2.12",
42
42
  "aria-hidden": "^1.2.6",
43
43
  "tabbable": "^6.5.0",
44
- "@octanejs/floating-ui": "0.1.24"
44
+ "@octanejs/floating-ui": "0.1.25"
45
45
  },
46
46
  "peerDependencies": {
47
- "octane": "0.1.25"
47
+ "octane": "0.1.26"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@base-ui/react": "^1.6.0",
@@ -53,7 +53,7 @@
53
53
  "react": "^19.2.7",
54
54
  "react-dom": "^19.2.7",
55
55
  "vitest": "^4.1.10",
56
- "octane": "0.1.25"
56
+ "octane": "0.1.26"
57
57
  },
58
58
  "scripts": {
59
59
  "test": "vitest run"
package/src/tabs.ts ADDED
@@ -0,0 +1,947 @@
1
+ // Ported from .base-ui/packages/react/src/tabs/ (v1.6.0): root/TabsRootContext,
2
+ // root/stateAttributesMapping, root/TabsRoot, list/TabsListContext, list/TabsList, tab/TabsTab,
3
+ // panel/TabsPanel — plus its `index.parts` (the `Tabs` namespace).
4
+ //
5
+ // Tabs is a thin family over the composite infrastructure this package already carries: `List` IS a
6
+ // `CompositeRoot` (roving focus, Home/End, loop), each `Tab` is a `useCompositeItem`, and `Root`
7
+ // wraps the whole thing in a `CompositeList` so panels can register their own indices. Reading this
8
+ // alongside `utils/composite/` is the intended way to follow it.
9
+ //
10
+ // WHAT ROOT ACTUALLY OWNS, since the file reads as a lot of bookkeeping:
11
+ // 1. The controlled/uncontrolled value, via `useControlled`.
12
+ // 2. The ACTIVATION DIRECTION — which way the selection moved — computed DURING RENDER when the
13
+ // value changes, so children see the correct direction on their first render after a change
14
+ // rather than one commit late (upstream's mui/base-ui#3873). The previous-value snapshot then
15
+ // syncs in a layout effect.
16
+ // 3. An automatic fallback policy for UNCONTROLLED roots only: when the selected tab is disabled
17
+ // or removed, selection falls back to the first enabled tab and reports a reason
18
+ // ('initial' | 'disabled' | 'missing'). Controlled roots keep exactly what the parent gave
19
+ // them. An explicit `defaultValue` may deliberately point at a disabled tab on mount, and that
20
+ // is honored once — after the selection first becomes valid, later disabled states fall back.
21
+ // Automatic changes are not cancelable.
22
+ // 4. Two id registries wiring `aria-controls` on a Tab to its Panel, and `aria-labelledby` back.
23
+ //
24
+ // NOT PORTED YET: `Tabs.Indicator`. It positions itself from measured tab geometry and ships a
25
+ // pre-hydration `<script>` to avoid a first-paint jump, which is a separate piece of work with its
26
+ // own SSR contract. Every other part is here, and the shadcn `tabs` family this unblocks does not
27
+ // use it. Adding it later is additive.
28
+ //
29
+ // ORIENTATION ATTRIBUTE — worth stating because a consumer will hit it: at this pin the state-to-
30
+ // attribute conversion emits `data-orientation="horizontal" | "vertical"`, NOT the bare
31
+ // `data-horizontal` / `data-vertical` that newer Base UI releases (and the current shadcn sources
32
+ // written against them) use. That is faithful to v1.6.0; the mismatch is a version skew, and it is
33
+ // the consuming layer's job to target what this pin emits.
34
+ //
35
+ // octane adaptations:
36
+ // 1. `forwardRef` → ref-as-prop; merged refs become an array ref, which octane accepts.
37
+ // 2. `useIsoLayoutEffect` → `useLayoutEffect`, per the other ports in this package.
38
+ // 3. Events are native, so handlers receive the native event directly.
39
+ // 4. Every hook threads an explicit compiler slot (`S(...)` / `subSlot(...)`), because these are
40
+ // plain `.ts` hooks rather than compiled `.tsrx` call sites.
41
+ import {
42
+ createContext,
43
+ createElement,
44
+ useContext,
45
+ useLayoutEffect,
46
+ useMemo,
47
+ useRef,
48
+ useState,
49
+ } from 'octane';
50
+
51
+ import { S, subSlot } from './internal';
52
+ import { useRenderElement } from './utils/useRenderElement';
53
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
54
+ import { useBaseUiId } from './utils/useBaseUiId';
55
+ import { useButton } from './utils/useButton';
56
+ import { useControlled } from './utils/useControlled';
57
+ import { useStableCallback } from './utils/useStableCallback';
58
+ import { inertValue } from './utils/inertValue';
59
+ import { ownerDocument } from './utils/owner';
60
+ import { activeElement, contains } from './utils/floating/element';
61
+ import { transitionStatusMapping, useTransitionStatus } from './utils/useTransitionStatus';
62
+ import type { TransitionStatus } from './utils/useTransitionStatus';
63
+ import { useOpenChangeComplete } from './utils/useOpenChangeComplete';
64
+ import { CompositeList } from './utils/composite/CompositeList';
65
+ import { CompositeRoot } from './utils/composite/CompositeRoot';
66
+ import { useCompositeItem } from './utils/composite/useCompositeItem';
67
+ import { useCompositeListItem } from './utils/composite/useCompositeListItem';
68
+ import { ACTIVE_COMPOSITE_ITEM } from './utils/composite/keys';
69
+ import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
70
+
71
+ const EMPTY_DISABLED_INDICES: number[] = [];
72
+
73
+ export type TabsValue = any;
74
+ export type TabsActivationDirection = 'left' | 'right' | 'up' | 'down' | 'none';
75
+ export type TabsOrientation = 'horizontal' | 'vertical';
76
+
77
+ export interface TabsRootState {
78
+ orientation: TabsOrientation;
79
+ tabActivationDirection: TabsActivationDirection;
80
+ }
81
+
82
+ const tabsStateAttributesMapping: StateAttributesMapping<any> = {
83
+ tabActivationDirection: (dir: TabsActivationDirection) => ({
84
+ 'data-activation-direction': dir,
85
+ }),
86
+ };
87
+
88
+ // --- Root context -------------------------------------------------------------
89
+
90
+ export interface TabsRootContextValue {
91
+ value: TabsValue;
92
+ onValueChange: (value: TabsValue, eventDetails: any) => void;
93
+ orientation: TabsOrientation;
94
+ getTabElementBySelectedValue: (selectedValue: TabsValue | undefined) => HTMLElement | null;
95
+ getTabIdByPanelValue: (panelValue: TabsValue) => string | undefined;
96
+ getTabPanelIdByValue: (tabValue: TabsValue) => string | undefined;
97
+ registerMountedTabPanel: (panelValue: TabsValue, panelId: string) => void;
98
+ unregisterMountedTabPanel: (panelValue: TabsValue, panelId: string) => void;
99
+ setTabMap: (map: Map<Node, any>) => void;
100
+ tabActivationDirection: TabsActivationDirection;
101
+ }
102
+
103
+ const TabsRootContext = createContext<TabsRootContextValue | undefined>(undefined);
104
+
105
+ export function useTabsRootContext(): TabsRootContextValue {
106
+ const context = useContext(TabsRootContext);
107
+ if (context === undefined) {
108
+ throw new Error(
109
+ 'Base UI: TabsRootContext is missing. Tabs parts must be placed within <Tabs.Root>.',
110
+ );
111
+ }
112
+ return context;
113
+ }
114
+
115
+ // --- List context -------------------------------------------------------------
116
+
117
+ export interface TabsListContextValue {
118
+ activateOnFocus: boolean;
119
+ highlightedTabIndex: number;
120
+ onTabActivation: (newValue: TabsValue, eventDetails: any) => void;
121
+ registerTabResizeObserverElement: (element: HTMLElement) => () => void;
122
+ setHighlightedTabIndex: (index: number) => void;
123
+ tabsListElement: HTMLElement | null;
124
+ }
125
+
126
+ const TabsListContext = createContext<TabsListContextValue | undefined>(undefined);
127
+
128
+ export function useTabsListContext(): TabsListContextValue {
129
+ const context = useContext(TabsListContext);
130
+ if (context === undefined) {
131
+ throw new Error(
132
+ 'Base UI: TabsListContext is missing. TabsList parts must be placed within <Tabs.List>.',
133
+ );
134
+ }
135
+ return context;
136
+ }
137
+
138
+ // --- Activation direction -----------------------------------------------------
139
+
140
+ function computeActivationDirection(
141
+ oldValue: TabsValue,
142
+ newValue: TabsValue,
143
+ orientation: TabsOrientation,
144
+ tabMap: Map<Node, any>,
145
+ ): TabsActivationDirection {
146
+ if (oldValue == null || newValue == null) {
147
+ return 'none';
148
+ }
149
+
150
+ let oldTab: HTMLElement | null = null;
151
+ let newTab: HTMLElement | null = null;
152
+
153
+ for (const [tabElement, tabMetadata] of tabMap.entries()) {
154
+ if (tabMetadata == null) {
155
+ continue;
156
+ }
157
+ const tabValue = tabMetadata.value ?? tabMetadata.index;
158
+ if (oldValue === tabValue) {
159
+ oldTab = tabElement as HTMLElement;
160
+ }
161
+ if (newValue === tabValue) {
162
+ newTab = tabElement as HTMLElement;
163
+ }
164
+ if (oldTab != null && newTab != null) {
165
+ break;
166
+ }
167
+ }
168
+
169
+ if (oldTab == null || newTab == null) {
170
+ // Dynamic tabs: an element may not be registered yet (added and selected in the same
171
+ // update), so infer from the values themselves when they are comparable.
172
+ if (
173
+ oldTab !== newTab &&
174
+ (typeof oldValue === 'number' || typeof oldValue === 'string') &&
175
+ typeof oldValue === typeof newValue
176
+ ) {
177
+ if (orientation === 'horizontal') {
178
+ return newValue > oldValue ? 'right' : 'left';
179
+ }
180
+ return newValue > oldValue ? 'down' : 'up';
181
+ }
182
+ return 'none';
183
+ }
184
+
185
+ const oldRect = oldTab.getBoundingClientRect();
186
+ const newRect = newTab.getBoundingClientRect();
187
+
188
+ if (orientation === 'horizontal') {
189
+ if (newRect.left < oldRect.left) {
190
+ return 'left';
191
+ }
192
+ if (newRect.left > oldRect.left) {
193
+ return 'right';
194
+ }
195
+ } else {
196
+ if (newRect.top < oldRect.top) {
197
+ return 'up';
198
+ }
199
+ if (newRect.top > oldRect.top) {
200
+ return 'down';
201
+ }
202
+ }
203
+
204
+ return 'none';
205
+ }
206
+
207
+ // --- Root ---------------------------------------------------------------------
208
+
209
+ function TabsRoot(props: any): any {
210
+ const slot = S('TabsRoot');
211
+ const {
212
+ className,
213
+ defaultValue: defaultValueProp = 0,
214
+ onValueChange: onValueChangeProp,
215
+ orientation = 'horizontal',
216
+ render,
217
+ value: valueProp,
218
+ style,
219
+ ref,
220
+ ...elementProps
221
+ } = props;
222
+
223
+ // Whether the consumer explicitly passed a defined `defaultValue`, which decides the
224
+ // disabled-default honor policy below.
225
+ const hasExplicitDefaultValueProp = props.defaultValue !== undefined;
226
+
227
+ const tabPanelRefs = useRef<(HTMLElement | null)[]>([], subSlot(slot, 'panelRefs'));
228
+ const [mountedTabPanels, setMountedTabPanels] = useState(
229
+ () => new Map<TabsValue, string>(),
230
+ subSlot(slot, 'mountedPanels'),
231
+ );
232
+
233
+ const [value, setValue] = useControlled(
234
+ { controlled: valueProp, default: defaultValueProp, name: 'Tabs', state: 'value' },
235
+ subSlot(slot, 'value'),
236
+ );
237
+
238
+ const isControlled = valueProp !== undefined;
239
+
240
+ const [tabMap, setTabMap] = useState(() => new Map<Node, any>(), subSlot(slot, 'tabMap'));
241
+ const lastKnownTabElementRef = useRef<Node | undefined>(undefined, subSlot(slot, 'lastTabEl'));
242
+
243
+ const getTabElementBySelectedValue = useStableCallback(
244
+ (selectedValue: TabsValue | undefined): HTMLElement | null => {
245
+ if (selectedValue === undefined) {
246
+ return null;
247
+ }
248
+ for (const [tabElement, tabMetadata] of tabMap.entries()) {
249
+ if (tabMetadata != null && selectedValue === (tabMetadata.value ?? tabMetadata.index)) {
250
+ return tabElement as HTMLElement;
251
+ }
252
+ }
253
+ return null;
254
+ },
255
+ subSlot(slot, 'getTabEl'),
256
+ );
257
+
258
+ const [activationDirectionState, setActivationDirectionState] = useState(
259
+ () => ({ previousValue: value, tabActivationDirection: 'none' as TabsActivationDirection }),
260
+ subSlot(slot, 'activationDir'),
261
+ );
262
+ const { previousValue, tabActivationDirection: committedTabActivationDirection } =
263
+ activationDirectionState;
264
+
265
+ let tabActivationDirection = committedTabActivationDirection;
266
+ let directionComputationIncomplete = false;
267
+
268
+ // Computed DURING RENDER so children see the right direction on their first render after the
269
+ // selection changes, rather than one commit later.
270
+ if (previousValue !== value) {
271
+ tabActivationDirection = computeActivationDirection(previousValue, value, orientation, tabMap);
272
+ // A tab added and selected in the same update is not in tabMap yet, so the direction above
273
+ // came from the value fallback. Keep the snapshot stale to recompute from DOM positions.
274
+ directionComputationIncomplete =
275
+ previousValue != null && value != null && getTabElementBySelectedValue(value) == null;
276
+ }
277
+
278
+ const nextPreviousValue = directionComputationIncomplete ? previousValue : value;
279
+ const shouldSyncActivationDirectionState =
280
+ previousValue !== nextPreviousValue ||
281
+ committedTabActivationDirection !== tabActivationDirection;
282
+
283
+ useLayoutEffect(
284
+ () => {
285
+ if (!shouldSyncActivationDirectionState) {
286
+ return;
287
+ }
288
+ setActivationDirectionState({
289
+ previousValue: nextPreviousValue,
290
+ tabActivationDirection,
291
+ });
292
+ },
293
+ [nextPreviousValue, shouldSyncActivationDirectionState, tabActivationDirection],
294
+ subSlot(slot, 'e:syncDir'),
295
+ );
296
+
297
+ const onValueChange = useStableCallback(
298
+ (newValue: TabsValue, eventDetails: any) => {
299
+ const activationDirection = computeActivationDirection(value, newValue, orientation, tabMap);
300
+ eventDetails.activationDirection = activationDirection;
301
+ onValueChangeProp?.(newValue, eventDetails);
302
+ if (eventDetails.isCanceled) {
303
+ return;
304
+ }
305
+ setValue(newValue);
306
+ },
307
+ subSlot(slot, 'onValueChange'),
308
+ );
309
+
310
+ const notifyAutomaticValueChange = useStableCallback(
311
+ (nextValue: TabsValue, reason: string) => {
312
+ onValueChangeProp?.(
313
+ nextValue,
314
+ createChangeEventDetails(reason, undefined, undefined, {
315
+ activationDirection: 'none',
316
+ }),
317
+ );
318
+ },
319
+ subSlot(slot, 'notifyAuto'),
320
+ );
321
+
322
+ const registerMountedTabPanel = useStableCallback(
323
+ (panelValue: TabsValue, panelId: string) => {
324
+ setMountedTabPanels((prev: Map<TabsValue, string>) => {
325
+ if (prev.get(panelValue) === panelId) {
326
+ return prev;
327
+ }
328
+ const next = new Map(prev);
329
+ next.set(panelValue, panelId);
330
+ return next;
331
+ });
332
+ },
333
+ subSlot(slot, 'registerPanel'),
334
+ );
335
+
336
+ const unregisterMountedTabPanel = useStableCallback(
337
+ (panelValue: TabsValue, panelId: string) => {
338
+ setMountedTabPanels((prev: Map<TabsValue, string>) => {
339
+ if (!prev.has(panelValue) || prev.get(panelValue) !== panelId) {
340
+ return prev;
341
+ }
342
+ const next = new Map(prev);
343
+ next.delete(panelValue);
344
+ return next;
345
+ });
346
+ },
347
+ subSlot(slot, 'unregisterPanel'),
348
+ );
349
+
350
+ // `aria-controls` on a Tab points at its Panel's id.
351
+ const getTabPanelIdByValue = useStableCallback(
352
+ (tabValue: TabsValue) => mountedTabPanels.get(tabValue),
353
+ subSlot(slot, 'getPanelId'),
354
+ );
355
+
356
+ // `aria-labelledby` on a Panel points back at its Tab's id.
357
+ const getTabIdByPanelValue = useStableCallback(
358
+ (tabPanelValue: TabsValue) => {
359
+ for (const tabMetadata of tabMap.values()) {
360
+ if (tabPanelValue === tabMetadata?.value) {
361
+ return tabMetadata?.id;
362
+ }
363
+ }
364
+ return undefined;
365
+ },
366
+ subSlot(slot, 'getTabId'),
367
+ );
368
+
369
+ const tabsContextValue: TabsRootContextValue = useMemo(
370
+ () => ({
371
+ getTabElementBySelectedValue,
372
+ getTabIdByPanelValue,
373
+ getTabPanelIdByValue,
374
+ onValueChange,
375
+ orientation,
376
+ registerMountedTabPanel,
377
+ setTabMap,
378
+ unregisterMountedTabPanel,
379
+ tabActivationDirection,
380
+ value,
381
+ }),
382
+ [
383
+ getTabElementBySelectedValue,
384
+ getTabIdByPanelValue,
385
+ getTabPanelIdByValue,
386
+ onValueChange,
387
+ orientation,
388
+ registerMountedTabPanel,
389
+ setTabMap,
390
+ unregisterMountedTabPanel,
391
+ tabActivationDirection,
392
+ value,
393
+ ],
394
+ subSlot(slot, 'ctx'),
395
+ );
396
+
397
+ const selectedTabMetadata = useMemo(
398
+ () => {
399
+ for (const tabMetadata of tabMap.values()) {
400
+ if (tabMetadata != null && tabMetadata.value === value) {
401
+ return tabMetadata;
402
+ }
403
+ }
404
+ return undefined;
405
+ },
406
+ [tabMap, value],
407
+ subSlot(slot, 'selectedMeta'),
408
+ );
409
+
410
+ const firstEnabledTabValue = useMemo(
411
+ () => {
412
+ for (const tabMetadata of tabMap.values()) {
413
+ if (tabMetadata != null && !tabMetadata.disabled) {
414
+ return tabMetadata.value;
415
+ }
416
+ }
417
+ return undefined;
418
+ },
419
+ [tabMap],
420
+ subSlot(slot, 'firstEnabled'),
421
+ );
422
+
423
+ // An implicit uncontrolled selection is still an automatic change, so it is notified once when
424
+ // the tabs first register. An explicit default is treated as user-owned.
425
+ const shouldNotifyInitialValueChangeRef = useRef(
426
+ !hasExplicitDefaultValueProp,
427
+ subSlot(slot, 'notifyInitial'),
428
+ );
429
+ const initialDefaultValueRef = useRef(defaultValueProp, subSlot(slot, 'initialDefault'));
430
+ const shouldHonorDisabledDefaultValueRef = useRef(
431
+ hasExplicitDefaultValueProp,
432
+ subSlot(slot, 'honorDisabled'),
433
+ );
434
+ const didRegisterTabsRef = useRef(false, subSlot(slot, 'didRegister'));
435
+
436
+ // Uncontrolled roots own automatic fallback. Controlled roots keep exactly what the parent
437
+ // supplied, even when that tab is disabled or missing.
438
+ useLayoutEffect(
439
+ () => {
440
+ if (isControlled) {
441
+ return;
442
+ }
443
+
444
+ function commitAutomaticValueChange(fallbackValue: TabsValue, fallbackReason: string) {
445
+ setValue(fallbackValue);
446
+ // Automatic fallbacks are not directional, so the direction resets alongside the
447
+ // value and both land in the same commit.
448
+ setActivationDirectionState((prev: any) => {
449
+ if (prev.previousValue === fallbackValue && prev.tabActivationDirection === 'none') {
450
+ return prev;
451
+ }
452
+ return { previousValue: fallbackValue, tabActivationDirection: 'none' };
453
+ });
454
+ notifyAutomaticValueChange(fallbackValue, fallbackReason);
455
+ // Marked delivered only after the consumer callback returns: the fallback value is
456
+ // queued first so a throwing handler cannot cancel an automatic consistency update.
457
+ shouldNotifyInitialValueChangeRef.current = false;
458
+ }
459
+
460
+ if (tabMap.size === 0) {
461
+ // A Suspense boundary above the root can tear down layout effects while leaving the
462
+ // previous tabs connected. That is not removal.
463
+ if (
464
+ didRegisterTabsRef.current &&
465
+ value !== null &&
466
+ !(lastKnownTabElementRef.current as any)?.isConnected
467
+ ) {
468
+ commitAutomaticValueChange(null, REASONS.missing);
469
+ }
470
+ return;
471
+ }
472
+
473
+ didRegisterTabsRef.current = true;
474
+ lastKnownTabElementRef.current = tabMap.keys().next().value;
475
+
476
+ const selectionIsDisabled = selectedTabMetadata?.disabled;
477
+ const selectionIsMissing = selectedTabMetadata == null && value !== null;
478
+
479
+ if (!selectionIsDisabled && value === initialDefaultValueRef.current) {
480
+ shouldHonorDisabledDefaultValueRef.current = false;
481
+ }
482
+
483
+ if (
484
+ shouldHonorDisabledDefaultValueRef.current &&
485
+ selectionIsDisabled &&
486
+ value === initialDefaultValueRef.current
487
+ ) {
488
+ return;
489
+ }
490
+
491
+ const shouldNotifyInitialValueChange = shouldNotifyInitialValueChangeRef.current;
492
+
493
+ if (selectionIsDisabled || selectionIsMissing) {
494
+ const fallbackValue = firstEnabledTabValue ?? null;
495
+
496
+ if (value === fallbackValue) {
497
+ shouldNotifyInitialValueChangeRef.current = false;
498
+ return;
499
+ }
500
+
501
+ let fallbackReason: string = REASONS.missing;
502
+ if (shouldNotifyInitialValueChange) {
503
+ fallbackReason = REASONS.initial;
504
+ } else if (selectionIsDisabled) {
505
+ fallbackReason = REASONS.disabled;
506
+ }
507
+
508
+ commitAutomaticValueChange(fallbackValue, fallbackReason);
509
+ return;
510
+ }
511
+
512
+ if (shouldNotifyInitialValueChange && selectedTabMetadata != null) {
513
+ notifyAutomaticValueChange(value, REASONS.initial);
514
+ shouldNotifyInitialValueChangeRef.current = false;
515
+ }
516
+ },
517
+ [
518
+ firstEnabledTabValue,
519
+ isControlled,
520
+ notifyAutomaticValueChange,
521
+ selectedTabMetadata,
522
+ setValue,
523
+ tabMap,
524
+ value,
525
+ ],
526
+ subSlot(slot, 'e:fallback'),
527
+ );
528
+
529
+ const state: TabsRootState = { orientation, tabActivationDirection };
530
+
531
+ const element = useRenderElement(
532
+ 'div',
533
+ { render, className, style },
534
+ {
535
+ state,
536
+ ref,
537
+ props: elementProps,
538
+ stateAttributesMapping: tabsStateAttributesMapping,
539
+ },
540
+ subSlot(slot, 're'),
541
+ );
542
+
543
+ return createElement(TabsRootContext.Provider, {
544
+ value: tabsContextValue,
545
+ children: createElement(CompositeList, {
546
+ elementsRef: tabPanelRefs,
547
+ children: element,
548
+ }),
549
+ });
550
+ }
551
+
552
+ // --- List ---------------------------------------------------------------------
553
+
554
+ function TabsList(props: any): any {
555
+ const slot = S('TabsList');
556
+ const {
557
+ activateOnFocus = false,
558
+ className,
559
+ loopFocus = true,
560
+ render,
561
+ style,
562
+ ref,
563
+ ...elementProps
564
+ } = props;
565
+
566
+ const { onValueChange, orientation, value, setTabMap, tabActivationDirection } =
567
+ useTabsRootContext();
568
+
569
+ const [highlightedTabIndex, setHighlightedTabIndex] = useState(0, subSlot(slot, 'highlighted'));
570
+ const [tabsListElement, setTabsListElement] = useState<HTMLElement | null>(
571
+ null,
572
+ subSlot(slot, 'listEl'),
573
+ );
574
+
575
+ const tabResizeObserverElementsRef = useRef(new Set<HTMLElement>(), subSlot(slot, 'resizeEls'));
576
+
577
+ // Upstream keeps a ResizeObserver here to drive Indicator repositioning. Indicator is not
578
+ // ported yet, so the registration is kept (Tab calls it unconditionally) but there is no
579
+ // observer to attach to. Restore the observer alongside Indicator.
580
+ const registerTabResizeObserverElement = useStableCallback(
581
+ (element: HTMLElement) => {
582
+ tabResizeObserverElementsRef.current.add(element);
583
+ return () => {
584
+ tabResizeObserverElementsRef.current.delete(element);
585
+ };
586
+ },
587
+ subSlot(slot, 'registerResize'),
588
+ );
589
+
590
+ const onTabActivation = useStableCallback(
591
+ (newValue: TabsValue, eventDetails: any) => {
592
+ if (newValue !== value) {
593
+ onValueChange(newValue, eventDetails);
594
+ }
595
+ },
596
+ subSlot(slot, 'onTabActivation'),
597
+ );
598
+
599
+ const state: TabsRootState = { orientation, tabActivationDirection };
600
+
601
+ const defaultProps = {
602
+ 'aria-orientation': orientation === 'vertical' ? 'vertical' : undefined,
603
+ role: 'tablist',
604
+ };
605
+
606
+ const tabsListContextValue: TabsListContextValue = useMemo(
607
+ () => ({
608
+ activateOnFocus,
609
+ highlightedTabIndex,
610
+ registerTabResizeObserverElement,
611
+ onTabActivation,
612
+ setHighlightedTabIndex,
613
+ tabsListElement,
614
+ }),
615
+ [
616
+ activateOnFocus,
617
+ highlightedTabIndex,
618
+ registerTabResizeObserverElement,
619
+ onTabActivation,
620
+ setHighlightedTabIndex,
621
+ tabsListElement,
622
+ ],
623
+ subSlot(slot, 'ctx'),
624
+ );
625
+
626
+ return createElement(TabsListContext.Provider, {
627
+ value: tabsListContextValue,
628
+ children: createElement(CompositeRoot, {
629
+ render,
630
+ className,
631
+ style,
632
+ state,
633
+ refs: [ref, setTabsListElement],
634
+ props: [defaultProps, elementProps],
635
+ stateAttributesMapping: tabsStateAttributesMapping,
636
+ highlightedIndex: highlightedTabIndex,
637
+ enableHomeAndEndKeys: true,
638
+ loopFocus,
639
+ orientation,
640
+ onHighlightedIndexChange: setHighlightedTabIndex,
641
+ onMapChange: setTabMap,
642
+ disabledIndices: EMPTY_DISABLED_INDICES,
643
+ }),
644
+ });
645
+ }
646
+
647
+ // --- Tab ----------------------------------------------------------------------
648
+
649
+ function TabsTab(props: any): any {
650
+ const slot = S('TabsTab');
651
+ const {
652
+ className,
653
+ disabled = false,
654
+ render,
655
+ value,
656
+ id: idProp,
657
+ nativeButton = true,
658
+ style,
659
+ ref,
660
+ ...elementProps
661
+ } = props;
662
+
663
+ const {
664
+ value: activeTabValue,
665
+ getTabPanelIdByValue,
666
+ orientation,
667
+ tabActivationDirection,
668
+ } = useTabsRootContext();
669
+
670
+ const {
671
+ activateOnFocus,
672
+ highlightedTabIndex,
673
+ onTabActivation,
674
+ registerTabResizeObserverElement,
675
+ setHighlightedTabIndex,
676
+ tabsListElement,
677
+ } = useTabsListContext();
678
+
679
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
680
+
681
+ const tabMetadata = useMemo(
682
+ () => ({ disabled, id, value }),
683
+ [disabled, id, value],
684
+ subSlot(slot, 'meta'),
685
+ );
686
+
687
+ // The hook rather than the CompositeItem component, because the index is needed below.
688
+ const { compositeProps, compositeRef, index } = useCompositeItem(
689
+ { metadata: tabMetadata },
690
+ subSlot(slot, 'composite'),
691
+ );
692
+
693
+ const active = value === activeTabValue;
694
+
695
+ const isNavigatingRef = useRef(false, subSlot(slot, 'navigating'));
696
+ const tabElementRef = useRef<HTMLElement | null>(null, subSlot(slot, 'tabEl'));
697
+
698
+ useLayoutEffect(
699
+ () => {
700
+ const tabElement = tabElementRef.current;
701
+ if (!tabElement) {
702
+ return undefined;
703
+ }
704
+ return registerTabResizeObserverElement(tabElement);
705
+ },
706
+ [registerTabResizeObserverElement],
707
+ subSlot(slot, 'e:resize'),
708
+ );
709
+
710
+ // Keep the roving highlight on the active tab when the value changes externally.
711
+ useLayoutEffect(
712
+ () => {
713
+ if (isNavigatingRef.current) {
714
+ isNavigatingRef.current = false;
715
+ return;
716
+ }
717
+
718
+ if (!(active && index > -1 && highlightedTabIndex !== index)) {
719
+ return;
720
+ }
721
+
722
+ // If focus is already inside the list, leave the roving highlight alone so keyboard
723
+ // navigation stays relative to the focused item.
724
+ const listElement = tabsListElement;
725
+ if (listElement != null) {
726
+ const activeEl = activeElement(ownerDocument(listElement));
727
+ if (activeEl && contains(listElement, activeEl)) {
728
+ return;
729
+ }
730
+ }
731
+
732
+ // Never highlight a disabled tab: keyboard focus must stay on an enabled one even when a
733
+ // disabled tab is selected.
734
+ if (!disabled) {
735
+ setHighlightedTabIndex(index);
736
+ }
737
+ },
738
+ [active, index, highlightedTabIndex, setHighlightedTabIndex, disabled, tabsListElement],
739
+ subSlot(slot, 'e:sync'),
740
+ );
741
+
742
+ const { getButtonProps, buttonRef } = useButton(
743
+ { disabled, native: nativeButton, focusableWhenDisabled: true },
744
+ subSlot(slot, 'button'),
745
+ );
746
+
747
+ const tabPanelId = getTabPanelIdByValue(value);
748
+
749
+ const isPressingRef = useRef(false, subSlot(slot, 'pressing'));
750
+ const isMainButtonRef = useRef(false, subSlot(slot, 'mainButton'));
751
+
752
+ function onClick(event: any) {
753
+ if (active || disabled) {
754
+ return;
755
+ }
756
+ onTabActivation(
757
+ value,
758
+ createChangeEventDetails(REASONS.none, event, undefined, { activationDirection: 'none' }),
759
+ );
760
+ }
761
+
762
+ function onFocus(event: any) {
763
+ if (active) {
764
+ return;
765
+ }
766
+
767
+ // Only highlight enabled tabs on focus; disabled tabs stay focusable.
768
+ if (index > -1 && !disabled) {
769
+ setHighlightedTabIndex(index);
770
+ }
771
+
772
+ if (disabled) {
773
+ return;
774
+ }
775
+
776
+ if (
777
+ activateOnFocus &&
778
+ (!isPressingRef.current || (isPressingRef.current && isMainButtonRef.current))
779
+ ) {
780
+ onTabActivation(
781
+ value,
782
+ createChangeEventDetails(REASONS.none, event, undefined, { activationDirection: 'none' }),
783
+ );
784
+ }
785
+ }
786
+
787
+ function onPointerDown(event: any) {
788
+ if (active || disabled) {
789
+ return;
790
+ }
791
+
792
+ isPressingRef.current = true;
793
+
794
+ function handlePointerUp() {
795
+ isPressingRef.current = false;
796
+ isMainButtonRef.current = false;
797
+ }
798
+
799
+ if (!event.button || event.button === 0) {
800
+ isMainButtonRef.current = true;
801
+ const doc = ownerDocument(event.currentTarget);
802
+ doc.addEventListener('pointerup', handlePointerUp, { once: true });
803
+ }
804
+ }
805
+
806
+ const state = { disabled, active, orientation, tabActivationDirection };
807
+
808
+ return useRenderElement(
809
+ 'button',
810
+ { render, className, style },
811
+ {
812
+ state,
813
+ ref: [ref, buttonRef, compositeRef, tabElementRef],
814
+ props: [
815
+ compositeProps,
816
+ {
817
+ role: 'tab',
818
+ 'aria-controls': tabPanelId,
819
+ 'aria-selected': active,
820
+ id,
821
+ onClick,
822
+ onFocus,
823
+ onPointerDown,
824
+ [ACTIVE_COMPOSITE_ITEM]: active ? '' : undefined,
825
+ onKeyDownCapture() {
826
+ isNavigatingRef.current = true;
827
+ },
828
+ },
829
+ elementProps,
830
+ getButtonProps,
831
+ ],
832
+ stateAttributesMapping: tabsStateAttributesMapping,
833
+ },
834
+ subSlot(slot, 're'),
835
+ );
836
+ }
837
+
838
+ // --- Panel --------------------------------------------------------------------
839
+
840
+ const panelStateAttributesMapping: StateAttributesMapping<any> = {
841
+ ...tabsStateAttributesMapping,
842
+ ...(transitionStatusMapping as StateAttributesMapping<any>),
843
+ };
844
+
845
+ function TabsPanel(props: any): any {
846
+ const slot = S('TabsPanel');
847
+ const { className, value, render, keepMounted = false, style, ref, ...elementProps } = props;
848
+
849
+ const {
850
+ value: selectedValue,
851
+ getTabIdByPanelValue,
852
+ orientation,
853
+ tabActivationDirection,
854
+ registerMountedTabPanel,
855
+ unregisterMountedTabPanel,
856
+ } = useTabsRootContext();
857
+
858
+ const id = useBaseUiId(undefined, subSlot(slot, 'id'));
859
+
860
+ const metadata = useMemo(() => ({ id, value }), [id, value], subSlot(slot, 'meta'));
861
+
862
+ const { ref: listItemRef, index } = useCompositeListItem({ metadata }, subSlot(slot, 'listItem'));
863
+
864
+ const open = value === selectedValue;
865
+ const { mounted, transitionStatus, setMounted } = useTransitionStatus(
866
+ open,
867
+ subSlot(slot, 'transition'),
868
+ );
869
+ const hidden = !mounted;
870
+
871
+ const correspondingTabId = getTabIdByPanelValue(value);
872
+
873
+ const state = { hidden, orientation, tabActivationDirection, transitionStatus };
874
+
875
+ const panelRef = useRef<HTMLElement | null>(null, subSlot(slot, 'panelRef'));
876
+
877
+ const element = useRenderElement(
878
+ 'div',
879
+ { render, className, style },
880
+ {
881
+ state,
882
+ ref: [ref, listItemRef, panelRef],
883
+ props: [
884
+ {
885
+ 'aria-labelledby': correspondingTabId,
886
+ hidden,
887
+ id,
888
+ role: 'tabpanel',
889
+ tabIndex: open ? 0 : -1,
890
+ inert: inertValue(!open),
891
+ 'data-index': index,
892
+ },
893
+ elementProps,
894
+ ],
895
+ stateAttributesMapping: panelStateAttributesMapping,
896
+ },
897
+ subSlot(slot, 're'),
898
+ );
899
+
900
+ useOpenChangeComplete(
901
+ {
902
+ open,
903
+ ref: panelRef,
904
+ onComplete() {
905
+ if (!open) {
906
+ setMounted(false);
907
+ }
908
+ },
909
+ },
910
+ subSlot(slot, 'openComplete'),
911
+ );
912
+
913
+ useLayoutEffect(
914
+ () => {
915
+ if (hidden && !keepMounted) {
916
+ return undefined;
917
+ }
918
+ if (id == null) {
919
+ return undefined;
920
+ }
921
+ registerMountedTabPanel(value, id);
922
+ return () => {
923
+ unregisterMountedTabPanel(value, id);
924
+ };
925
+ },
926
+ [hidden, keepMounted, value, id, registerMountedTabPanel, unregisterMountedTabPanel],
927
+ subSlot(slot, 'e:register'),
928
+ );
929
+
930
+ const shouldRender = keepMounted || mounted;
931
+ if (!shouldRender) {
932
+ return null;
933
+ }
934
+
935
+ return element;
936
+ }
937
+
938
+ // --- Namespace (mirrors `export * as Tabs`) -----------------------------------
939
+
940
+ export const Tabs = {
941
+ Root: TabsRoot,
942
+ List: TabsList,
943
+ Tab: TabsTab,
944
+ Panel: TabsPanel,
945
+ };
946
+
947
+ export { TabsRoot, TabsList, TabsTab, TabsPanel };
@@ -23,6 +23,9 @@ export const REASONS = {
23
23
  trackPress: 'track-press',
24
24
  drag: 'drag',
25
25
  disabled: 'disabled',
26
+ // Automatic-consistency reasons (tabs): the selected item was never valid, or went away.
27
+ initial: 'initial',
28
+ missing: 'missing',
26
29
  triggerHover: 'trigger-hover',
27
30
  triggerFocus: 'trigger-focus',
28
31
  outsidePress: 'outside-press',