// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file SelectableCard.tsx * @input Uses Card, useClickableContainer, StyleX * @output Exports SelectableCard component and SelectableCardProps * @position Interactive card for toggle selection * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/SelectableCard/SelectableCard.doc.mjs (props table, features) * - /packages/core/src/SelectableCard/index.ts (exports if types change) * - /apps/storybook/stories/SelectableCard.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Card/SelectableCardShowcase.tsx (showcase block) * - /packages/cli/assets/templates/blocks/components/Card/SelectableCardMulti.tsx (block) * - /packages/cli/assets/templates/blocks/components/Card/SelectableCardElevated.tsx (block) * * Composes Card for all visual styling. Adds selection state with * an inset box-shadow (zero layout jitter) and useClickableContainer * for safe nested interactive elements. * * A hidden inside the card provides the accessible * role, label, and checked state — the card surface itself has no role/tabIndex. * Space toggles it natively; Enter is wired up as an additional toggle key. * * For static display, use Card. * For navigation or action cards, use ClickableCard. */ import { type ReactNode, type MouseEvent, type KeyboardEvent, useRef, useCallback, type Ref } from 'react'; import * as stylex from "../style-runtime.js"; import type { StyleXStyles } from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import type { Elevation, SizeValue, SpacingStep } from "../utils/types.js"; import { mergeProps } from "../utils/index.js"; import { Card } from "../Card/Card.js"; import type { CardVariant } from "../Card/Card.js"; import { useClickableContainer } from "../hooks/useClickableContainer.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; // ============================================================================= // Styles — selection + interaction; Card handles the rest // ============================================================================= const styles = { interactive: { "--selectable-card-ring-color": "x11rj6l1", kVAEAm: "x1n2onr6", kkrTdU: "x1ypdohk x16khyan", k1ekBW: "x16mfhj0", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, overlay: { k5JduY: "x1s928wv", kwXMNM: "x1j6awrg", kv0HGH: "xarstr8", kUfP38: "x1k48kgn", kloYau: "x2q1x1w", kRicXK: "x1ywzrc5", kPNhGg: "x97pup0", kA8PQs: "x1dlmc9c", ks3ayO: "xyhc2n1", kAcZsS: "x1k7wiig", $$css: true as const }, hoverOnPointer: { k8t4tK: "x1912f9e", $$css: true as const }, disabled: { kkrTdU: "xt0e3qv", kSiTet: "xbyyjgo", $$css: true as const }, selected: { kVAM5u: "xad5do", "--_card-ring": "xr9ms2c", $$css: true as const }, selectedBlue: { kVAM5u: "xlee4gx", "--_card-ring": "xgwwt2v", $$css: true as const }, selectedCyan: { kVAM5u: "xb1xfl", "--_card-ring": "xcdi0x3", $$css: true as const }, selectedGray: { kVAM5u: "x1w9ec3u", "--_card-ring": "x1m1zkwp", $$css: true as const }, selectedGreen: { kVAM5u: "xy90a9n", "--_card-ring": "x1485227", $$css: true as const }, selectedOrange: { kVAM5u: "xloqurj", "--_card-ring": "x1cclxpp", $$css: true as const }, selectedPink: { kVAM5u: "x1czkbn8", "--_card-ring": "xzs6dn4", $$css: true as const }, selectedPurple: { kVAM5u: "xq98wne", "--_card-ring": "x9zgi83", $$css: true as const }, selectedRed: { kVAM5u: "xjqgoec", "--_card-ring": "xo8718j", $$css: true as const }, selectedTeal: { kVAM5u: "x1r9gxr1", "--_card-ring": "xp4e5fy", $$css: true as const }, selectedYellow: { kVAM5u: "x1kpnwn1", "--_card-ring": "x1glw7ee", $$css: true as const }, selectedUnknown: { "--_card-ring": "x3zsw4j", $$css: true as const } }; const selectedStyleForVariant = (variant: CardVariant) => { switch (variant) { case 'default': case 'transparent': case 'muted': return styles.selected; case 'blue': return styles.selectedBlue; case 'cyan': return styles.selectedCyan; case 'gray': return styles.selectedGray; case 'green': return styles.selectedGreen; case 'orange': return styles.selectedOrange; case 'pink': return styles.selectedPink; case 'purple': return styles.selectedPurple; case 'red': return styles.selectedRed; case 'teal': return styles.selectedTeal; case 'yellow': return styles.selectedYellow; // CardVariant is open — a theme can add a variant this switch has never // seen, and it still has to look selected. default: return styles.selectedUnknown; } }; // ============================================================================= // Props // ============================================================================= export interface SelectableCardProps extends Omit { /** Ref forwarded to the root element. */ ref?: Ref; /** * Accessibility label for the card. * Used as `aria-label` — provides the accessible name for screen readers. */ label: string; /** * Controlled selection state. * When true, the card shows an inset accent border indicating selection. */ isSelected: boolean; /** * Selection change handler. * Called with the new selection state when the card is toggled. */ onChange: (isSelected: boolean) => void; /** * Set to true to disable the card. * Disabled cards remain focusable (tabIndex 0) with aria-disabled * so screen reader users can discover them. */ isDisabled?: boolean; /** Content to render inside the card. */ children?: ReactNode; /** * Internal padding of the card using the spacing scale. * @default 4 (16px) */ padding?: SpacingStep; /** * Background color variant. * @default 'default' */ variant?: CardVariant; /** * Resting elevation — the shadow depth the card sits at. * The selection ring composes on top, so a selected card keeps its shadow. * @default 'none' */ elevation?: Elevation; /** Width of the card. */ width?: SizeValue; /** Height of the card. */ height?: SizeValue; /** Maximum width of the card. */ maxWidth?: SizeValue; } // ============================================================================= // Component // ============================================================================= /** * A card that toggles between selected and unselected states. * * Composes Card for visual styling and adds selection state with * an inset box-shadow (zero layout jitter vs plain Card). Supports * hover, pressed, focus, and disabled states. * * A visually-hidden inside the card provides * the accessible role, label, and checked state. The card surface * is a plain
— no role or tabIndex on the container. * * @compositionHint Use for multi-select or single-select card groups. * Manage selection state externally — use a Set for multi-select * or a single value for radio-style selection. * For navigation/action cards, use ClickableCard instead. * * @example * ``` * setSelected('a')}> * Option A * * ``` */ export function SelectableCard({ label, isSelected, onChange, onClick: onClickProp, onMouseUp: onMouseUpProp, isDisabled = false, children, padding, variant = 'default', elevation = 'none', width, height, maxWidth, ref, xstyle: xstyleProp, className: classNameProp, style, ...props }: SelectableCardProps) { const containerRef = useRef(null); const interactiveRef = useRef(null); const handleClick = useCallback((_event: MouseEvent) => { if (!isDisabled) { onChange(!isSelected); } }, [isDisabled, isSelected, onChange]); // The focusable control is a native checkbox, which toggles on Space but // ignores Enter. Add Enter as an extra toggle key; Space keeps its native // handling, so we deliberately do not toggle on Space here (would double). const handleKeyDown = useCallback((event: KeyboardEvent) => { if (!isDisabled && event.key === 'Enter') { event.preventDefault(); onChange(!isSelected); } }, [isDisabled, isSelected, onChange]); const { onClick, onMouseUp } = useClickableContainer({ containerRef, interactiveRef, onClick: handleClick, disabled: isDisabled }); const composedOnClick = onClickProp ? (e: MouseEvent) => { onClick(e); onClickProp(e); } : onClick; const composedOnMouseUp = onMouseUpProp ? (e: MouseEvent) => { onMouseUp(e); onMouseUpProp(e); } : onMouseUp; return onChange(!isSelected)} onKeyDown={handleKeyDown} className="x10l6tqk x1i1rx1s xjm9jq1 x1717udv xkdpibf xb3r6kr xzpqnlu xuxw1ft xc342km" /> {children} ; } SelectableCard.displayName = 'SelectableCard';