// 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';