// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file RadioListItem.tsx * @input Uses React use, useId, useRef, RadioListContext, Item * @output Exports RadioListItem component, RadioListItemProps * @position Core implementation; consumed by index.ts, tested by RadioList.test.tsx * * Composes Item for the shared start content + label + description + end content layout. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/RadioList/RadioList.doc.mjs * - /packages/core/src/RadioList/RadioList.test.tsx * - /packages/core/src/RadioList/index.ts * - /apps/storybook/stories/RadioList.stories.tsx * - /packages/cli/assets/templates/blocks/components/RadioList/ (showcase blocks) */ import React, { use, useId, useRef, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import type { StyleXStyles } from "../style-runtime.js"; import type { BaseProps } from "../BaseProps.js"; import { RadioListContext } from "./RadioList.js"; import "../theme/tokens.stylex.js"; import { colorVars, radiusVars } from "../theme/tokens.stylex.js"; import { mergeProps, isRenderable, rtlStyles } from "../utils/index.js"; import "../Indicator/indicator.markers.stylex.js"; import { indicatorScope } from "../Indicator/indicator.markers.stylex.js"; import { useIndicatorFocusRing } from "../hooks/useIndicatorFocusRing.js"; import { useIndicator } from "../Indicator/index.js"; import { Item } from "../Item/index.js"; import { themeProps } from "../utils/themeProps.js"; const styles = { radioWrapper: { kVAEAm: "x1n2onr6", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", kHBbk8: "xc8icb0", $$css: true as const }, indicatorPressOverlay: { k5JduY: "x1s928wv", kwXMNM: "x1j6awrg", kv0HGH: "xarstr8", kUfP38: "xq94oqs", kloYau: "x2q1x1w", ks3ayO: "xyhc2n1 x2kv6q6", $$css: true as const }, input: { kVAEAm: "x10l6tqk", k87sOh: "xwa60dl", kogj98: "x1ghz6dp", kmVPX3: "x1717udv", kSiTet: "xg01cxk", kkrTdU: "x1ypdohk x16khyan", kY2c9j: "x1vjfegm", $$css: true as const }, inputCoarse: { kjFi6P: "xkagaj0", kHzOjL: "x80b3aj", $$css: true as const }, inputDisabled: { kkrTdU: "xt0e3qv", $$css: true as const } }; const wrapperSizeStyles = { sm: { kzqmXN: "xw4jnvo", kZKoxP: "x1qx5ct2", $$css: true as const }, md: { kzqmXN: "xvy4d1p", kZKoxP: "xxk0z11", $$css: true as const } }; const rowStyles = { root: { k8WAf4: "xt970qd", kg3NbH: "xnjsko4", kaIpWk: "x2u8bby", k7Eaqz: "xeuugli", kWkggS: "xjbqb8w", $$css: true as const } }; export interface RadioListItemProps extends BaseProps { ref?: React.Ref; /** * Primary label for the radio item. * * Accepts a plain string or a ReactNode for rich content. Links and buttons * in the label keep their own behavior; only non-interactive row clicks * delegate to the radio. The radio points at the rendered label for its * accessible name, so a rich label still names it from its own text. Pass * `aria-label` when that text is absent. When visible text is present, the * override must retain every visible word so speech-input users can say what * they see. */ label: ReactNode; /** * Plain-text accessible name for the radio, applied to the control rather * than the row. It replaces the name the label would otherwise supply. Use * it when a rich label has no visible text; otherwise the value must retain * every visible word. * * @example * ``` * Pro plan } * aria-label="Pro plan Recommended option" * value="pro" * /> * ``` */ 'aria-label'?: string; /** * Value of this radio item. */ value: string; /** * Secondary content displayed below the label. Accepts a plain string or a * ReactNode. Links and buttons anywhere in the row keep their own click * behaviour — the row only delegates clicks from its non-interactive * surface to the radio. */ description?: ReactNode; /** * Whether this individual radio item is disabled. * @default false */ isDisabled?: boolean; /** * Content to render before the radio circle. */ startContent?: ReactNode; /** * Content to render after the label. */ endContent?: ReactNode; } /** * An individual radio item within an RadioList. * * @example * ``` * * * ``` */ export function RadioListItem({ ref, label, 'aria-label': ariaLabel, value, description, isDisabled: isItemDisabled = false, startContent, endContent, xstyle, className, style, onClick, ...rest }: RadioListItemProps) { const context = use(RadioListContext); if (!context) { throw new Error('RadioListItem must be used within an RadioList'); } const id = useId(); const labelID = useId(); const descriptionID = useId(); const isDisabled = context.isDisabled || isItemDisabled; // When the whole group is disabled with a disabledMessage, radios stay // focusable via aria-disabled (instead of native `disabled`) so the group's // reason tooltip is keyboard-discoverable. Per-item disabling is unaffected // and always uses the native disabled attribute. const keepsFocusableForMessage = context.hasDisabledMessage && !isItemDisabled; const isChecked = context.value === value; // One predicate for both the rendered element and the aria-describedby that // points at it. A ReactNode description can be `false` or `''`, which render // nothing — a `!= null` check would leave the link dangling. const hasDescription = isRenderable(description); const size = context.size; // The radio visual is an indicator: a theme can restyle it through the // `radio` / `radio-dot` targets or replace the component outright. const RadioControl = useIndicator('radio'); // See CheckboxInput: the ring goes on the indicator's own element, because // the native input is visually hidden and only the indicator knows its shape. const indicatorRef = useRef(null); const { focusProps } = useIndicatorFocusRing(indicatorRef, isDisabled); // The radio is the row's single keyboard control and action. The row is an // enlarged click/tap target that delegates surface clicks — the description // and the empty hover area, not just the control and its label — to the // input via Item's `interactiveRef` (useClickableContainer). This matches // CheckboxListItem so the whole row is clickable, and keeps one tab stop per // option (WCAG 4.1.2). The radio carries its accessible name via `aria-label` // since the visible label is now a plain (non-`