From 880d4549932a12f3950288e1186bd1c25665d26b Mon Sep 17 00:00:00 2001 From: cixzhang Date: Sun, 2 Aug 2026 21:26:07 +0000 Subject: [PATCH 1/5] feat(core): add ComplexSelector --- .changeset/complex-selector.md | 6 + .../stories/ComplexSelector.stories.tsx | 300 +++++++++ packages/core/package.json | 5 + .../ComplexSelector/ComplexSelector.doc.mjs | 193 ++++++ .../ComplexSelector/ComplexSelector.test.tsx | 174 ++++++ .../src/ComplexSelector/ComplexSelector.tsx | 588 ++++++++++++++++++ packages/core/src/ComplexSelector/index.ts | 22 + packages/core/src/index.ts | 1 + 8 files changed, 1289 insertions(+) create mode 100644 .changeset/complex-selector.md create mode 100644 apps/storybook/stories/ComplexSelector.stories.tsx create mode 100644 packages/core/src/ComplexSelector/ComplexSelector.doc.mjs create mode 100644 packages/core/src/ComplexSelector/ComplexSelector.test.tsx create mode 100644 packages/core/src/ComplexSelector/ComplexSelector.tsx create mode 100644 packages/core/src/ComplexSelector/index.ts 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..083a9439bfd9 --- /dev/null +++ b/apps/storybook/stories/ComplexSelector.stories.tsx @@ -0,0 +1,300 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import type {Meta, StoryObj} from '@storybook/react'; +import {useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {ComplexSelector} from '@astryxdesign/core/ComplexSelector'; +import {Text} from '@astryxdesign/core/Text'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; +import { + borderVars, + colorVars, + durationVars, + easeVars, + fontWeightVars, + radiusVars, + spacingVars, + typeScaleVars, +} from '@astryxdesign/core/theme/tokens.stylex'; + +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, async changeAction, and optional grid keyboard behavior while consumers render the content.', + }, + }, + }, +}; + +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'}, +]; + +const styles = stylex.create({ + wrapper: { + width: 340, + }, + content: { + width: 520, + }, + intro: { + marginBlockEnd: spacingVars['--spacing-3'], + }, + headerGrid: { + display: 'grid', + gridTemplateColumns: '132px repeat(4, 1fr)', + gap: spacingVars['--spacing-2'], + alignItems: 'center', + marginBlockEnd: spacingVars['--spacing-2'], + }, + columnHeading: { + textAlign: 'center', + color: colorVars['--color-text-secondary'], + fontSize: typeScaleVars['--text-supporting-size'], + fontWeight: fontWeightVars['--font-weight-medium'], + }, + matrix: { + display: 'grid', + gridTemplateColumns: '132px repeat(4, 1fr)', + gap: spacingVars['--spacing-2'], + alignItems: 'stretch', + }, + rowHeader: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-2'], + paddingBlock: spacingVars['--spacing-2'], + color: colorVars['--color-text-primary'], + }, + fruitEmoji: { + fontSize: 20, + }, + fruitName: { + fontSize: typeScaleVars['--text-label-size'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + fruitDescription: { + color: colorVars['--color-text-secondary'], + fontSize: typeScaleVars['--text-supporting-size'], + }, + cell: { + minHeight: 72, + borderWidth: borderVars['--border-width'], + borderStyle: 'solid', + borderColor: colorVars['--color-border'], + borderRadius: radiusVars['--radius-container'], + backgroundColor: colorVars['--color-background-card'], + color: colorVars['--color-text-primary'], + padding: spacingVars['--spacing-2'], + cursor: 'pointer', + transitionProperty: 'background-color, border-color, box-shadow, transform', + transitionDuration: durationVars['--duration-fast'], + transitionTimingFunction: easeVars['--ease-standard'], + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--color-accent']}`, + }, + outlineOffset: 2, + ':hover': { + '@media (hover: hover)': { + backgroundColor: colorVars['--color-background-muted'], + borderColor: colorVars['--color-border-emphasized'], + }, + }, + ':active': { + transform: 'scale(0.98)', + }, + }, + selectedCell: { + borderColor: colorVars['--color-accent'], + boxShadow: `inset 0 0 0 2px ${colorVars['--color-accent']}`, + }, + cellLabel: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: spacingVars['--spacing-1'], + fontWeight: fontWeightVars['--font-weight-semibold'], + fontSize: typeScaleVars['--text-label-size'], + }, + cellDescription: { + marginBlockStart: spacingVars['--spacing-1'], + color: colorVars['--color-text-secondary'], + fontSize: typeScaleVars['--text-supporting-size'], + textAlign: 'start', + }, + badge: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 20, + height: 20, + borderRadius: radiusVars['--radius-full'], + backgroundColor: colorVars['--color-accent'], + color: colorVars['--color-on-accent'], + fontSize: 11, + fontWeight: fontWeightVars['--font-weight-bold'], + }, + keyboardHint: { + marginBlockStart: spacingVars['--spacing-3'], + paddingBlockStart: spacingVars['--spacing-3'], + borderBlockStartWidth: borderVars['--border-width'], + borderBlockStartStyle: 'solid', + borderBlockStartColor: colorVars['--color-border'], + }, +}); + +function formatValue(value: FruitValue) { + return `${value.fruit} ยท ${value.ripeness}`; +} + +export const FruitRipenessGrid: Story = { + name: 'Fruit ripeness grid', + 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={formatValue(value)} + layout={{type: 'grid', columns: ripenessLevels.length}} + contentXstyle={styles.content} + getFormValue={formatValue}> + {({value: selectedValue, getOptionProps}) => ( +
+
+ + Pick a blend profile. Keyboard users can move across ripeness + levels with left/right and preserve the same ripeness when + moving between fruit rows with up/down. + +
+ + + )} + + + ); + }, + parameters: { + docs: { + description: { + story: + 'A fruit-themed stand-in for a model plus level selector. The content is fully custom, but ComplexSelector owns the popover and grid keyboard contract.', + }, + }, + }, +}; diff --git a/packages/core/package.json b/packages/core/package.json index a17c8f0ee092..36e0f05bbf8f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -202,6 +202,11 @@ "types": "./dist/CommandPalette/index.d.ts", "default": "./dist/CommandPalette/index.js" }, + "./ComplexSelector": { + "source": "./src/ComplexSelector/index.ts", + "types": "./dist/ComplexSelector/index.d.ts", + "default": "./dist/ComplexSelector/index.js" + }, "./ContextMenu": { "source": "./src/ContextMenu/index.ts", "types": "./dist/ContextMenu/index.d.ts", diff --git a/packages/core/src/ComplexSelector/ComplexSelector.doc.mjs b/packages/core/src/ComplexSelector/ComplexSelector.doc.mjs new file mode 100644 index 000000000000..be3ab2a772a9 --- /dev/null +++ b/packages/core/src/ComplexSelector/ComplexSelector.doc.mjs @@ -0,0 +1,193 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */ + +export const docs = { + name: 'ComplexSelector', + displayName: 'Complex Selector', + group: 'Selector', + category: 'Data Input', + keywords: [ + 'selector', + 'picker', + 'popover', + 'dialog', + 'grid', + 'matrix', + 'custom', + 'rich', + ], + theming: { + targets: [ + {className: 'astryx-complex-selector', visualProps: ['size', 'status']}, + { + className: 'astryx-complex-selector-indicator-icon', + states: ['state'], + }, + ], + }, + components: [ + { + name: 'ComplexSelector', + displayName: 'Complex Selector', + description: + 'A field and popover shell for building rich custom selector surfaces, including two-dimensional grids.', + props: [ + { + name: 'label', + type: 'string', + description: 'Label text for accessibility and the field label.', + required: true, + }, + { + name: 'value', + type: 'Value', + description: 'Current controlled value.', + required: true, + }, + { + name: 'onChange', + type: '(value: Value) => void', + description: 'Called when custom content commits a new value.', + }, + { + name: 'changeAction', + type: '(value: Value) => void | Promise', + description: + 'Async action after onChange. ComplexSelector exposes optimistic value and busy state while pending.', + }, + { + name: 'children', + type: '(props: ComplexSelectorRenderProps) => ReactNode', + description: + 'Custom selector content. Receives value, onChange, changeAction, close, isOpen, isBusy, IDs, and getOptionProps.', + required: true, + }, + { + name: 'triggerLabel', + type: 'ReactNode', + description: 'Label/content shown in the closed trigger.', + }, + { + name: 'renderTrigger', + type: '(props: ComplexSelectorTriggerRenderProps) => ReactNode', + description: + 'Custom trigger content rendered inside the selector trigger while ComplexSelector owns the button semantics.', + }, + { + name: 'placeholder', + type: 'ReactNode', + description: 'Placeholder shown when triggerLabel is omitted.', + default: "'Select...'", + }, + { + name: 'layout', + type: "{type: 'grid', columns: number}", + description: + 'Optional popup layout behavior. Grid layout wires arrow-key navigation and preserves columns on vertical movement.', + }, + { + name: 'hasCloseOnChange', + type: 'boolean', + description: 'Whether to close the popup after a value is committed.', + default: 'true', + }, + { + name: 'getOptionProps', + type: 'render prop helper', + description: + 'Returned from children props. Spread onto selectable buttons/cells so the selector can apply grid semantics and commit values.', + }, + { + name: 'isDisabled', + type: 'boolean', + description: 'Disables the selector.', + }, + { + name: 'isLoading', + type: 'boolean', + description: 'Shows loading state on the trigger.', + }, + { + name: 'status', + type: "{type: 'warning' | 'error' | 'success', message?: string}", + description: 'Validation status.', + }, + { + name: 'size', + type: "'sm' | 'md' | 'lg'", + description: 'Trigger and field size.', + default: "'md'", + }, + { + name: 'width', + type: 'SizeValue', + description: 'Width of the field.', + }, + { + name: 'htmlName', + type: 'string', + description: 'HTML form field name. Renders a hidden input.', + }, + { + name: 'getFormValue', + type: '(value: Value) => string', + description: 'Converts value for the hidden input.', + }, + ], + }, + ], + usage: { + description: + 'Use ComplexSelector when a selection needs richer custom content than a Selector option row, such as a card picker, color matrix, or two-dimensional option grid. It is intentionally one component: consumers customize content through a render prop while the design system owns the field, popover, focus restore, changeAction flow, and optional grid navigation.', + bestPractices: [ + { + guidance: true, + description: + 'Use layout={{type: \'grid\', columns}} for two-dimensional selectors so arrow navigation preserves columns.', + }, + { + guidance: true, + description: + 'Spread getOptionProps onto each selectable cell/button; pass a clear label so screen readers announce the row and column meaning.', + }, + { + guidance: true, + description: + 'Keep selectable cells as the only focusable elements inside grid content. Put decorative or explanatory content inside the cell.', + }, + { + guidance: false, + description: + 'Do not rebuild trigger ARIA, popover focus management, or roving tabindex in product code.', + }, + { + guidance: false, + description: + 'Do not use ComplexSelector for a plain single-column text list; use Selector instead.', + }, + ], + }, +}; + +export const docsDense = { + name: 'ComplexSelector', + displayName: 'Complex Selector', + group: 'Selector', + category: 'Data Input', + description: + 'Field+popover shell for rich custom selectors. Content render prop gets value/onChange/changeAction/close/isBusy/getOptionProps. Grid layout owns roving focus + column-preserving arrows.', + propDescriptions: { + label: 'Accessible field label.', + value: 'Controlled value.', + onChange: 'Commit value.', + changeAction: 'Async action after onChange; drives optimistic value/busy.', + children: + 'Render custom content from {value,onChange,changeAction,close,isBusy,getOptionProps}.', + triggerLabel: 'Closed trigger label/content.', + renderTrigger: 'Custom trigger content inside owned button.', + layout: "Optional {type:'grid', columns} for grid keyboard navigation.", + hasCloseOnChange: 'Close popup after value commit; default true.', + getOptionProps: 'Render helper to spread onto selectable cells/buttons.', + }, +}; diff --git a/packages/core/src/ComplexSelector/ComplexSelector.test.tsx b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx new file mode 100644 index 000000000000..4bd4c5b9b7c5 --- /dev/null +++ b/packages/core/src/ComplexSelector/ComplexSelector.test.tsx @@ -0,0 +1,174 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ComplexSelector.test.tsx + * @input Uses vitest, Testing Library, user-event + * @output Unit tests for ComplexSelector + * @position Tests; validates custom content, async actions, and grid keyboard behavior + * + * SYNC: When ComplexSelector.tsx API changes, update these tests. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {ComplexSelector} from './ComplexSelector'; + +beforeEach(() => { + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute('popover-open', ''); + const event = new Event('toggle', {bubbles: false}); + Object.defineProperty(event, 'newState', {value: 'open'}); + this.dispatchEvent(event); + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute('popover-open'); + const event = new Event('toggle', {bubbles: false}); + Object.defineProperty(event, 'newState', {value: 'closed'}); + this.dispatchEvent(event); + }); + const originalMatches = HTMLElement.prototype.matches; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (HTMLElement.prototype as any).matches = function ( + selector: string, + ): boolean { + if (selector === ':popover-open') { + return this.hasAttribute('popover-open'); + } + return originalMatches.call(this, selector); + }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +type FruitValue = { + fruit: 'Apple' | 'Banana'; + ripeness: 'Crisp' | 'Ripe' | 'Juicy'; +}; + +const FRUITS = ['Apple', 'Banana'] as const; +const RIPENESS = ['Crisp', 'Ripe', 'Juicy'] as const; +const h = {hidden: true} as const; + +function FruitComplexSelector({ + value, + onChange, + changeAction, +}: { + value: FruitValue; + onChange: (value: FruitValue) => void; + changeAction?: (value: FruitValue) => void | Promise; +}) { + return ( + + {({getOptionProps}) => ( +
+ {FRUITS.flatMap((fruit, rowIndex) => + RIPENESS.map((ripeness, columnIndex) => { + const optionValue = {fruit, ripeness}; + const isSelected = + value.fruit === fruit && value.ripeness === ripeness; + return ( + + ); + }), + )} +
+ )} +
+ ); +} + +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 after onChange', 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('uses grid keyboard navigation that preserves columns vertically', async () => { + const user = userEvent.setup(); + + render( + {}} + />, + ); + + await user.click(screen.getByRole('button', {name: 'Fruit blend'})); + + const appleRipe = screen.getByRole('gridcell', {name: 'Apple Ripe', ...h}); + const bananaRipe = screen.getByRole('gridcell', { + name: 'Banana Ripe', + ...h, + }); + + appleRipe.focus(); + await user.keyboard('{ArrowDown}'); + + expect(bananaRipe).toHaveFocus(); + }); +}); diff --git a/packages/core/src/ComplexSelector/ComplexSelector.tsx b/packages/core/src/ComplexSelector/ComplexSelector.tsx new file mode 100644 index 000000000000..f7f7a907896d --- /dev/null +++ b/packages/core/src/ComplexSelector/ComplexSelector.tsx @@ -0,0 +1,588 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ComplexSelector.tsx + * @input Uses React, StyleX, Field, usePopover, useGridFocus + * @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, + useEffect, + 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 {useGridFocus} from '../hooks/useGridFocus'; +import {layerAnimations} from '../Layer/layerAnimations.stylex'; +import type {LayerPlacement} from '../Layer/useLayer'; +import {usePopover} from '../Popover/usePopover'; +import { + colorVars, + durationVars, + easeVars, + radiusVars, + sizeVars, + spacingVars, + typographyVars, + typeScaleVars, +} from '../theme/tokens.stylex'; +import {useTranslator} from '../i18n'; +import {mergeProps} from '../utils'; +import type {SizeValue} from '../utils/types'; +import {themeProps} from '../utils/themeProps'; + +const OPTION_SELECTOR = '[data-astryx-complex-selector-option]'; + +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'], + outline: 'none', + }, + 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 ComplexSelectorGridLayout { + /** Use the WAI-ARIA grid pattern for a two-dimensional picker. */ + type: 'grid'; + /** Number of visual columns. ArrowUp/ArrowDown preserve the current column. */ + columns: number; +} + +export type ComplexSelectorLayout = ComplexSelectorGridLayout; + +export interface ComplexSelectorGetOptionPropsOptions { + /** Zero-based DOM/grid index for the option. */ + index: number; + /** Value to commit when the option is selected. */ + value: Value; + /** Accessible label for this option. */ + label: string; + /** Whether this option represents the current value. */ + isSelected?: boolean; + /** Whether this option is visible but unavailable. */ + isDisabled?: boolean; +} + +export interface ComplexSelectorOptionProps { + id: string; + role?: 'gridcell'; + 'aria-label': string; + 'aria-selected'?: boolean; + 'aria-disabled'?: true; + 'data-astryx-complex-selector-option': string; + tabIndex: 0 | -1; + onClick: () => void; +} + +export interface ComplexSelectorRenderProps { + /** Current optimistic value. */ + value: Value; + /** Commit a value through onChange/changeAction. */ + onChange: (value: Value) => void; + /** Async action passed to ComplexSelector, exposed for composed content. */ + changeAction?: (value: Value) => void | Promise; + /** Close the selector surface. */ + close: () => void; + /** 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; + /** Props for selectable options inside the custom content. */ + getOptionProps: ( + options: ComplexSelectorGetOptionPropsOptions, + ) => ComplexSelectorOptionProps; +} + +export interface ComplexSelectorTriggerRenderProps { + /** Current optimistic value. */ + value: Value; + /** Whether the selector surface is open. */ + isOpen: boolean; + /** Whether changeAction/isLoading is pending. */ + isBusy: boolean; +} + +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. */ + children: (props: ComplexSelectorRenderProps) => ReactNode; + /** Label/content shown in the closed trigger. */ + triggerLabel?: ReactNode; + /** Custom trigger content rendered inside the selector trigger. */ + renderTrigger?: ( + props: ComplexSelectorTriggerRenderProps, + ) => ReactNode; + /** Placeholder shown when triggerLabel is omitted. */ + placeholder?: ReactNode; + /** Popup layout behavior owned by ComplexSelector. */ + layout?: ComplexSelectorLayout; + /** Whether to close the popup after a value is committed. */ + hasCloseOnChange?: boolean; + /** 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?: LayerPlacement; + /** HTML form field name. */ + htmlName?: string; + /** Converts value for the hidden input. */ + getFormValue?: (value: Value) => string; + /** 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, async change + * action, and optional grid keyboard behavior. Consumers provide the actual + * content as a render function and spread `getOptionProps` onto each selectable + * cell when using `layout={{type: 'grid'}}`. + * + * @example + * ``` + * + * {({getOptionProps}) => fruits.flatMap((fruit, row) => + * levels.map((level, column) => ( + * + * )), + * )} + * + * ``` + */ +export function ComplexSelector({ + label, + value, + onChange, + changeAction, + children, + triggerLabel, + renderTrigger, + placeholder: placeholderFromProps, + layout, + hasCloseOnChange = true, + isLabelHidden = false, + description, + isOptional = false, + isRequired = false, + isDisabled = false, + isLoading = false, + status, + statusVariant = 'attached', + labelTooltip, + size = 'md', + width, + placement = 'below', + htmlName, + getFormValue, + 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 { + gridRef, + handleKeyDown: handleGridKeyDown, + handleFocus: handleGridFocus, + focusCell, + } = useGridFocus({ + columns: layout?.type === 'grid' ? layout.columns : 1, + cellSelector: OPTION_SELECTOR, + isCellFocusable: cell => cell.getAttribute('aria-disabled') !== 'true', + hasRovingTabIndex: layout?.type === 'grid', + }); + + const popover = usePopover({ + dialogLabel: label, + hasCloseButton: false, + hasAutoFocus: layout?.type !== 'grid', + onHide: () => { + document.getElementById(triggerId)?.focus(); + }, + }); + + const commitValue = useCallback( + (nextValue: Value) => { + onChange?.(nextValue); + if (changeAction) { + startTransition(async () => { + setOptimisticValue(nextValue); + await changeAction(nextValue); + }); + } + if (hasCloseOnChange) { + popover.hide(); + } + }, + [changeAction, hasCloseOnChange, onChange, popover, setOptimisticValue], + ); + + const getOptionProps = useCallback( + ({ + index, + value: optionValue, + label: optionLabel, + isSelected = false, + isDisabled: optionDisabled = false, + }: ComplexSelectorGetOptionPropsOptions): ComplexSelectorOptionProps => ({ + id: `${contentId}-option-${index}`, + role: layout?.type === 'grid' ? 'gridcell' : undefined, + 'aria-label': optionLabel, + 'aria-selected': isSelected || undefined, + 'aria-disabled': optionDisabled ? true : undefined, + 'data-astryx-complex-selector-option': '', + tabIndex: isSelected ? 0 : -1, + onClick: () => { + if (!optionDisabled) { + commitValue(optionValue); + } + }, + }), + [commitValue, contentId, layout?.type], + ); + + useEffect(() => { + if (!popover.isOpen || layout?.type !== 'grid') { + return; + } + + requestAnimationFrame(() => { + const grid = gridRef.current; + if (!grid) { + return; + } + const cells = Array.from( + grid.querySelectorAll(OPTION_SELECTOR), + ); + const selectedIndex = cells.findIndex( + cell => cell.getAttribute('aria-selected') === 'true', + ); + focusCell(selectedIndex >= 0 ? selectedIndex : 0); + }); + }, [focusCell, gridRef, layout?.type, popover.isOpen]); + + const triggerContent = renderTrigger + ? renderTrigger({value: optimisticValue, isOpen: popover.isOpen, isBusy}) + : (triggerLabel ?? placeholder); + + const content = ( +
+ {children({ + value: optimisticValue, + onChange: commitValue, + changeAction, + close: popover.hide, + isOpen: popover.isOpen, + isBusy, + triggerId, + contentId, + getOptionProps, + })} +
+ ); + + 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 && !renderTrigger && styles.placeholder, + xstyle, + ), + className, + style, + )}> + + {htmlName != null && ( + + )} + {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..5ebf7bf7f387 --- /dev/null +++ b/packages/core/src/ComplexSelector/index.ts @@ -0,0 +1,22 @@ +// 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 ComplexSelectorRenderProps, + type ComplexSelectorTriggerRenderProps, + type ComplexSelectorLayout, + type ComplexSelectorGridLayout, + type ComplexSelectorGetOptionPropsOptions, + type ComplexSelectorOptionProps, + 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'; From 18b56c09a1dcbd5a7484f2e7f8865f2e38547304 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Sun, 2 Aug 2026 22:45:22 +0000 Subject: [PATCH 2/5] feat(core): simplify ComplexSelector composition --- .../stories/ComplexSelector.stories.tsx | 174 ++++++++++------ .../test-sets/complex-selector.json | 33 +++ .../ComplexSelector/ComplexSelector.doc.mjs | 66 +++--- .../ComplexSelector/ComplexSelector.test.tsx | 128 +++++------- .../src/ComplexSelector/ComplexSelector.tsx | 196 +++--------------- packages/core/src/ComplexSelector/index.ts | 6 +- 6 files changed, 249 insertions(+), 354 deletions(-) create mode 100644 internal/vibe-tests/test-sets/complex-selector.json diff --git a/apps/storybook/stories/ComplexSelector.stories.tsx b/apps/storybook/stories/ComplexSelector.stories.tsx index 083a9439bfd9..52d498aee404 100644 --- a/apps/storybook/stories/ComplexSelector.stories.tsx +++ b/apps/storybook/stories/ComplexSelector.stories.tsx @@ -1,11 +1,12 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. import type {Meta, StoryObj} from '@storybook/react'; -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import * as stylex from '@stylexjs/stylex'; import {ComplexSelector} from '@astryxdesign/core/ComplexSelector'; import {Text} from '@astryxdesign/core/Text'; import {HStack, VStack} from '@astryxdesign/core/Layout'; +import {useGridFocus} from '@astryxdesign/core/hooks'; import { borderVars, colorVars, @@ -17,6 +18,8 @@ import { typeScaleVars, } from '@astryxdesign/core/theme/tokens.stylex'; +const GRID_CELL_SELECTOR = '[role="gridcell"]'; + const meta: Meta = { title: 'Core/ComplexSelector', component: ComplexSelector, @@ -26,7 +29,7 @@ const meta: Meta = { docs: { description: { component: - 'A high-level selector shell for rich custom content. The component owns the field, trigger, popover, focus restore, async changeAction, and optional grid keyboard behavior while consumers render the content.', + '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.', }, }, }, @@ -183,6 +186,98 @@ function formatValue(value: FruitValue) { return `${value.fruit} ยท ${value.ripeness}`; } +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 ( +
+ + ); +} + export const FruitRipenessGrid: Story = { name: 'Fruit ripeness grid', render: () => { @@ -199,10 +294,8 @@ export const FruitRipenessGrid: Story = { value={value} onChange={setValue} triggerLabel={formatValue(value)} - layout={{type: 'grid', columns: ripenessLevels.length}} - contentXstyle={styles.content} - getFormValue={formatValue}> - {({value: selectedValue, getOptionProps}) => ( + contentXstyle={styles.content}> + {(selectedValue, onChange, close) => (
@@ -212,66 +305,13 @@ export const FruitRipenessGrid: Story = {
-