// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Tab.tsx * @input Uses React, StyleX, TabListContext * @output Exports Tab component and TabProps type * @position Core tab item; renders as button or anchor in navigation with a * divider-overlay selected indicator. Where the TabList speaks the tabs * pattern it is a button with role="tab". * * SYNC: When modified, update: * - /packages/core/src/TabList/TabList.doc.mjs * - /packages/core/src/TabList/index.ts * - /packages/core/src/TabList/TabList.test.tsx * - /packages/cli/assets/templates/blocks/components/TabList/ (showcase blocks) */ import React, { useCallback, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, sizeVars, radiusVars, durationVars, easeVars, fontWeightVars, typeScaleVars } from "../theme/tokens.stylex.js"; import type { BaseProps } from "../BaseProps.js"; import { useTabListContext } from "./TabListContext.js"; import type { TabListSize } from "./TabListContext.js"; import "./tab.markers.stylex.js"; import { tabScope } from "./tab.markers.stylex.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import type { LinkComponentType } from "../Link/types.js"; import { mergeProps } from "../utils/index.js"; import { useDevWarning } from "../hooks/useDevWarning.js"; import { EDGE_COMP_ATTR } from "../Layout/edgeCompensation.stylex.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; export interface TabProps extends BaseProps { /** * Custom component to render instead of `` for link tabs. * Overrides the provider-level default set by LinkProvider. * Only applies when `href` is provided. Must accept href, className, style, and children props. */ as?: LinkComponentType; ref?: React.Ref; /** * Unique value for this tab. Matched against TabListContext.value. */ value: string; /** * Accessible label for this tab. Used as visible text by default, or * as aria-label when isLabelHidden is true. */ label: string; /** * Whether the label is visually hidden. When true, only the icon and * endContent are displayed, and label is used as aria-label for accessibility. * @default false */ isLabelHidden?: boolean; /** * URL to navigate to. When provided, renders as an anchor element. * * Ignored in a TabList given an explicit `role="tablist"`: activating a tab * there swaps a panel in place, so a tab that navigates would be a false * statement. */ href?: string; /** * Id of the panel this tab controls, wired up as `aria-controls` where the * TabList speaks the tabs pattern. Put the same id on the panel element. * * Has no effect under the navigation pattern, where there is no panel to * associate — a development warning says so. */ panelId?: string; /** * Icon element shown when tab is not selected. */ icon?: ReactNode; /** * Icon element shown when tab is selected. Falls back to `icon` if not provided. */ selectedIcon?: ReactNode; /** * Content rendered after the label (e.g. a badge or status dot). */ endContent?: ReactNode; } // ============================================================================= // Styles // ============================================================================= const styles = { base: { kVAEAm: "x1n2onr6", k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kOIVth: "xzye2dw", kg3NbH: "xrrkdod", kWkggS: "xjbqb8w", kMzoRj: "xc342km", ksu8eU: "xng3xce", kaIpWk: "xh6dtrn", kMv6JI: "xjb2p0i", kGuDYH: "xcr08ib", kLWn49: "x1kq96og", k63SB2: "x1sodnla", kMwMTN: "xv1l7n4", kkrTdU: "x1ypdohk x16khyan", kybGjl: "x1hl2dhg", khDVqt: "xuxw1ft", k1ekBW: "xt3l3uh", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, hoverBg: { kVAEAm: "x10l6tqk", kpwlN0: "x10a8y8t", kogj98: "x1bpp3o7", kzqmXN: "xh8yej3", kaIpWk: "xh6dtrn", kfzvcC: "x47corl", kWkggS: "xjbqb8w x1a1zr74 x6ipy2d x74x3iv", k1ekBW: "x15406qy", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, selected: { kMwMTN: "x1tgivj0", k63SB2: "x2mo6ok", $$css: true as const }, icon: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", $$css: true as const } }; const sizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; // Hover bg uses the standard element size (one step smaller than tab) const hoverSizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; const layoutStyles = { fill: { kUk6DE: "x98rzlu", kjj79g: "xl56j7k", $$css: true as const } }; const iconSizeStyles = { sm: { kzqmXN: "x6jxa94", kZKoxP: "x1v9usgg", $$css: true as const }, md: { kzqmXN: "x1kky2od", kZKoxP: "xlup9mm", $$css: true as const }, lg: { kzqmXN: "x1xp8n7a", kZKoxP: "xmix8c7", $$css: true as const } }; /** * Tab item component. Renders as an anchor when `href` is provided, * otherwise as a button. * * @example * ``` * * * * * ``` */ export function Tab({ as, ref, value, label, isLabelHidden = false, href, panelId, icon, selectedIcon, endContent, xstyle, className, style, onClick, ...restProps }: TabProps) { const tabListCtx = useTabListContext(); const LinkComponent = useLinkComponent(as); const isSelected = tabListCtx.value === value; const size: TabListSize = tabListCtx.size; const isFill = tabListCtx.layout === 'fill'; const isTabsPattern = tabListCtx.pattern === 'tabs'; const isLink = href != null && !isTabsPattern; const isTabRole = isTabsPattern && !isLink; const displayIcon = isSelected && selectedIcon ? selectedIcon : icon; const hasVisibleLabel = !isLabelHidden && label !== ''; const isDisabled = restProps['aria-disabled'] === true || restProps['aria-disabled'] === 'true'; const handleSelect = useCallback((event: React.MouseEvent) => { if (isDisabled) { event.preventDefault(); return; } onClick?.(event as React.MouseEvent); if (!event.defaultPrevented) { tabListCtx.onChange(value); } }, [isDisabled, onClick, tabListCtx, value]); useDevWarning('Tab', 'href is ignored in a role="tablist" TabList — a tab swaps a panel in ' + 'place rather than navigating. Drop the href, or drop the role for the ' + 'navigation pattern.', isTabRole && href != null); // A consumer who wired aria-controls by hand already said which panel this // is, so panelId is the sugar, not the only way in. const controls = panelId ?? restProps['aria-controls']; useDevWarning('Tab', 'a tab in a role="tablist" TabList controls nothing: pass panelId with ' + 'the id of the panel it opens, so assistive technology can associate ' + 'the two.', isTabRole && controls == null); useDevWarning('Tab', 'panelId does nothing outside a role="tablist" TabList — the navigation ' + 'pattern has no panel to associate. Give the TabList role="tablist", ' + 'or drop the panelId.', !isTabsPattern && panelId != null); const iconElement = displayIcon ? {displayIcon} : null; const sharedProps = { ...restProps, ...(isLabelHidden ? { 'aria-label': label } : {}), [EDGE_COMP_ATTR]: '', 'data-tab-value': value, ...(isTabRole ? { role: 'tab' as const, 'aria-selected': isSelected, // Only when there is a panel to point at: an aria-controls whose // target does not exist is an invalid attribute value, which is a // worse state than saying nothing. The dev warning above asks for // the id instead. 'aria-controls': controls } : { // Generic `true` ("the current item within a set"), not `page`: the // strip switches views in place at least as often as it navigates, // and claiming "current page" when no page changed is a false // statement to a screen reader. Stays truthful for the `href` case // too, just less specific. A tab role states this with // aria-selected instead. 'aria-current': isSelected ? 'true' as const : undefined }), // Roving tabindex: the tab strip is a single Tab stop. The selected tab is // the tabbable one; the rest are reachable via arrow keys (handled by // TabList's onKeyDown). When no tab is selected, TabList's repair effect // makes the first stop tabbable. tabIndex: isSelected ? 0 : -1, ...mergeProps(themeProps('tab', { selected: isSelected ? 'selected' : null }), focusOutlineProps.focusVisible(styles.base, sizeStyles[size], isSelected && styles.selected, isFill && layoutStyles.fill, !isDisabled && tabScope, xstyle), className, style) }; const hoverBgElement =