From 9f549c1baff3ba00e4f863ca4ec69fe76826cf5a Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 13 Jul 2026 17:43:44 +1000 Subject: [PATCH 1/4] [tabs][toolbar] Reduce bundle size --- .../composite/list/useCompositeListItem.ts | 13 ++- .../composite/root/useCompositeRoot.ts | 9 +- .../src/tabs/indicator/TabsIndicator.tsx | 16 ++- packages/react/src/tabs/list/TabsList.tsx | 20 +--- .../react/src/tabs/list/TabsListContext.ts | 5 - packages/react/src/tabs/panel/TabsPanel.tsx | 24 +--- packages/react/src/tabs/root/TabsRoot.tsx | 110 +++++++----------- .../react/src/tabs/root/TabsRootContext.ts | 3 +- .../src/tabs/root/stateAttributesMapping.ts | 3 +- packages/react/src/tabs/tab/TabsTab.tsx | 51 ++++---- .../react/src/toggle-group/ToggleGroup.tsx | 2 +- .../src/toolbar/button/ToolbarButton.test.tsx | 1 - .../src/toolbar/button/ToolbarButton.tsx | 4 +- .../src/toolbar/group/ToolbarGroup.test.tsx | 1 - .../src/toolbar/group/ToolbarGroupContext.ts | 12 +- .../src/toolbar/input/ToolbarInput.test.tsx | 1 - .../react/src/toolbar/input/ToolbarInput.tsx | 20 ++-- .../src/toolbar/link/ToolbarLink.test.tsx | 1 - .../react/src/toolbar/root/ToolbarRoot.tsx | 5 +- .../src/toolbar/root/ToolbarRootContext.ts | 5 - .../toolbar/separator/ToolbarSeparator.tsx | 8 +- 21 files changed, 109 insertions(+), 205 deletions(-) diff --git a/packages/react/src/internals/composite/list/useCompositeListItem.ts b/packages/react/src/internals/composite/list/useCompositeListItem.ts index 5a7c9c0ec3a..77980085ec8 100644 --- a/packages/react/src/internals/composite/list/useCompositeListItem.ts +++ b/packages/react/src/internals/composite/list/useCompositeListItem.ts @@ -19,10 +19,13 @@ interface UseCompositeListItemReturnValue { index: number; } -export enum IndexGuessBehavior { - None, - GuessFromOrder, -} +const GUESS_FROM_ORDER = 1; + +export const IndexGuessBehavior = { + None: 0, + GuessFromOrder: GUESS_FROM_ORDER, +} as const; +export type IndexGuessBehavior = (typeof IndexGuessBehavior)[keyof typeof IndexGuessBehavior]; /** * Used to register a list item and its index (DOM position) in the `CompositeList`. @@ -38,7 +41,7 @@ export function useCompositeListItem( const indexRef = React.useRef(-1); const [index, setIndex] = React.useState( externalIndex ?? - (indexGuessBehavior === IndexGuessBehavior.GuessFromOrder + (indexGuessBehavior === GUESS_FROM_ORDER ? () => { if (indexRef.current === -1) { const newIndex = nextIndexRef.current; diff --git a/packages/react/src/internals/composite/root/useCompositeRoot.ts b/packages/react/src/internals/composite/root/useCompositeRoot.ts index f85448c5239..9025105807f 100644 --- a/packages/react/src/internals/composite/root/useCompositeRoot.ts +++ b/packages/react/src/internals/composite/root/useCompositeRoot.ts @@ -88,7 +88,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { onHighlightedIndexChange: externalSetHighlightedIndex, rootRef: externalRef, enableHomeAndEndKeys = false, - stopEventPropagation = false, + stopEventPropagation, disabledIndices, modifierKeys = EMPTY_ARRAY, } = params; @@ -215,7 +215,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { if (target != null && isNativeInput(target) && !isElementDisabled(target)) { const selectionStart = target.selectionStart; const selectionEnd = target.selectionEnd; - const textContent = target.value ?? ''; + const textContent = target.value; // return to native textbox behavior when // 1 - Shift is held to make a text selection, or if there already is a text selection if (selectionStart == null || event.shiftKey || selectionStart !== selectionEnd) { @@ -326,7 +326,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { if (!element || target == null || !isNativeInput(target)) { return; } - target.setSelectionRange(0, target.value.length ?? 0); + target.setSelectionRange(0, target.value.length); }, onKeyDown, }; @@ -336,14 +336,13 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { highlightedIndex, onHighlightedIndexChange, elementsRef, - disabledIndices, onMapChange, relayKeyboardEvent: onKeyDown, }; } function isModifierKeySet(event: React.KeyboardEvent, ignoredModifierKeys: ModifierKey[]) { - for (const key of MODIFIER_KEYS.values()) { + for (const key of MODIFIER_KEYS) { if (ignoredModifierKeys.includes(key)) { continue; } diff --git a/packages/react/src/tabs/indicator/TabsIndicator.tsx b/packages/react/src/tabs/indicator/TabsIndicator.tsx index 4ad08e7fac0..d04b5759fcc 100644 --- a/packages/react/src/tabs/indicator/TabsIndicator.tsx +++ b/packages/react/src/tabs/indicator/TabsIndicator.tsx @@ -11,7 +11,6 @@ import { useTabsRootContext } from '../root/TabsRootContext'; import { tabsStateAttributesMapping } from '../root/stateAttributesMapping'; import { useTabsListContext } from '../list/TabsListContext'; import type { TabsTab } from '../tab/TabsTab'; -import { TabsIndicatorCssVars } from './TabsIndicatorCssVars'; const stateAttributesMapping = { ...tabsStateAttributesMapping, @@ -69,8 +68,7 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( const tabsListRect = tabsListElement.getBoundingClientRect(); const scaleX = tabListWidth > 0 ? tabsListRect.width / tabListWidth : 1; const scaleY = tabListHeight > 0 ? tabsListRect.height / tabListHeight : 1; - const hasNonZeroScale = - Math.abs(scaleX) > Number.EPSILON && Math.abs(scaleY) > Number.EPSILON; + const hasNonZeroScale = scaleX > Number.EPSILON && scaleY > Number.EPSILON; if (hasNonZeroScale) { const tabLeftDelta = tabRect.left - tabsListRect.left; @@ -96,12 +94,12 @@ export const TabsIndicator = React.forwardRef(function TabsIndicator( const style: React.CSSProperties | undefined = isTabSelected ? ({ - [TabsIndicatorCssVars.activeTabLeft]: `${left}px`, - [TabsIndicatorCssVars.activeTabRight]: `${right}px`, - [TabsIndicatorCssVars.activeTabTop]: `${top}px`, - [TabsIndicatorCssVars.activeTabBottom]: `${bottom}px`, - [TabsIndicatorCssVars.activeTabWidth]: `${width}px`, - [TabsIndicatorCssVars.activeTabHeight]: `${height}px`, + '--active-tab-left': `${left}px`, + '--active-tab-right': `${right}px`, + '--active-tab-top': `${top}px`, + '--active-tab-bottom': `${bottom}px`, + '--active-tab-width': `${width}px`, + '--active-tab-height': `${height}px`, } as React.CSSProperties) : undefined; diff --git a/packages/react/src/tabs/list/TabsList.tsx b/packages/react/src/tabs/list/TabsList.tsx index a1259a4e761..3b44e71cd5d 100644 --- a/packages/react/src/tabs/list/TabsList.tsx +++ b/packages/react/src/tabs/list/TabsList.tsx @@ -4,11 +4,10 @@ import { useStableCallback } from '@base-ui/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { EMPTY_ARRAY } from '@base-ui/utils/empty'; import { BaseUIComponentProps, HTMLProps } from '../../internals/types'; -import type { TabsRoot, TabsRootState } from '../root/TabsRoot'; +import type { TabsRootState } from '../root/TabsRoot'; import { CompositeRoot } from '../../internals/composite/root/CompositeRoot'; import { tabsStateAttributesMapping } from '../root/stateAttributesMapping'; import { useTabsRootContext } from '../root/TabsRootContext'; -import type { TabsTab } from '../tab/TabsTab'; import { TabsListContext } from './TabsListContext'; /** @@ -30,8 +29,7 @@ export const TabsList = React.forwardRef(function TabsList( ...elementProps } = componentProps; - const { onValueChange, orientation, value, setTabMap, tabActivationDirection } = - useTabsRootContext(); + const { orientation, setTabMap, tabActivationDirection } = useTabsRootContext(); const [highlightedTabIndex, setHighlightedTabIndex] = React.useState(0); const [tabsListElement, setTabsListElement] = React.useState(null); @@ -83,14 +81,6 @@ export const TabsList = React.forwardRef(function TabsList( }; }); - const onTabActivation = useStableCallback( - (newValue: TabsTab.Value, eventDetails: TabsRoot.ChangeEventDetails) => { - if (newValue !== value) { - onValueChange(newValue, eventDetails); - } - }, - ); - const state: TabsListState = { orientation, tabActivationDirection, @@ -104,20 +94,14 @@ export const TabsList = React.forwardRef(function TabsList( const tabsListContextValue: TabsListContext = React.useMemo( () => ({ activateOnFocus, - highlightedTabIndex, registerIndicatorUpdateListener, registerTabResizeObserverElement, - onTabActivation, - setHighlightedTabIndex, tabsListElement, }), [ activateOnFocus, - highlightedTabIndex, registerIndicatorUpdateListener, registerTabResizeObserverElement, - onTabActivation, - setHighlightedTabIndex, tabsListElement, ], ); diff --git a/packages/react/src/tabs/list/TabsListContext.ts b/packages/react/src/tabs/list/TabsListContext.ts index cafe7d0c9cf..f3f848e2ecb 100644 --- a/packages/react/src/tabs/list/TabsListContext.ts +++ b/packages/react/src/tabs/list/TabsListContext.ts @@ -1,15 +1,10 @@ 'use client'; import * as React from 'react'; -import type { TabsRoot } from '../root/TabsRoot'; -import type { TabsTab } from '../tab/TabsTab'; export interface TabsListContext { activateOnFocus: boolean; - highlightedTabIndex: number; registerIndicatorUpdateListener: (listener: () => void) => () => void; registerTabResizeObserverElement: (element: HTMLElement) => () => void; - onTabActivation: (newValue: TabsTab.Value, eventDetails: TabsRoot.ChangeEventDetails) => void; - setHighlightedTabIndex: (index: number) => void; tabsListElement: HTMLElement | null; } diff --git a/packages/react/src/tabs/panel/TabsPanel.tsx b/packages/react/src/tabs/panel/TabsPanel.tsx index ef69561740e..f0a1959d16e 100644 --- a/packages/react/src/tabs/panel/TabsPanel.tsx +++ b/packages/react/src/tabs/panel/TabsPanel.tsx @@ -14,7 +14,6 @@ import { tabsStateAttributesMapping } from '../root/stateAttributesMapping'; import { useTabsRootContext } from '../root/TabsRootContext'; import type { TabsRootState } from '../root/TabsRoot'; import type { TabsTab } from '../tab/TabsTab'; -import { TabsPanelDataAttributes } from './TabsPanelDataAttributes'; const stateAttributesMapping: StateAttributesMapping = { ...tabsStateAttributesMapping, @@ -39,22 +38,11 @@ export const TabsPanel = React.forwardRef(function TabsPanel( orientation, tabActivationDirection, registerMountedTabPanel, - unregisterMountedTabPanel, } = useTabsRootContext(); const id = useBaseUiId(); - const metadata = React.useMemo( - () => ({ - id, - value, - }), - [id, value], - ); - - const { ref: listItemRef, index } = useCompositeListItem({ - metadata, - }); + const { ref: listItemRef, index } = useCompositeListItem(); const open = value === selectedValue; const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); @@ -82,7 +70,8 @@ export const TabsPanel = React.forwardRef(function TabsPanel( role: 'tabpanel', tabIndex: open ? 0 : -1, inert: inertValue(!open), - [TabsPanelDataAttributes.index as string]: index, + // Computed key: a plain literal key fails the DOM-props excess property check. + ['data-index' as string]: index, }, elementProps, ], @@ -108,11 +97,8 @@ export const TabsPanel = React.forwardRef(function TabsPanel( return undefined; } - registerMountedTabPanel(value, id); - return () => { - unregisterMountedTabPanel(value, id); - }; - }, [hidden, keepMounted, value, id, registerMountedTabPanel, unregisterMountedTabPanel]); + return registerMountedTabPanel(value, id); + }, [hidden, keepMounted, value, id, registerMountedTabPanel]); const shouldRender = keepMounted || mounted; if (!shouldRender) { diff --git a/packages/react/src/tabs/root/TabsRoot.tsx b/packages/react/src/tabs/root/TabsRoot.tsx index 1640fa75bfc..ccab4a053d9 100644 --- a/packages/react/src/tabs/root/TabsRoot.tsx +++ b/packages/react/src/tabs/root/TabsRoot.tsx @@ -63,19 +63,8 @@ export const TabsRoot = React.forwardRef(function TabsRoot( // Used for activation direction detection via tab element positions. const getTabElementBySelectedValue = React.useCallback( - (selectedValue: TabsTab.Value | undefined): HTMLElement | null => { - if (selectedValue === undefined) { - return null; - } - - for (const [tabElement, tabMetadata] of tabMap.entries()) { - if (tabMetadata != null && selectedValue === (tabMetadata.value ?? tabMetadata.index)) { - return tabElement as HTMLElement; - } - } - - return null; - }, + (selectedValue: TabsTab.Value | undefined): HTMLElement | null => + findTabElement(tabMap, selectedValue), [tabMap], ); @@ -158,20 +147,18 @@ export const TabsRoot = React.forwardRef(function TabsRoot( next.set(panelValue, panelId); return next; }); - }, - ); - const unregisterMountedTabPanel = useStableCallback( - (panelValue: TabsTab.Value | number, panelId: string) => { - setMountedTabPanels((prev) => { - if (!prev.has(panelValue) || prev.get(panelValue) !== panelId) { - return prev; - } - - const next = new Map(prev); - next.delete(panelValue); - return next; - }); + return () => { + setMountedTabPanels((prev) => { + if (prev.get(panelValue) !== panelId) { + return prev; + } + + const next = new Map(prev); + next.delete(panelValue); + return next; + }); + }; }, ); @@ -205,7 +192,6 @@ export const TabsRoot = React.forwardRef(function TabsRoot( orientation, registerMountedTabPanel, setTabMap, - unregisterMountedTabPanel, tabActivationDirection, value, }), @@ -217,7 +203,6 @@ export const TabsRoot = React.forwardRef(function TabsRoot( orientation, registerMountedTabPanel, setTabMap, - unregisterMountedTabPanel, tabActivationDirection, value, ], @@ -373,6 +358,23 @@ export const TabsRoot = React.forwardRef(function TabsRoot( ); }); +function findTabElement( + tabMap: Map | null>, + value: TabsTab.Value | undefined, +): HTMLElement | null { + if (value === undefined) { + return null; + } + + for (const [tabElement, tabMetadata] of tabMap.entries()) { + if (tabMetadata != null && value === (tabMetadata.value ?? tabMetadata.index)) { + return tabElement as HTMLElement; + } + } + + return null; +} + function computeActivationDirection( oldValue: TabsTab.Value | null, newValue: TabsTab.Value | null, @@ -383,25 +385,13 @@ function computeActivationDirection( return 'none'; } - let oldTab: HTMLElement | null = null; - let newTab: HTMLElement | null = null; + const [positionProp, backward, forward] = + orientation === 'horizontal' + ? (['left', 'left', 'right'] as const) + : (['top', 'up', 'down'] as const); - for (const [tabElement, tabMetadata] of tabMap.entries()) { - if (tabMetadata == null) { - continue; - } - - const tabValue = tabMetadata.value ?? tabMetadata.index; - if (oldValue === tabValue) { - oldTab = tabElement as HTMLElement; - } - if (newValue === tabValue) { - newTab = tabElement as HTMLElement; - } - if (oldTab != null && newTab != null) { - break; - } - } + const oldTab = findTabElement(tabMap, oldValue); + const newTab = findTabElement(tabMap, newValue); if (oldTab == null || newTab == null) { // Fallback for dynamic tabs: when a tab element isn't registered yet @@ -412,31 +402,19 @@ function computeActivationDirection( (typeof oldValue === 'number' || typeof oldValue === 'string') && typeof oldValue === typeof newValue ) { - if (orientation === 'horizontal') { - return newValue > oldValue ? 'right' : 'left'; - } - return newValue > oldValue ? 'down' : 'up'; + return newValue > oldValue ? forward : backward; } return 'none'; } - const oldRect = oldTab.getBoundingClientRect(); - const newRect = newTab.getBoundingClientRect(); + const oldPosition = oldTab.getBoundingClientRect()[positionProp]; + const newPosition = newTab.getBoundingClientRect()[positionProp]; - if (orientation === 'horizontal') { - if (newRect.left < oldRect.left) { - return 'left'; - } - if (newRect.left > oldRect.left) { - return 'right'; - } - } else { - if (newRect.top < oldRect.top) { - return 'up'; - } - if (newRect.top > oldRect.top) { - return 'down'; - } + if (newPosition < oldPosition) { + return backward; + } + if (newPosition > oldPosition) { + return forward; } return 'none'; diff --git a/packages/react/src/tabs/root/TabsRootContext.ts b/packages/react/src/tabs/root/TabsRootContext.ts index 993ca7e85e1..18529b7e47c 100644 --- a/packages/react/src/tabs/root/TabsRootContext.ts +++ b/packages/react/src/tabs/root/TabsRootContext.ts @@ -28,11 +28,10 @@ export interface TabsRootContext { * Gets the `id` attribute of the TabPanel that corresponds to the given Tab value. */ getTabPanelIdByValue: (tabValue: TabsTab.Value) => string | undefined; - registerMountedTabPanel: (panelValue: TabsTab.Value | number, panelId: string) => void; + registerMountedTabPanel: (panelValue: TabsTab.Value | number, panelId: string) => () => void; setTabMap: ( map: Map, ) => void; - unregisterMountedTabPanel: (panelValue: TabsTab.Value | number, panelId: string) => void; /** * The position of the active tab relative to the previously active tab. */ diff --git a/packages/react/src/tabs/root/stateAttributesMapping.ts b/packages/react/src/tabs/root/stateAttributesMapping.ts index cdc2dea7c5d..0321769eef4 100644 --- a/packages/react/src/tabs/root/stateAttributesMapping.ts +++ b/packages/react/src/tabs/root/stateAttributesMapping.ts @@ -1,9 +1,8 @@ import type { TabsRootState } from './TabsRoot'; import type { StateAttributesMapping } from '../../internals/getStateAttributesProps'; -import { TabsRootDataAttributes } from './TabsRootDataAttributes'; export const tabsStateAttributesMapping: StateAttributesMapping = { tabActivationDirection: (dir) => ({ - [TabsRootDataAttributes.activationDirection]: dir, + 'data-activation-direction': dir, }), }; diff --git a/packages/react/src/tabs/tab/TabsTab.tsx b/packages/react/src/tabs/tab/TabsTab.tsx index 31193ae1193..c285061b5b4 100644 --- a/packages/react/src/tabs/tab/TabsTab.tsx +++ b/packages/react/src/tabs/tab/TabsTab.tsx @@ -8,6 +8,7 @@ import type { BaseUIComponentProps, NativeButtonProps } from '../../internals/ty import { useButton } from '../../internals/use-button'; import { ACTIVE_COMPOSITE_ITEM } from '../../internals/composite/constants'; import { useCompositeItem } from '../../internals/composite/item/useCompositeItem'; +import { useCompositeRootContext } from '../../internals/composite/root/CompositeRootContext'; import type { TabsRoot } from '../root/TabsRoot'; import { useTabsRootContext } from '../root/TabsRootContext'; import { tabsStateAttributesMapping } from '../root/stateAttributesMapping'; @@ -40,18 +41,15 @@ export const TabsTab = React.forwardRef(function TabsTab( const { value: activeTabValue, getTabPanelIdByValue, + onValueChange, orientation, tabActivationDirection, } = useTabsRootContext(); - const { - activateOnFocus, - highlightedTabIndex, - onTabActivation, - registerTabResizeObserverElement, - setHighlightedTabIndex, - tabsListElement, - } = useTabsListContext(); + const { activateOnFocus, registerTabResizeObserverElement, tabsListElement } = + useTabsListContext(); + + const { highlightedIndex, onHighlightedIndexChange } = useCompositeRootContext(); const id = useBaseUiId(idProp); @@ -89,7 +87,7 @@ export const TabsTab = React.forwardRef(function TabsTab( return; } - if (!(active && index > -1 && highlightedTabIndex !== index)) { + if (!(active && index > -1 && highlightedIndex !== index)) { return; } @@ -107,9 +105,9 @@ export const TabsTab = React.forwardRef(function TabsTab( // Don't highlight disabled tabs to prevent them from interfering with keyboard navigation. // Keyboard focus (tabIndex) should remain on an enabled tab even when a disabled tab is selected. if (!disabled) { - setHighlightedTabIndex(index); + onHighlightedIndexChange(index); } - }, [active, index, highlightedTabIndex, setHighlightedTabIndex, disabled, tabsListElement]); + }, [active, index, highlightedIndex, onHighlightedIndexChange, disabled, tabsListElement]); const { getButtonProps, buttonRef } = useButton({ disabled, @@ -122,12 +120,9 @@ export const TabsTab = React.forwardRef(function TabsTab( const isPressingRef = React.useRef(false); const isMainButtonRef = React.useRef(false); - function onClick(event: React.MouseEvent) { - if (active || disabled) { - return; - } - - onTabActivation( + // Both callers guard on `!active`, so the current value is never re-committed. + function activate(event: React.SyntheticEvent) { + onValueChange( value, createChangeEventDetails(REASONS.none, event.nativeEvent, undefined, { activationDirection: 'none', @@ -135,17 +130,16 @@ export const TabsTab = React.forwardRef(function TabsTab( ); } - function onFocus(event: React.FocusEvent) { - if (active) { + function onClick(event: React.MouseEvent) { + if (active || disabled) { return; } - // Only highlight enabled tabs when focused (disabled tabs remain focusable via focusableWhenDisabled). - if (index > -1 && !disabled) { - setHighlightedTabIndex(index); - } + activate(event); + } - if (disabled) { + function onFocus(event: React.FocusEvent) { + if (active || disabled) { return; } @@ -154,12 +148,7 @@ export const TabsTab = React.forwardRef(function TabsTab( (!isPressingRef.current || // keyboard or touch focus (isPressingRef.current && isMainButtonRef.current)) // mouse focus ) { - onTabActivation( - value, - createChangeEventDetails(REASONS.none, event.nativeEvent, undefined, { - activationDirection: 'none', - }), - ); + activate(event); } } @@ -175,7 +164,7 @@ export const TabsTab = React.forwardRef(function TabsTab( isMainButtonRef.current = false; } - if (!event.button || event.button === 0) { + if (!event.button) { isMainButtonRef.current = true; const doc = ownerDocument(event.currentTarget); diff --git a/packages/react/src/toggle-group/ToggleGroup.tsx b/packages/react/src/toggle-group/ToggleGroup.tsx index 8d3371d67d5..8057e7a6b0e 100644 --- a/packages/react/src/toggle-group/ToggleGroup.tsx +++ b/packages/react/src/toggle-group/ToggleGroup.tsx @@ -46,7 +46,7 @@ export const ToggleGroup = React.forwardRef(function ToggleGroup valueProp !== undefined || defaultValueProp !== undefined, diff --git a/packages/react/src/toolbar/button/ToolbarButton.test.tsx b/packages/react/src/toolbar/button/ToolbarButton.test.tsx index 144c3f25629..5cc6a6ad278 100644 --- a/packages/react/src/toolbar/button/ToolbarButton.test.tsx +++ b/packages/react/src/toolbar/button/ToolbarButton.test.tsx @@ -25,7 +25,6 @@ const testCompositeContext: CompositeRootContext = { const testToolbarContext: ToolbarRootContext = { disabled: false, orientation: 'horizontal', - setItemMap: NOOP, }; describe('', () => { diff --git a/packages/react/src/toolbar/button/ToolbarButton.tsx b/packages/react/src/toolbar/button/ToolbarButton.tsx index e4499a3637a..3e24da370f0 100644 --- a/packages/react/src/toolbar/button/ToolbarButton.tsx +++ b/packages/react/src/toolbar/button/ToolbarButton.tsx @@ -23,14 +23,14 @@ export const ToolbarButton = React.forwardRef(function ToolbarButton( disabled: disabledProp = false, focusableWhenDisabled = true, render, - nativeButton = true, + nativeButton, style, ...elementProps } = componentProps; const { disabled: toolbarDisabled, orientation } = useToolbarRootContext(); - const groupContext = useToolbarGroupContext(true); + const groupContext = useToolbarGroupContext(); const disabled = toolbarDisabled || (groupContext?.disabled ?? false) || disabledProp; diff --git a/packages/react/src/toolbar/group/ToolbarGroup.test.tsx b/packages/react/src/toolbar/group/ToolbarGroup.test.tsx index 7fcdd245b6f..04d2da926ee 100644 --- a/packages/react/src/toolbar/group/ToolbarGroup.test.tsx +++ b/packages/react/src/toolbar/group/ToolbarGroup.test.tsx @@ -16,7 +16,6 @@ const testCompositeContext: CompositeRootContext = { const testToolbarContext: ToolbarRootContext = { disabled: false, orientation: 'horizontal', - setItemMap: NOOP, }; describe('', () => { diff --git a/packages/react/src/toolbar/group/ToolbarGroupContext.ts b/packages/react/src/toolbar/group/ToolbarGroupContext.ts index 18d26fd94c7..aed5f11c7fe 100644 --- a/packages/react/src/toolbar/group/ToolbarGroupContext.ts +++ b/packages/react/src/toolbar/group/ToolbarGroupContext.ts @@ -7,14 +7,6 @@ export interface ToolbarGroupContext { export const ToolbarGroupContext = React.createContext(undefined); -export function useToolbarGroupContext(optional?: false): ToolbarGroupContext; -export function useToolbarGroupContext(optional: true): ToolbarGroupContext | undefined; -export function useToolbarGroupContext(optional?: boolean) { - const context = React.useContext(ToolbarGroupContext); - if (context === undefined && !optional) { - throw new Error( - 'Base UI: ToolbarGroupContext is missing. ToolbarGroup parts must be placed within .', - ); - } - return context; +export function useToolbarGroupContext(): ToolbarGroupContext | undefined { + return React.useContext(ToolbarGroupContext); } diff --git a/packages/react/src/toolbar/input/ToolbarInput.test.tsx b/packages/react/src/toolbar/input/ToolbarInput.test.tsx index b99bc69b930..d6eb6e97ab3 100644 --- a/packages/react/src/toolbar/input/ToolbarInput.test.tsx +++ b/packages/react/src/toolbar/input/ToolbarInput.test.tsx @@ -19,7 +19,6 @@ const testCompositeContext: CompositeRootContext = { const testToolbarContext: ToolbarRootContext = { disabled: false, orientation: 'horizontal', - setItemMap: NOOP, }; describe('', () => { diff --git a/packages/react/src/toolbar/input/ToolbarInput.tsx b/packages/react/src/toolbar/input/ToolbarInput.tsx index 1906bb295f9..464f1f583e3 100644 --- a/packages/react/src/toolbar/input/ToolbarInput.tsx +++ b/packages/react/src/toolbar/input/ToolbarInput.tsx @@ -28,7 +28,7 @@ export const ToolbarInput = React.forwardRef(function ToolbarInput( const { disabled: toolbarDisabled, orientation } = useToolbarRootContext(); - const groupContext = useToolbarGroupContext(true); + const groupContext = useToolbarGroupContext(); const disabled = toolbarDisabled || (groupContext?.disabled ?? false) || disabledProp; @@ -50,17 +50,15 @@ export const ToolbarInput = React.forwardRef(function ToolbarInput( focusable: focusableWhenDisabled, }; + const preventWhenDisabled = (event: React.SyntheticEvent) => { + if (disabled) { + event.preventDefault(); + } + }; + const defaultProps: HTMLProps = { - onClick(event) { - if (disabled) { - event.preventDefault(); - } - }, - onPointerDown(event) { - if (disabled) { - event.preventDefault(); - } - }, + onClick: preventWhenDisabled, + onPointerDown: preventWhenDisabled, }; return ( diff --git a/packages/react/src/toolbar/link/ToolbarLink.test.tsx b/packages/react/src/toolbar/link/ToolbarLink.test.tsx index ebd42603fff..42a00423b80 100644 --- a/packages/react/src/toolbar/link/ToolbarLink.test.tsx +++ b/packages/react/src/toolbar/link/ToolbarLink.test.tsx @@ -16,7 +16,6 @@ const testCompositeContext: CompositeRootContext = { const testToolbarContext: ToolbarRootContext = { disabled: false, orientation: 'horizontal', - setItemMap: NOOP, }; describe('', () => { diff --git a/packages/react/src/toolbar/root/ToolbarRoot.tsx b/packages/react/src/toolbar/root/ToolbarRoot.tsx index e3977121ae1..9458913527b 100644 --- a/packages/react/src/toolbar/root/ToolbarRoot.tsx +++ b/packages/react/src/toolbar/root/ToolbarRoot.tsx @@ -21,7 +21,7 @@ export const ToolbarRoot = React.forwardRef(function ToolbarRoot( ) { const { disabled = false, - loopFocus = true, + loopFocus, orientation = 'horizontal', className, render, @@ -53,9 +53,8 @@ export const ToolbarRoot = React.forwardRef(function ToolbarRoot( () => ({ disabled, orientation, - setItemMap, }), - [disabled, orientation, setItemMap], + [disabled, orientation], ); const state: ToolbarRootState = { disabled, orientation }; diff --git a/packages/react/src/toolbar/root/ToolbarRootContext.ts b/packages/react/src/toolbar/root/ToolbarRootContext.ts index b2686a42d8b..3f15ed65b76 100644 --- a/packages/react/src/toolbar/root/ToolbarRootContext.ts +++ b/packages/react/src/toolbar/root/ToolbarRootContext.ts @@ -1,15 +1,10 @@ 'use client'; import * as React from 'react'; import type { Orientation } from '../../internals/types'; -import type { CompositeMetadata } from '../../internals/composite/list/CompositeList'; -import type { ToolbarRoot } from './ToolbarRoot'; export interface ToolbarRootContext { disabled: boolean; orientation: Orientation; - setItemMap: React.Dispatch< - React.SetStateAction | null>> - >; } export const ToolbarRootContext = React.createContext(undefined); diff --git a/packages/react/src/toolbar/separator/ToolbarSeparator.tsx b/packages/react/src/toolbar/separator/ToolbarSeparator.tsx index ba195dea2e4..63b4c879898 100644 --- a/packages/react/src/toolbar/separator/ToolbarSeparator.tsx +++ b/packages/react/src/toolbar/separator/ToolbarSeparator.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import type { BaseUIComponentProps, Orientation } from '../../internals/types'; import { Separator, type SeparatorState } from '../../separator'; import { useToolbarRootContext } from '../root/ToolbarRootContext'; -import type { ToolbarRoot } from '../root/ToolbarRoot'; /** * A separator element accessible to screen readers. @@ -17,12 +16,7 @@ export const ToolbarSeparator = React.forwardRef(function ToolbarSeparator( ) { const context = useToolbarRootContext(); - const orientation = ( - { - vertical: 'horizontal', - horizontal: 'vertical', - } as Record - )[context.orientation]; + const orientation = context.orientation === 'vertical' ? 'horizontal' : 'vertical'; return ; }); From ba9f57d6ac83dbea0d711662656ac08af80d182b Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 13 Jul 2026 20:53:14 +1000 Subject: [PATCH 2/4] [tabs] Pin inlined enums with a runtime sync test --- packages/react/src/tabs/enumSync.test.tsx | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/react/src/tabs/enumSync.test.tsx diff --git a/packages/react/src/tabs/enumSync.test.tsx b/packages/react/src/tabs/enumSync.test.tsx new file mode 100644 index 00000000000..a4e4e9abd6a --- /dev/null +++ b/packages/react/src/tabs/enumSync.test.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { expect } from 'vitest'; +import { Tabs } from '@base-ui/react/tabs'; +import { screen } from '@mui/internal-test-utils'; +import { createRenderer } from '#test-utils'; +import { tabsStateAttributesMapping } from './root/stateAttributesMapping'; +import { TabsRootDataAttributes } from './root/TabsRootDataAttributes'; +import { TabsPanelDataAttributes } from './panel/TabsPanelDataAttributes'; +import { TabsIndicatorCssVars } from './indicator/TabsIndicatorCssVars'; + +// The parts inline these enums' string values at runtime (instead of referencing +// the members) so the enum objects stay tree-shakeable — the win that motivated +// the bundle-size pass. That splits the source of truth: the enums feed generated +// public documentation while the literals drive behavior, and TypeScript no longer +// links the two. These tests re-establish the link so a rename applied to only one +// side fails CI. They import the enums but ship no production bytes. +describe('Tabs docs enum / runtime sync', () => { + const { render } = createRenderer(); + + it('names the activation-direction attribute per TabsRootDataAttributes', () => { + const emitted = tabsStateAttributesMapping.tabActivationDirection!('none'); + expect(Object.keys(emitted!)[0]).toBe(TabsRootDataAttributes.activationDirection); + }); + + it('names the panel index attribute per TabsPanelDataAttributes', async () => { + await render( + + + + + + , + ); + + expect(screen.getByTestId('panel')).toHaveAttribute(TabsPanelDataAttributes.index); + }); + + it('names the indicator CSS variables per TabsIndicatorCssVars', async () => { + await render( + + + + + + , + ); + + // The indicator writes every variable through the `style` prop whenever a tab + // is selected, so the inline style carries them even without layout measurement. + const indicator = screen.getByTestId('indicator'); + const vars = [ + TabsIndicatorCssVars.activeTabLeft, + TabsIndicatorCssVars.activeTabRight, + TabsIndicatorCssVars.activeTabTop, + TabsIndicatorCssVars.activeTabBottom, + TabsIndicatorCssVars.activeTabWidth, + TabsIndicatorCssVars.activeTabHeight, + ] as const; + + for (const cssVar of vars) { + expect(indicator.style.getPropertyValue(cssVar)).not.toBe(''); + } + }); +}); From a533ab9f9ee8641b59ea4c46de399ef340cfa39d Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 13 Jul 2026 20:56:04 +1000 Subject: [PATCH 3/4] [tabs] Trim enum sync comment --- packages/react/src/tabs/enumSync.test.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/react/src/tabs/enumSync.test.tsx b/packages/react/src/tabs/enumSync.test.tsx index a4e4e9abd6a..834a3e5ef2f 100644 --- a/packages/react/src/tabs/enumSync.test.tsx +++ b/packages/react/src/tabs/enumSync.test.tsx @@ -8,13 +8,11 @@ import { TabsRootDataAttributes } from './root/TabsRootDataAttributes'; import { TabsPanelDataAttributes } from './panel/TabsPanelDataAttributes'; import { TabsIndicatorCssVars } from './indicator/TabsIndicatorCssVars'; -// The parts inline these enums' string values at runtime (instead of referencing -// the members) so the enum objects stay tree-shakeable — the win that motivated -// the bundle-size pass. That splits the source of truth: the enums feed generated -// public documentation while the literals drive behavior, and TypeScript no longer -// links the two. These tests re-establish the link so a rename applied to only one -// side fails CI. They import the enums but ship no production bytes. -describe('Tabs docs enum / runtime sync', () => { +// The parts inline these enums' values instead of referencing the members, so +// nothing links the docs enums to runtime behavior. These tests re-link every +// inlined member so a rename to only one side fails CI. Test-only imports ship no +// production bytes. +describe('Tabs enum sync', () => { const { render } = createRenderer(); it('names the activation-direction attribute per TabsRootDataAttributes', () => { From f260347a4d5789267a8ea07f35d41b1940701852 Mon Sep 17 00:00:00 2001 From: atomiks Date: Mon, 13 Jul 2026 22:24:37 +1000 Subject: [PATCH 4/4] [tabs][toolbar] Add interaction regressions --- .../react/src/tabs/root/TabsRoot.test.tsx | 115 ++++++++++++++++++ .../src/toolbar/input/ToolbarInput.test.tsx | 48 ++++++++ 2 files changed, 163 insertions(+) diff --git a/packages/react/src/tabs/root/TabsRoot.test.tsx b/packages/react/src/tabs/root/TabsRoot.test.tsx index 845e67cbfc9..6b7af9f5ca0 100644 --- a/packages/react/src/tabs/root/TabsRoot.test.tsx +++ b/packages/react/src/tabs/root/TabsRoot.test.tsx @@ -161,6 +161,73 @@ describe('', () => { expect(tabs[1]).toHaveAttribute('aria-controls', secondTabPanel.id); }); }); + + it('cleans and replaces panel registrations in Strict Mode', async () => { + function App() { + const [panel, setPanel] = React.useState({ id: 'panel-a', mounted: true, value: 'a' }); + + return ( + + + + + + + A + B + + {panel.mounted && } + + + ); + } + + const { user } = await render( + + + , + ); + const [tabA, tabB] = screen.getAllByRole('tab'); + + expect(tabA).toHaveAttribute( + 'aria-controls', + screen.getByRole('tabpanel', { hidden: true }).id, + ); + expect(tabB).not.toHaveAttribute('aria-controls'); + + await user.click(screen.getByRole('button', { name: 'replace' })); + expect(tabA).not.toHaveAttribute('aria-controls'); + expect(tabB).toHaveAttribute( + 'aria-controls', + screen.getByRole('tabpanel', { hidden: true }).id, + ); + + await user.click(screen.getByRole('button', { name: 'unmount' })); + expect(tabA).not.toHaveAttribute('aria-controls'); + expect(tabB).not.toHaveAttribute('aria-controls'); + + await user.click(screen.getByRole('button', { name: 'remount' })); + expect(tabA).not.toHaveAttribute('aria-controls'); + expect(tabB).toHaveAttribute( + 'aria-controls', + screen.getByRole('tabpanel', { hidden: true }).id, + ); + }); }); describe('prop: value', () => { @@ -2254,6 +2321,54 @@ describe('', () => { }); describe('highlight synchronization on external value change relative to focus', () => { + it.each([true, false])( + 'keeps controlled async activation and focus aligned with activateOnFocus=%s', + async (activateOnFocus) => { + const onValueChange = vi.fn(); + + function App() { + const [value, setValue] = React.useState(0); + return ( + { + onValueChange(nextValue); + Promise.resolve().then(() => setValue(nextValue)); + }} + > + + First + + Disabled + + Third + + + ); + } + + const { user } = await render(); + const [firstTab, disabledTab, thirdTab] = screen.getAllByRole('tab'); + + await act(async () => firstTab.focus()); + await user.keyboard('{ArrowRight}'); + expect(disabledTab).toHaveFocus(); + expect(firstTab).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{ArrowRight}'); + expect(thirdTab).toHaveFocus(); + + if (!activateOnFocus) { + expect(onValueChange).not.toHaveBeenCalled(); + await user.keyboard('{Enter}'); + } + + await waitFor(() => expect(thirdTab).toHaveAttribute('aria-selected', 'true')); + expect(thirdTab).toHaveFocus(); + expect(onValueChange).toHaveBeenCalledWith(2); + }, + ); + it('when focus is outside the tablist, highlight follows the new active tab (tabIndex=0 moves)', async () => { const { setProps } = await render( diff --git a/packages/react/src/toolbar/input/ToolbarInput.test.tsx b/packages/react/src/toolbar/input/ToolbarInput.test.tsx index d6eb6e97ab3..eb4f3dc926f 100644 --- a/packages/react/src/toolbar/input/ToolbarInput.test.tsx +++ b/packages/react/src/toolbar/input/ToolbarInput.test.tsx @@ -1,6 +1,7 @@ import { expect, vi } from 'vitest'; import { Toolbar } from '@base-ui/react/toolbar'; import { NumberField } from '@base-ui/react/number-field'; +import { DirectionProvider } from '@base-ui/react/direction-provider'; import { screen } from '@mui/internal-test-utils'; import { createRenderer, describeConformance, isJSDOM } from '#test-utils'; import { NOOP } from '../../internals/noop'; @@ -51,6 +52,53 @@ describe('', () => { }); describe.skipIf(isJSDOM)('keyboard navigation', () => { + it.each([ + ['ltr', ARROW_RIGHT, ARROW_LEFT], + ['rtl', ARROW_LEFT, ARROW_RIGHT], + ] as const)( + 'respects caret and selection boundaries in horizontal %s toolbars', + async (direction, nextKey, previousKey) => { + const { user } = await render( + + + + + + + , + ); + const input = screen.getByRole('textbox') as HTMLInputElement; + const before = screen.getByTestId('before'); + const after = screen.getByTestId('after'); + + await user.keyboard('[Tab]'); + await user.keyboard(`[${nextKey}]`); + expect(input).toHaveFocus(); + + input.setSelectionRange(1, 3); + await user.keyboard(`[${nextKey}]`); + expect(input).toHaveFocus(); + + input.setSelectionRange(2, 2); + await user.keyboard(`[ShiftLeft>][${nextKey}][/ShiftLeft]`); + expect(input).toHaveFocus(); + + const nextBoundary = + direction === 'rtl' || nextKey === ARROW_RIGHT ? input.value.length : 0; + input.setSelectionRange(nextBoundary, nextBoundary); + await user.keyboard(`[${nextKey}]`); + expect(after).toHaveFocus(); + + await user.keyboard(`[${previousKey}]`); + expect(input).toHaveFocus(); + const previousBoundary = + direction === 'rtl' || previousKey === ARROW_LEFT ? 0 : input.value.length; + input.setSelectionRange(previousBoundary, previousBoundary); + await user.keyboard(`[${previousKey}]`); + expect(before).toHaveFocus(); + }, + ); + // when navigating through RTL text in real browsers the arrow keys for // moving the text insertion cursor is also reversed from LTR but this doesn't // work with testing library