// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file SegmentedControl.tsx * @input Uses React, StyleX, SegmentedControlContext, useListFocus, useKeyboardHint * @output Exports SegmentedControl component and SegmentedControlProps type * @position Container wrapper; provides context to SegmentedControlItem children * * SYNC: When modified, update: * - /packages/core/src/SegmentedControl/SegmentedControl.doc.mjs * - /packages/core/src/SegmentedControl/index.ts * - /packages/core/src/SegmentedControl/SegmentedControl.test.tsx * - /packages/cli/assets/templates/blocks/components/SegmentedControl/ (showcase blocks) */ import React, { useMemo, useCallback, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars } from "../theme/tokens.stylex.js"; import { SegmentedControlContext } from "./SegmentedControlContext.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useKeyboardHint } from "../hooks/useKeyboardHint.js"; import { useTooltip } from "../Tooltip/index.js"; import type { SegmentedControlSize, SegmentedControlLayout } from "./SegmentedControlContext.js"; import { mergeProps, composeEventHandlers } from "../utils/index.js"; import { useSize } from "../SizeContext/SizeContext.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; export interface SegmentedControlProps extends Omit, 'onChange'> { ref?: React.Ref; /** * The currently selected value (controlled). */ value: string; /** * Callback fired when a segment is selected. */ onChange: (value: string) => void; /** * Accessible label for the radio group (used as aria-label, never rendered visually). */ label: string; /** * Size variant for the control. * @default 'md' */ size?: SegmentedControlSize; /** * Layout mode for segment sizing. * - `'hug'` (default): each segment hugs its content width. * - `'fill'`: segments stretch equally to fill the container width. * @default 'hug' */ layout?: SegmentedControlLayout; /** * Whether the entire control is disabled. * @default false */ isDisabled?: boolean; /** * Explains why the control is disabled. Applies to the whole-group disabled * state (`isDisabled`), not individual segments. When set together with * `isDisabled`, the control shows a tooltip with this text on hover and * keyboard focus, and stays focusable (via `aria-disabled`) so the reason is * discoverable by keyboard and assistive technology. Selection stays blocked. * * Use this instead of wrapping a disabled control in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. */ disabledMessage?: string; /** * SegmentedControlItem children. */ children: ReactNode; } // ============================================================================= // Styles // ============================================================================= const styles = { container: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kOIVth: "x1lsbc85", "--_segmented-control-padding": "x18jk3ff", kmVPX3: "xpoxszi", kWkggS: "x17x4s8c", $$css: true as const }, fill: { k1xSpc: "x78zum5", kzqmXN: "xh8yej3", $$css: true as const }, disabled: { kSiTet: "xbyyjgo", kfzvcC: "x47corl", $$css: true as const }, disabledWithMessage: { kSiTet: "xbyyjgo", $$css: true as const } }; const sizeStyles = { sm: { "--_segmented-control-radius": "x9icjy1", kaIpWk: "x1hapoqb", $$css: true as const }, md: { "--_segmented-control-radius": "x9icjy1", kaIpWk: "x1hapoqb", $$css: true as const }, lg: { "--_segmented-control-radius": "x9icjy1", kaIpWk: "x1hapoqb", $$css: true as const } }; /** * Segmented button group for single selection (radio group semantics). * Visually resembles a tab bar but controls a value, not a view. * * @example * ``` * * * * * * ``` */ export function SegmentedControl({ ref, value, onChange, label, size: sizeProp, layout = 'hug', isDisabled = false, disabledMessage, children, xstyle, className, style, onKeyDown: onKeyDownProp, onFocus: onFocusProp, onBlur: onBlurProp, ...rest }: SegmentedControlProps) { const size = useSize(sizeProp, 'md'); // Disabled-reason tooltip. Applies to the whole-group disabled state. Disabled // controls swallow pointer events, so the tooltip listeners attach to the // radiogroup container (and the container keeps pointer events on in this // mode) while the radios stay perceivable via aria-disabled. Selection is // blocked by the isDisabled guards in the click/focus handlers. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The radiogroup container is not naturally focusable; focusin bubbles up // from the radios, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); // Roving tabindex + arrow/Home/End navigation is owned by the shared // useListFocus primitive: it stamps a single tab stop (tabIndex 0/-1) across // the radios, skips disabled ones, wraps at the ends, handles Home/End, and // repairs the tab stop on mount and whenever items mount/disable — replacing // the component's former inline keyboard handler and tab-stop repair effect. const { listRef, handleKeyDown, handleFocus } = useListFocus({ itemSelector: '[role="radio"]:not([aria-disabled="true"])', hasRovingTabIndex: true, wrap: true, orientation: 'horizontal' }); const hint = useKeyboardHint({ orientation: 'horizontal', isEnabled: !isDisabled }); const handleContainerKeyDown = useCallback((e: React.KeyboardEvent) => { onKeyDownProp?.(e); if (e.defaultPrevented) { return; } hint.onKeyDown(e); handleKeyDown(e); }, [onKeyDownProp, hint, handleKeyDown]); // Selection-follows-focus (APG radiogroup): useListFocus only *moves* focus, // so whenever it lands focus on a new radio (arrow/Home/End, or a click) we // select that radio's value. Reading the focused element's data-value here // keeps selection in lockstep with focus without duplicating the navigation // logic. Disabled radios are ignored (they should never become the value), // and the already-selected value is skipped so an initial Tab-in (or a click // on the current segment) is a no-op, matching click behavior. const handleContainerFocus = useCallback((e: React.FocusEvent) => { onFocusProp?.(e); if (e.defaultPrevented) { return; } hint.onFocus(e); handleFocus(e); if (isDisabled) { return; } // Only select when focus moved WITHIN the group (arrow/Home/End — APG // selection-follows-focus). When focus is entering from outside, Tab // must stay a pure focus move: with an unmatched or disabled-selected // value the roving tab stop falls back to the first enabled radio, and // selecting it here would rewrite the form value on mere traversal. // Clicks are unaffected: the item's own handleClick selects. if (!e.currentTarget.contains(e.relatedTarget)) { return; } const focused = (e.target as HTMLElement | null)?.closest('[role="radio"][data-value]'); if (!focused || focused.getAttribute('aria-disabled') === 'true') { return; } const nextValue = focused.dataset.value; if (nextValue != null && nextValue !== value) { onChange(nextValue); } }, [onFocusProp, hint, handleFocus, isDisabled, onChange, value]); const contextValue = useMemo(() => ({ value, onChange, size, layout, isDisabled, hasDisabledMessage: showsDisabledMessage }), [value, onChange, size, layout, isDisabled, showsDisabledMessage]); return
{children} {hint.hintElement}
{showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; } SegmentedControl.displayName = 'SegmentedControl';