diff --git a/.changeset/complex-selector.md b/.changeset/complex-selector.md new file mode 100644 index 000000000000..3851361cc56c --- /dev/null +++ b/.changeset/complex-selector.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] ComplexSelector: add a rich custom selector shell with accessible button/popover behavior, async change actions, and optional grid keyboard navigation. +@cixzhang diff --git a/apps/storybook/stories/ComplexSelector.stories.tsx b/apps/storybook/stories/ComplexSelector.stories.tsx new file mode 100644 index 000000000000..797fb63beac0 --- /dev/null +++ b/apps/storybook/stories/ComplexSelector.stories.tsx @@ -0,0 +1,730 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import type {Meta, StoryObj} from '@storybook/react'; +import {useEffect, useMemo, useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {ComplexSelector} from '@astryxdesign/core/ComplexSelector'; +import {Button} from '@astryxdesign/core/Button'; +import {Text} from '@astryxdesign/core/Text'; +import {TextInput} from '@astryxdesign/core/TextInput'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; +import {Token} from '@astryxdesign/core/Token'; +import {TreeList, type TreeListItemData} from '@astryxdesign/core/TreeList'; +import {useGridFocus} from '@astryxdesign/core/hooks'; +import { + borderVars, + colorVars, + durationVars, + easeVars, + fontWeightVars, + radiusVars, + shadowVars, + spacingVars, + typeScaleVars, +} from '@astryxdesign/core/theme/tokens.stylex'; + +const GRID_CELL_SELECTOR = '[role="gridcell"]'; + +const meta: Meta = { + title: 'Core/ComplexSelector', + component: ComplexSelector, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'A high-level selector shell for rich custom content. The component owns the field, trigger, popover, focus restore, and async changeAction flow while consumers render the content. Custom content should use Astryx focus hooks where appropriate and be evaluated against WCAG 2.2.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +type Fruit = 'Apple' | 'Pear' | 'Peach' | 'Plum'; +type Ripeness = 'Crisp' | 'Tender' | 'Juicy' | 'Peak'; + +type FruitValue = { + fruit: Fruit; + ripeness: Ripeness; +}; + +const fruits: Array<{ + id: Fruit; + emoji: string; + description: string; +}> = [ + {id: 'Apple', emoji: '๐ŸŽ', description: 'Bright and balanced'}, + {id: 'Pear', emoji: '๐Ÿ', description: 'Soft floral sweetness'}, + {id: 'Peach', emoji: '๐Ÿ‘', description: 'Round summer flavor'}, + {id: 'Plum', emoji: '๐ŸŸฃ', description: 'Jammy and tart'}, +]; + +const ripenessLevels: Array<{ + id: Ripeness; + shortLabel: string; + description: string; +}> = [ + {id: 'Crisp', shortLabel: 'C', description: 'Snappy bite'}, + {id: 'Tender', shortLabel: 'T', description: 'Easy bite'}, + {id: 'Juicy', shortLabel: 'J', description: 'Full juice'}, + {id: 'Peak', shortLabel: 'P', description: 'Most intense'}, +]; + +type DestinationValue = { + id: string; + label: string; + path: string; +}; + +interface DestinationNode { + id: string; + label: string; + path: string; + kind: 'folder' | 'space' | 'team'; + isExpanded?: boolean; + children?: DestinationNode[]; +} + +const destinationTree: DestinationNode[] = [ + { + id: 'workspace', + label: 'Workspace', + path: '/Workspace', + kind: 'space', + children: [ + { + id: 'workspace-research', + label: 'Research', + path: '/Workspace/Research', + kind: 'folder', + children: [ + { + id: 'workspace-research-field-notes', + label: 'Field notes', + path: '/Workspace/Research/Field notes', + kind: 'folder', + }, + { + id: 'workspace-research-interviews', + label: 'Interviews', + path: '/Workspace/Research/Interviews', + kind: 'folder', + }, + ], + }, + { + id: 'workspace-roadmap', + label: 'Roadmap', + path: '/Workspace/Roadmap', + kind: 'folder', + }, + ], + }, + { + id: 'teams', + label: 'Teams', + path: '/Teams', + kind: 'space', + children: [ + { + id: 'teams-design-systems', + label: 'Design systems', + path: '/Teams/Design systems', + kind: 'team', + children: [ + { + id: 'teams-design-systems-components', + label: 'Components', + path: '/Teams/Design systems/Components', + kind: 'folder', + }, + { + id: 'teams-design-systems-accessibility', + label: 'Accessibility', + path: '/Teams/Design systems/Accessibility', + kind: 'folder', + }, + ], + }, + { + id: 'teams-growth', + label: 'Growth', + path: '/Teams/Growth', + kind: 'team', + }, + ], + }, + { + id: 'archive', + label: 'Archive', + path: '/Archive', + kind: 'space', + children: [ + { + id: 'archive-2025', + label: '2025 projects', + path: '/Archive/2025 projects', + kind: 'folder', + }, + ], + }, +]; + +const categoryTree: DestinationNode[] = [ + { + id: 'produce', + label: 'Produce', + path: 'Produce', + kind: 'space', + children: [ + { + id: 'produce-fruit', + label: 'Fruit', + path: 'Produce / Fruit', + kind: 'folder', + children: [ + { + id: 'produce-fruit-citrus', + label: 'Citrus', + path: 'Produce / Fruit / Citrus', + kind: 'folder', + }, + { + id: 'produce-fruit-stone', + label: 'Stone fruit', + path: 'Produce / Fruit / Stone fruit', + kind: 'folder', + }, + ], + }, + { + id: 'produce-vegetables', + label: 'Vegetables', + path: 'Produce / Vegetables', + kind: 'folder', + }, + ], + }, + { + id: 'pantry', + label: 'Pantry', + path: 'Pantry', + kind: 'space', + children: [ + { + id: 'pantry-grains', + label: 'Grains', + path: 'Pantry / Grains', + kind: 'folder', + }, + { + id: 'pantry-snacks', + label: 'Snacks', + path: 'Pantry / Snacks', + kind: 'folder', + }, + ], + }, +]; + +const styles = stylex.create({ + wrapper: { + width: 340, + }, + fruitContent: { + width: 500, + padding: spacingVars['--spacing-2'], + }, + treeContent: { + width: 420, + padding: spacingVars['--spacing-3'], + }, + intro: { + marginBlockEnd: spacingVars['--spacing-3'], + }, + thinkingSurface: { + display: 'flex', + flexDirection: 'column', + gap: spacingVars['--spacing-1'], + }, + thinkingRow: { + display: 'grid', + gridTemplateColumns: 'minmax(156px, 1fr) repeat(4, 56px)', + alignItems: 'center', + columnGap: spacingVars['--spacing-1'], + minHeight: 48, + paddingBlock: spacingVars['--spacing-1'], + paddingInline: spacingVars['--spacing-2'], + borderRadius: radiusVars['--radius-container'], + backgroundColor: { + default: 'transparent', + ':hover': { + '@media (hover: hover)': colorVars['--color-background-muted'], + }, + ':focus-within': colorVars['--color-background-muted'], + }, + }, + fruitSummary: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-2'], + minWidth: 0, + }, + fruitEmoji: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + borderRadius: radiusVars['--radius-full'], + backgroundColor: colorVars['--color-background-muted'], + fontSize: 17, + flexShrink: 0, + }, + fruitText: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + fruitName: { + color: colorVars['--color-text-primary'], + fontSize: typeScaleVars['--text-label-size'], + fontWeight: fontWeightVars['--font-weight-semibold'], + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + fruitDescription: { + color: colorVars['--color-text-secondary'], + fontSize: typeScaleVars['--text-supporting-size'], + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + levelButton: { + borderWidth: borderVars['--border-width'], + borderStyle: 'solid', + borderColor: colorVars['--color-border'], + borderRadius: radiusVars['--radius-full'], + backgroundColor: colorVars['--color-background-card'], + color: colorVars['--color-text-secondary'], + minHeight: 30, + paddingInline: spacingVars['--spacing-2'], + fontFamily: 'inherit', + fontSize: typeScaleVars['--text-supporting-size'], + fontWeight: fontWeightVars['--font-weight-medium'], + cursor: 'pointer', + opacity: 0.68, + transitionProperty: + 'opacity, background-color, border-color, color, box-shadow', + transitionDuration: durationVars['--duration-fast'], + transitionTimingFunction: easeVars['--ease-standard'], + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--color-accent']}`, + }, + outlineOffset: 2, + ':hover': { + '@media (hover: hover)': { + opacity: 1, + borderColor: colorVars['--color-border-emphasized'], + color: colorVars['--color-text-primary'], + }, + }, + }, + selectedLevelButton: { + opacity: 1, + borderColor: colorVars['--color-accent'], + backgroundColor: colorVars['--color-accent'], + color: colorVars['--color-on-accent'], + boxShadow: shadowVars['--shadow-low'], + }, + keyboardHint: { + marginBlockStart: spacingVars['--spacing-3'], + paddingBlockStart: spacingVars['--spacing-3'], + borderBlockStartWidth: borderVars['--border-width'], + borderBlockStartStyle: 'solid', + borderBlockStartColor: colorVars['--color-border'], + }, + searchArea: { + marginBlockEnd: spacingVars['--spacing-3'], + }, + treePanel: { + maxHeight: 280, + overflow: 'auto', + borderWidth: borderVars['--border-width'], + borderStyle: 'solid', + borderColor: colorVars['--color-border'], + borderRadius: radiusVars['--radius-container'], + padding: spacingVars['--spacing-1'], + }, + selectedSummary: { + marginBlockStart: spacingVars['--spacing-3'], + paddingBlockStart: spacingVars['--spacing-3'], + borderBlockStartWidth: borderVars['--border-width'], + borderBlockStartStyle: 'solid', + borderBlockStartColor: colorVars['--color-border'], + }, + emptyState: { + padding: spacingVars['--spacing-3'], + color: colorVars['--color-text-secondary'], + textAlign: 'center', + }, +}); + +function formatFruitValue(value: FruitValue) { + return `${value.fruit} ยท ${value.ripeness}`; +} + +function formatDestinationValue(value: DestinationValue) { + return value.path; +} + +function nodeMatchesQuery(node: DestinationNode, normalizedQuery: string) { + return ( + node.label.toLowerCase().includes(normalizedQuery) || + node.path.toLowerCase().includes(normalizedQuery) + ); +} + +function filterDestinationTree( + nodes: DestinationNode[], + query: string, +): DestinationNode[] { + const normalizedQuery = query.trim().toLowerCase(); + const result: DestinationNode[] = []; + + for (const node of nodes) { + const filteredChildren = node.children + ? filterDestinationTree(node.children, query) + : undefined; + const isMatch = + normalizedQuery.length === 0 || nodeMatchesQuery(node, normalizedQuery); + + if (!isMatch && (!filteredChildren || filteredChildren.length === 0)) { + continue; + } + + result.push({ + ...node, + isExpanded: normalizedQuery.length > 0 || node.children != null, + children: filteredChildren, + }); + } + + return result; +} + +function toTreeListItems( + nodes: DestinationNode[], + selectedId: string, + onSelect: (value: DestinationValue) => void, +): TreeListItemData[] { + return nodes.map(node => { + const hasChildren = node.children != null && node.children.length > 0; + return { + id: node.id, + label: node.label, + description: node.path, + isExpanded: hasChildren, + isSelected: !hasChildren && node.id === selectedId, + endContent: + node.kind === 'team' ? ( + + ) : undefined, + onClick: hasChildren + ? undefined + : () => onSelect({id: node.id, label: node.label, path: node.path}), + children: hasChildren + ? toTreeListItems(node.children ?? [], selectedId, onSelect) + : undefined, + }; + }); +} + +function FruitRipenessMatrix({ + value, + onChange, +}: { + value: FruitValue; + onChange: (value: FruitValue) => void; +}) { + const {gridRef, handleKeyDown, handleFocus, focusCell} = + useGridFocus({ + columns: ripenessLevels.length, + cellSelector: GRID_CELL_SELECTOR, + hasRovingTabIndex: true, + }); + + useEffect(() => { + const rowIndex = fruits.findIndex(fruit => fruit.id === value.fruit); + const columnIndex = ripenessLevels.findIndex( + level => level.id === value.ripeness, + ); + requestAnimationFrame(() => { + focusCell( + rowIndex >= 0 && columnIndex >= 0 + ? rowIndex * ripenessLevels.length + columnIndex + : 0, + ); + }); + }, [focusCell, value]); + + return ( +
+ {fruits.map(fruit => ( +
+
+ + + {fruit.id} + + {fruit.description} + + +
+ {ripenessLevels.map(level => { + const nextValue = {fruit: fruit.id, ripeness: level.id}; + const isSelected = + value.fruit === fruit.id && value.ripeness === level.id; + + return ( + + ); + })} +
+ ))} +
+ ); +} + +function TreeSearchContent({ + label, + value, + tree, + searchPlaceholder, + onChange, + close, +}: { + label: string; + value: DestinationValue; + tree: DestinationNode[]; + searchPlaceholder: string; + onChange: (value: DestinationValue) => void; + close: () => void; +}) { + const [query, setQuery] = useState(''); + const filteredTree = useMemo( + () => filterDestinationTree(tree, query), + [query, tree], + ); + const treeItems = useMemo( + () => + toTreeListItems(filteredTree, value.id, nextValue => { + onChange(nextValue); + close(); + }), + [close, filteredTree, onChange, value.id], + ); + + return ( + +
+ +
+
+ {treeItems.length > 0 ? ( + + ) : ( +
+ + No matching destinations. + +
+ )} +
+
+ + + Current: + + + +
+
+ ); +} + +export const FruitRipenessGrid: Story = { + name: 'Fruit ripeness selector', + render: () => { + const [value, setValue] = useState({ + fruit: 'Apple', + ripeness: 'Juicy', + }); + + return ( + + + label="Fruit blend" + description="Choose a fruit and ripeness level in one selector. Arrow down preserves the ripeness column." + value={value} + onChange={setValue} + triggerLabel={formatFruitValue(value)} + contentXstyle={styles.fruitContent}> + {(selectedValue, onChange, close) => ( +
+
+ + Pick a blend profile. The compact pills mirror a hover-rich + selector while staying available to keyboard users. + +
+ + { + onChange(nextValue); + close(); + }} + /> + +
+ + + Try keyboard: + + โ†“ from Apple J lands on Pear J. + +
+
+ )} + +
+ ); + }, + parameters: { + docs: { + description: { + story: + 'A fruit-themed stand-in for a rich two-axis selector. ComplexSelector owns the trigger, popover, focus restore, and change flow; the custom content owns its grid semantics.', + }, + }, + }, +}; + +export const TreeListWithSearch: Story = { + name: 'Tree list with search', + render: () => { + const [value, setValue] = useState({ + id: 'teams-design-systems-accessibility', + label: 'Accessibility', + path: '/Teams/Design systems/Accessibility', + }); + + return ( + + + label="Project destination" + description="Search and browse nested folders from one selector." + value={value} + onChange={setValue} + triggerLabel={formatDestinationValue(value)} + contentXstyle={styles.treeContent}> + {(selectedValue, onChange, close) => ( + + )} + + + ); + }, + parameters: { + docs: { + description: { + story: + 'A complex selector that combines TextInput search with TreeList hierarchy. TreeList owns tree keyboard navigation while ComplexSelector owns the trigger and popover shell. Evaluate the composed content against WCAG 2.2 keyboard, focus, name/role, label, and contrast criteria.', + }, + }, + }, +}; + +export const CategoryTreeSelector: Story = { + name: 'Category tree selector', + render: () => { + const [value, setValue] = useState({ + id: 'produce-fruit-citrus', + label: 'Citrus', + path: 'Produce / Fruit / Citrus', + }); + + return ( + + + label="Product category" + description="Search or browse a category tree." + value={value} + onChange={setValue} + triggerLabel={value.path} + contentXstyle={styles.treeContent}> + {(selectedValue, onChange, close) => ( + + )} + + + ); + }), + )} + + ); +} + +function FruitComplexSelector({ + value, + onChange, + changeAction, +}: { + value: FruitValue; + onChange: (value: FruitValue) => void; + changeAction?: (value: FruitValue) => void | Promise; +}) { + return ( + + {(value, onChange, close) => ( + { + onChange(nextValue); + close(); + }} + /> + )} + + ); +} + +describe('ComplexSelector', () => { + it('renders custom content with value and commits through onChange', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', {name: 'Fruit blend'})); + await user.click( + screen.getByRole('gridcell', {name: 'Banana Juicy', ...h}), + ); + + expect(onChange).toHaveBeenCalledWith({fruit: 'Banana', ripeness: 'Juicy'}); + expect(screen.getByRole('button', {name: 'Fruit blend'})).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); + + it('runs changeAction through the provided onChange helper', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const changeAction = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', {name: 'Fruit blend'})); + await user.click( + screen.getByRole('gridcell', {name: 'Banana Crisp', ...h}), + ); + + expect(onChange).toHaveBeenCalledWith({fruit: 'Banana', ripeness: 'Crisp'}); + await waitFor(() => { + expect(changeAction).toHaveBeenCalledWith({ + fruit: 'Banana', + ripeness: 'Crisp', + }); + }); + }); + + it('passes a close helper to composed content', async () => { + const user = userEvent.setup(); + + render( + + {(_value, _onChange, close) => ( + + )} + , + ); + + const trigger = screen.getByRole('button', {name: 'Fruit blend'}); + await user.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await user.click(screen.getByRole('button', {name: 'Done', ...h})); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/packages/core/src/ComplexSelector/ComplexSelector.tsx b/packages/core/src/ComplexSelector/ComplexSelector.tsx new file mode 100644 index 000000000000..d165b5feac6a --- /dev/null +++ b/packages/core/src/ComplexSelector/ComplexSelector.tsx @@ -0,0 +1,429 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ComplexSelector.tsx + * @input Uses React, StyleX, Field, usePopover + * @output Exports ComplexSelector component for custom selector surfaces + * @position Core implementation; consumed by index.ts + * + * SYNC: When modified, update: + * - /packages/core/src/ComplexSelector/ComplexSelector.doc.mjs + * - /packages/core/src/ComplexSelector/ComplexSelector.test.tsx + * - /packages/core/src/ComplexSelector/index.ts + * - /apps/storybook/stories/ComplexSelector.stories.tsx + */ + +import React, { + useCallback, + useId, + useOptimistic, + useTransition, + type ReactNode, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type {StyleXStyles} from '@stylexjs/stylex'; +import type {BaseProps} from '../BaseProps'; +import {Field, inputWrapperStyles, type FieldStatusVariant} from '../Field'; +import {Icon} from '../Icon'; +import {Spinner} from '../Spinner'; +import {useTranslator} from '../i18n'; +import {layerAnimations} from '../Layer/layerAnimations.stylex'; +import {usePopover} from '../Popover/usePopover'; +import { + colorVars, + durationVars, + easeVars, + radiusVars, + sizeVars, + spacingVars, + typographyVars, + typeScaleVars, +} from '../theme/tokens.stylex'; +import {mergeProps} from '../utils'; +import type {SizeValue} from '../utils/types'; +import {themeProps} from '../utils/themeProps'; + +const styles = stylex.create({ + triggerContainer: { + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacingVars['--spacing-2'], + width: '100%', + paddingBlock: spacingVars['--spacing-2'], + paddingInline: spacingVars['--spacing-3'], + fontFamily: typographyVars['--font-family-body'], + fontSize: { + default: typeScaleVars['--text-label-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--text-label-size']})`, + }, + lineHeight: typeScaleVars['--text-label-leading'], + color: colorVars['--color-text-primary'], + cursor: 'pointer', + }, + trigger: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacingVars['--spacing-2'], + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + minWidth: 0, + padding: 0, + margin: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + color: 'inherit', + cursor: 'pointer', + outline: 'none', + borderRadius: radiusVars['--radius-element'], + }, + triggerText: { + flexGrow: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textAlign: 'start', + }, + placeholder: { + color: colorVars['--color-text-secondary'], + }, + triggerIcon: { + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 16, + height: 16, + transitionProperty: 'transform', + transitionDuration: durationVars['--duration-fast'], + transitionTimingFunction: easeVars['--ease-standard'], + transformOrigin: 'center', + color: colorVars['--color-icon-secondary'], + }, + triggerIconOpen: { + transform: 'rotate(180deg)', + }, + popover: { + minWidth: 'anchor-size(width)', + marginBlockStart: spacingVars['--spacing-1'], + }, + content: { + boxSizing: 'border-box', + maxHeight: 'min(480px, calc(100vh - 32px))', + overflow: 'auto', + padding: spacingVars['--spacing-3'], + }, + sm: { + minHeight: sizeVars['--size-element-sm'], + }, + md: { + minHeight: sizeVars['--size-element-md'], + }, + lg: { + minHeight: sizeVars['--size-element-lg'], + }, + disabled: { + cursor: 'not-allowed', + }, + focusRing: { + ':focus-within': { + outline: `2px solid ${colorVars['--color-accent']}`, + outlineOffset: '2px', + }, + }, +}); + +export type ComplexSelectorSize = 'sm' | 'md' | 'lg'; + +export interface ComplexSelectorRenderState { + /** Whether the selector surface is open. */ + isOpen: boolean; + /** Whether changeAction/isLoading is pending. */ + isBusy: boolean; + /** ID of the trigger button. */ + triggerId: string; + /** ID of the popup content container. */ + contentId: string; +} + +export interface ComplexSelectorStatus { + type: 'warning' | 'error' | 'success'; + message?: string; +} + +export interface ComplexSelectorProps extends Omit< + BaseProps, + 'children' | 'onChange' +> { + /** Label text for accessibility and the field label. */ + label: string; + /** Current controlled value. */ + value: Value; + /** Called when custom content commits a new value. */ + onChange?: (value: Value) => void; + /** Optional async action after onChange; drives optimistic UI. */ + changeAction?: (value: Value) => void | Promise; + /** Custom selector surface content rendered inside a dialog popover. */ + children: ( + value: Value, + onChange: (value: Value) => void, + close: () => void, + state: ComplexSelectorRenderState, + ) => ReactNode; + /** Label/content shown in the closed trigger. */ + triggerLabel?: ReactNode; + /** Placeholder shown when triggerLabel is omitted. */ + placeholder?: ReactNode; + /** Whether to visually hide the field label. */ + isLabelHidden?: boolean; + /** Helper text displayed below the label. */ + description?: string; + /** Marks the field optional. */ + isOptional?: boolean; + /** Marks the field required. */ + isRequired?: boolean; + /** Disables the selector. */ + isDisabled?: boolean; + /** Shows loading state on the trigger. */ + isLoading?: boolean; + /** Validation status. */ + status?: ComplexSelectorStatus; + /** Status placement. */ + statusVariant?: FieldStatusVariant; + /** Tooltip text displayed next to the label. */ + labelTooltip?: string; + /** Trigger and field size. */ + size?: ComplexSelectorSize; + /** Width of the field. */ + width?: SizeValue; + /** Popup placement. */ + placement?: 'above' | 'below' | 'start' | 'end'; + /** StyleX styles for the popup content container. */ + contentXstyle?: StyleXStyles; + /** Test ID for the trigger container. */ + 'data-testid'?: string; +} + +/** + * A selector shell for rich, custom selection surfaces. + * + * ComplexSelector owns the field, trigger, popover, focus restore, and async + * change action flow. Consumers provide the dialog content as a render function, + * using the supplied `value`, `onChange`, and `close` helpers to compose the + * right accessible structure for the custom selector. + * + * @example + * ``` + * + * {(value, onChange, close) => ( + * { + * onChange(nextValue); + * close(); + * }} + * /> + * )} + * + * ``` + */ +export function ComplexSelector({ + label, + value, + onChange, + changeAction, + children, + triggerLabel, + placeholder: placeholderFromProps, + isLabelHidden = false, + description, + isOptional = false, + isRequired = false, + isDisabled = false, + isLoading = false, + status, + statusVariant = 'attached', + labelTooltip, + size = 'md', + width, + placement = 'below', + contentXstyle, + xstyle, + className, + style, + 'data-testid': testId, + ...props +}: ComplexSelectorProps) { + const t = useTranslator(); + const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder'); + + const triggerId = useId(); + const labelId = useId(); + const contentId = useId(); + const descriptionId = useId(); + const statusMessageId = useId(); + const ariaDescribedBy = + [ + description ? descriptionId : null, + status?.message ? statusMessageId : null, + ] + .filter((id): id is string => id != null) + .join(' ') || undefined; + + const [isPending, startTransition] = useTransition(); + const [optimisticValue, setOptimisticValue] = useOptimistic(value); + const isBusy = isLoading || isPending; + + const popover = usePopover({ + dialogLabel: label, + hasCloseButton: false, + hasAutoFocus: true, + onHide: () => { + document.getElementById(triggerId)?.focus(); + }, + }); + + const commitValue = useCallback( + (nextValue: Value) => { + onChange?.(nextValue); + if (changeAction) { + startTransition(async () => { + setOptimisticValue(nextValue); + await changeAction(nextValue); + }); + } + }, + [changeAction, onChange, setOptimisticValue, startTransition], + ); + + const triggerContent = triggerLabel ?? placeholder; + + const content = ( +
+ {children(optimisticValue, commitValue, popover.hide, { + isOpen: popover.isOpen, + isBusy, + triggerId, + contentId, + })} +
+ ); + + const selectorContent = ( + <> +
{ + if (!isDisabled) { + popover.toggle(); + } + }} + {...mergeProps( + themeProps('complex-selector', { + size, + status: status?.type ?? null, + }), + stylex.props( + inputWrapperStyles.base, + styles.triggerContainer, + styles[size], + styles.focusRing, + isDisabled && inputWrapperStyles.disabled, + isDisabled && styles.disabled, + triggerLabel == null && styles.placeholder, + xstyle, + ), + className, + style, + )}> + + {isBusy && } + + + +
+ + {popover.render(content, { + placement, + alignment: 'start', + xstyle: [styles.popover, layerAnimations[placement]], + })} + + ); + + return ( + + {selectorContent} + + ); +} + +ComplexSelector.displayName = 'ComplexSelector'; diff --git a/packages/core/src/ComplexSelector/index.ts b/packages/core/src/ComplexSelector/index.ts new file mode 100644 index 000000000000..182e9a1e5b9e --- /dev/null +++ b/packages/core/src/ComplexSelector/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file index.ts + * @output Exports ComplexSelector and types + * @position Public API entry point + */ + +export { + ComplexSelector, + type ComplexSelectorProps, + type ComplexSelectorRenderState, + type ComplexSelectorSize, + type ComplexSelectorStatus, +} from './ComplexSelector'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8e2b78c1ffe5..d29ee10c58c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export * from './Calendar'; export * from './Center'; export * from './CodeBlock'; export * from './CommandPalette'; +export * from './ComplexSelector'; export * from './Chat'; export * from './Markdown'; export * from './Citation';