// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Item.tsx * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer * @output Exports Item component, ItemProps type * @position Core layout primitive; consumed by index.ts, tested by Item.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Item/Item.doc.mjs * - /packages/core/src/Item/Item.test.tsx * - /packages/core/src/Item/index.ts * - /apps/storybook/stories/Item.stories.tsx * - /packages/cli/assets/templates/blocks/components/Item/ (showcase blocks) */ import { useId, useRef, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, radiusVars, spacingVars, durationVars, easeVars, typeScaleVars } from "../theme/tokens.stylex.js"; import type { BaseProps } from "../BaseProps.js"; import { isRenderable, mergeProps } from "../utils/index.js"; import { ItemDescriptionContext } from "./ItemDescriptionContext.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { computeTargetAndRel } from "../Link/computeTargetAndRel.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import { useClickableContainer } from "../hooks/useClickableContainer.js"; import { useDevWarning } from "../hooks/useDevWarning.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; // ============================================================================= // Types // ============================================================================= export type ItemAlign = 'center' | 'start'; export type ItemDensity = 'compact' | 'balanced' | 'spacious'; export interface ItemProps extends BaseProps { /** Ref forwarded to the root element. */ ref?: React.Ref; /** * HTML element to render as the root. * @default 'div' */ as?: 'div' | 'li' | 'span'; /** * Marker rendered before startContent as a direct flex child. * Use for list bullets/counters that need custom baseline alignment. */ marker?: ReactNode; /** * Content rendered before the label/description area. * Use for leading icons, avatars, or checkboxes. */ startContent?: ReactNode; /** * Primary text identifying this item. Required. * Accepts string (auto-styled) or ReactNode (for rich content). */ label: ReactNode; /** * Secondary text — subtitle, description, or supporting info. */ description?: ReactNode; /** * Content rendered after the label/description area. * Use for badges, metadata, timestamps, or action buttons. */ endContent?: ReactNode; /** * Vertical alignment of the start/end content slots. * @default 'center' */ align?: ItemAlign; /** * Density: "compact" (4px block padding), "balanced" (8px block padding), * or "spacious" (12px block and inline padding). * @default 'balanced' */ density?: ItemDensity; /** * Max lines before label truncates. When set, overflow is hidden * and text-overflow: ellipsis is applied. */ labelLines?: number; /** * Max lines before description truncates. When set, overflow is hidden * and text-overflow: ellipsis is applied. */ descriptionLines?: number; /** * How the label and description sit together. `stacked` puts the description * on its own line below the label; `inline` keeps both on one line, with the * description ellipsizing first, so the row fits a fixed-height host. * * @default 'stacked' */ layout?: 'stacked' | 'inline'; /** * Click handler. Makes the item clickable with button semantics. */ onClick?: (event: React.MouseEvent) => void; /** * Ref to a nested control inside the item (e.g. a checkbox in * `startContent`) that already provides the item's keyboard access and * action. When set, the item becomes an enlarged click/tap target that * delegates surface clicks to that control via the `useClickableContainer` * pattern: it renders no invisible button/anchor, so the row adds no second * tab stop (WCAG 4.1.2 — one focusable control per option). Clicks on the * control itself, and on any other nested interactive element, are left to * that element. Mutually exclusive with `onClick`/`href` — when * `interactiveRef` is set those are ignored (the nested control is the sole * action). */ interactiveRef?: React.RefObject; /** * Link URL. Makes the item a link via an invisible anchor element. */ href?: string; /** * Link target (e.g., '_blank'). Only used with href. */ target?: '_blank' | '_self'; /** * Link relationship. Automatically includes noopener noreferrer when * target is "_blank". */ rel?: string; /** * Highlighted state (hover/keyboard focus appearance). * @default false */ isHighlighted?: boolean; /** * Selected state. Always applies the selected visual styling. When `role` * permits it (option, tab, row, gridcell, columnheader, rowheader, treeitem) * the state is exposed as `aria-selected`; otherwise (e.g. a listitem or a * bare div, where `aria-selected` is invalid ARIA) it falls back to * `aria-current="true"` so assistive tech is still told which item is * selected. A consumer-provided `aria-current` always wins. * @default false */ isSelected?: boolean; /** * Disabled state. * @default false */ isDisabled?: boolean; /** * Test ID for testing frameworks. */ 'data-testid'?: string; } // ============================================================================= // Constants // ============================================================================= /** * Roles on which WAI-ARIA permits the aria-selected attribute. * https://www.w3.org/TR/wai-aria-1.2/#aria-selected */ const ARIA_SELECTED_ROLES = new Set(['option', 'tab', 'row', 'gridcell', 'columnheader', 'rowheader', 'treeitem']); // ============================================================================= // Styles // ============================================================================= const styles = { root: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", kg3NbH: "xf314gf", kVAEAm: "x1n2onr6", kB7OPa: "x9f619", k9WMMc: "x1yc453h", kaIpWk: "xh6dtrn", $$css: true as const }, alignStart: { kGNEyG: "x1cy8zhl", $$css: true as const }, interactive: { kkrTdU: "x1ypdohk x16khyan", k1ekBW: "x15406qy", kIyJzY: "xkvfbh3", kAMwcw: "xlr8y92", $$css: true as const }, highlighted: { kWkggS: "x1lmrjuc", $$css: true as const }, selected: { kWkggS: "xgcxg3y", $$css: true as const }, disabled: { kkrTdU: "xt0e3qv", kfzvcC: "x47corl", $$css: true as const }, inlineLabel: { kmuXW: "x2lah0s", $$css: true as const }, inlineDescription: { kmuXW: "xs83m0k", k7Eaqz: "xeuugli", $$css: true as const }, label: { kMwMTN: "x5tbw38", kGuDYH: "xjm74w1", kLWn49: "xw6l6zx", $$css: true as const }, labelSingleTruncate: { kVQacm: "xb3r6kr", kg5iWk: "xlyipyv", khDVqt: "xuxw1ft", $$css: true as const }, labelMultiTruncate: { kVQacm: "xb3r6kr", k1xSpc: "x104kibb", kgKLqz: "x1ua5tub", $$css: true as const }, description: { kMwMTN: "x1gb3s7i", kGuDYH: "x141an7d", kLWn49: "x1ltkj2j", $$css: true as const }, descriptionSingleTruncate: { kVQacm: "xb3r6kr", kg5iWk: "xlyipyv", khDVqt: "xuxw1ft", $$css: true as const }, descriptionMultiTruncate: { kVQacm: "xb3r6kr", k1xSpc: "x104kibb", kgKLqz: "x1ua5tub", $$css: true as const } }; const dynamicStyles = { lineClamp: (lines: number) => [{ kJFfOR: lines != null ? "x1yhjpo9" : lines, $$css: true as const }, { "--x-WebkitLineClamp": lines != null ? lines : undefined }] }; const densityStyles = { compact: { k8WAf4: "xu0wf1k", $$css: true as const }, balanced: { k8WAf4: "xce4md1", $$css: true as const }, spacious: { k8WAf4: "x8o8v82", kg3NbH: "xrrkdod", $$css: true as const } }; // ============================================================================= // Component // ============================================================================= /** * A universal item primitive that unifies the "start content + label + * description + end content" layout pattern. Use as a building block for list items, * menu items, contact rows, notification items, and more. * * @example * ``` * } * label={user.name} * description={user.role} * endContent={Admin} * onClick={() => navigate(`/users/${user.id}`)} * /> * ``` */ export function Item({ as: Component = 'div', marker, startContent, label, description, endContent, align = 'center', density = 'balanced', labelLines, descriptionLines, layout = 'stacked', onClick, interactiveRef, href, target: targetFromProps, rel: relFromProps, isHighlighted = false, isSelected = false, isDisabled = false, xstyle, className, style, ref, role, ...restProps }: ItemProps) { const LinkComponent = useLinkComponent(); // Delegation mode: the row is an enlarged click/tap target for a nested // control (e.g. a checkbox) that owns the keyboard access and action. The // control is the row's only tab stop; the row proxies surface clicks to it. const isDelegate = interactiveRef != null; const containerRef = useRef(null); // Only onClick is needed: onMouseUp handles middle-click href navigation, // which delegation mode never has (href is ignored here). const { onClick: delegatedOnClick } = useClickableContainer({ containerRef, interactiveRef: interactiveRef ?? undefined, disabled: isDisabled }); useDevWarning('Item', '`interactiveRef` is mutually exclusive with `onClick`/`href`. In ' + 'delegation mode the row only forwards clicks to the referenced control, ' + 'so `onClick`/`href` are ignored. Drop one of them.', isDelegate && (onClick != null || href != null)); const isInteractive = onClick != null || href != null || isDelegate; const { target, rel } = computeTargetAndRel(targetFromProps, relFromProps); // When a semantic role is provided (e.g. "menuitem"), a parent component // handles keyboard access. Skip the invisible button/anchor and put // onClick directly on the root element instead. const hasParentRole = role != null; // aria-selected is only valid on selectable roles (option, tab, treeitem, // grid cells). On the default div/li root the attribute is invalid ARIA // (axe: aria-allowed-attr), so selection stays visual-only there — callers // that need selection semantics pass a permitted role. const allowsAriaSelected = role != null && ARIA_SELECTED_ROLES.has(role); // The description element's id, published through ItemDescriptionContext so a // control Item renders in a slot can point at it with `aria-describedby`. // `isRenderable` rather than `!= null` so the common empty values — `null`, // `undefined`, `false`, `''` — publish no id and leave a consumer with no // dangling reference. It is a shallow check: content that renders nothing // only once React runs it, such as an empty fragment, still publishes an id. const descriptionID = useId(); const hasRenderableDescription = isRenderable(description); const isStringLabel = typeof label === 'string'; const isStringDescription = typeof description === 'string'; const labelTruncateStyle = labelLines != null ? labelLines === 1 ? styles.labelSingleTruncate : styles.labelMultiTruncate : isStringLabel ? styles.labelSingleTruncate : null; // Inline rows are one line by definition, so the description always // ellipsizes there — a ReactNode description cannot wrap the row open. const isInline = layout === 'inline' && description != null; const descriptionTruncateStyle = descriptionLines != null ? descriptionLines === 1 ? styles.descriptionSingleTruncate : styles.descriptionMultiTruncate : isStringDescription || isInline ? styles.descriptionSingleTruncate : null; const labelAndDescription = <> 1 && dynamicStyles.lineClamp(labelLines))}> {label} {description != null && 1 && dynamicStyles.lineClamp(descriptionLines))}> {description} } ; const handleContainerClick = (e: React.MouseEvent) => { if (isDisabled) { return; } const target = e.target as HTMLElement; if (target.closest('button, a, input, select, textarea')) { return; } onClick?.(e); }; const innerContent = <> {marker} {startContent != null && {startContent}} {hasParentRole || isDelegate ? // Delegation mode (and parent-role mode) put the label in a plain span: // keyboard access lives on the nested control, so no invisible // button/anchor is rendered and the row adds no second tab stop. {labelAndDescription} : href != null ? {labelAndDescription} : onClick != null ? : {labelAndDescription} } {endContent != null && {endContent} } ; const mergedRef = useMergedRefs(ref, containerRef); return } {...restProps} aria-selected={allowsAriaSelected && isSelected || undefined} // aria-selected is invalid on roles that don't permit it (listitem, a // bare div, etc.). For those, convey selection via aria-current — valid // on any element — so the state still reaches AT. Written after // {...restProps} so it must defer to a consumer-provided aria-current. aria-current={restProps['aria-current'] ?? (isSelected && !allowsAriaSelected ? true : undefined)} aria-disabled={isDisabled || undefined} {...mergeProps(themeProps('item', { density, align }), focusOutlineProps.focusWithin(styles.root, densityStyles[density], align === 'start' && styles.alignStart, isInteractive && styles.interactive, isInteractive && interactionOverlayStyles.backgroundColor, isHighlighted && styles.highlighted, isSelected && styles.selected, isDisabled && !hasParentRole && styles.disabled, xstyle), className, style)} role={role} onClick={isDelegate ? delegatedOnClick : hasParentRole ? onClick : isInteractive ? handleContainerClick : undefined}> {innerContent} ; } Item.displayName = 'Item';