EXEPERTAI LAB

Research alpha

Time Machine
EXEPERTAI LAB
GALLERY / COLLECTION
← Browse Astryx gallery
Selector·component·@astryxdesign/core

Complex Selector

Use ComplexSelector when a selection needs richer custom content than a Selector option row. It is intentionally one component: ComplexSelector owns the field, trigger, popover, focus restore, and changeAction flow, while the content render prop owns the selector-specific accessible structure.

Open in Playground @astryxdesign/core/ComplexSelector

Showcases and examples

3 documented examples

Complex Selector

A two-axis picker: choose a fruit and a ripeness level from one control. ComplexSelector owns the trigger, popover, and focus restore while the custom grid owns its keyboard semantics.

Preview loads on approachPreview loads on approach
Exact source · complex-selector-showcase
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

import {useEffect, useState} from 'react';
import * as stylex from '@stylexjs/stylex';
import {ComplexSelector} from '@astryxdesign/core/ComplexSelector';
import {Text} from '@astryxdesign/core/Text';
import {useGridFocus} from '@astryxdesign/core/hooks';
import {
  borderVars,
  colorVars,
  radiusVars,
  spacingVars,
} from '@astryxdesign/core/theme/tokens.stylex';

type Fruit = 'Apple' | 'Pear' | 'Peach' | 'Plum';
type Ripeness = 'Crisp' | 'Tender' | 'Juicy' | 'Peak';

interface 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 GRID_CELL_SELECTOR = '[role="gridcell"]';

const styles = stylex.create({
  grid: {
    display: 'flex',
    flexDirection: 'column',
    gap: spacingVars['--spacing-1'],
    minWidth: 280,
  },
  row: {
    display: 'grid',
    gridTemplateColumns: 'minmax(150px, 1fr) repeat(4, 44px)',
    alignItems: 'center',
    columnGap: spacingVars['--spacing-1'],
  },
  rowHeader: {
    display: 'flex',
    alignItems: 'center',
    gap: spacingVars['--spacing-2'],
    textAlign: 'start',
    minWidth: 0,
  },
  emoji: {
    fontSize: 18,
    flexShrink: 0,
  },
  fruitText: {
    display: 'flex',
    flexDirection: 'column',
    minWidth: 0,
  },
  cell: {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    height: 36,
    borderWidth: borderVars['--border-width'],
    borderStyle: 'solid',
    borderColor: colorVars['--color-border'],
    borderRadius: radiusVars['--radius-container'],
    backgroundColor: colorVars['--color-background-card'],
    color: colorVars['--color-text-secondary'],
    fontFamily: 'inherit',
    cursor: 'pointer',
    ':hover': {
      '@media (hover: hover)': {
        borderColor: colorVars['--color-border-emphasized'],
        color: colorVars['--color-text-primary'],
      },
    },
  },
  cellSelected: {
    borderColor: colorVars['--color-accent'],
    backgroundColor: colorVars['--color-accent'],
    color: colorVars['--color-on-accent'],
  },
});

function FruitRipenessGrid({
  value,
  onChange,
}: {
  value: FruitValue;
  onChange: (value: FruitValue) => void;
}) {
  const {gridRef, handleKeyDown, handleFocus, focusCell} =
    useGridFocus<HTMLDivElement>({
      columns: ripenessLevels.length,
      cellSelector: GRID_CELL_SELECTOR,
      hasRovingTabIndex: true,
    });

  useEffect(() => {
    const rowIndex = fruits.findIndex(f => f.id === value.fruit);
    const columnIndex = ripenessLevels.findIndex(l => l.id === value.ripeness);
    requestAnimationFrame(() => {
      focusCell(
        rowIndex >= 0 && columnIndex >= 0
          ? rowIndex * ripenessLevels.length + columnIndex
          : 0,
      );
    });
  }, [focusCell, value]);

  return (
    <div
      ref={gridRef}
      role="grid"
      aria-label="Fruit ripeness choices"
      onKeyDown={handleKeyDown}
      onFocus={handleFocus}
      {...stylex.props(styles.grid)}>
      {fruits.map(fruit => (
        <div key={fruit.id} role="row" {...stylex.props(styles.row)}>
          <div role="rowheader" {...stylex.props(styles.rowHeader)}>
            <span aria-hidden="true" {...stylex.props(styles.emoji)}>
              {fruit.emoji}
            </span>
            <span {...stylex.props(styles.fruitText)}>
              <Text type="body">{fruit.id}</Text>
              <Text type="supporting" color="secondary">
                {fruit.description}
              </Text>
            </span>
          </div>
          {ripenessLevels.map(level => {
            const isSelected =
              value.fruit === fruit.id && value.ripeness === level.id;
            return (
              <button
                key={`${fruit.id}-${level.id}`}
                type="button"
                role="gridcell"
                aria-label={`${fruit.id}, ${level.id}: ${level.description}`}
                aria-selected={isSelected || undefined}
                tabIndex={isSelected ? 0 : -1}
                onClick={() => onChange({fruit: fruit.id, ripeness: level.id})}
                {...stylex.props(
                  styles.cell,
                  isSelected && styles.cellSelected,
                )}>
                {level.shortLabel}
              </button>
            );
          })}
        </div>
      ))}
    </div>
  );
}

export default function ComplexSelectorShowcase() {
  const [value, setValue] = useState<FruitValue>({
    fruit: 'Apple',
    ripeness: 'Juicy',
  });

  return (
    <ComplexSelector<FruitValue>
      label="Fruit blend"
      description="Choose a fruit and ripeness in one control. Arrow keys move across the grid."
      value={value}
      onChange={setValue}
      triggerLabel={`${value.fruit} · ${value.ripeness}`}
      style={{width: 280}}>
      {(selectedValue, onChange, close) => (
        <FruitRipenessGrid
          value={selectedValue}
          onChange={nextValue => {
            onChange(nextValue);
            close();
          }}
        />
      )}
    </ComplexSelector>
  );
}

Usage

Use ComplexSelector when a selection needs richer custom content than a Selector option row. It is intentionally one component: ComplexSelector owns the field, trigger, popover, focus restore, and changeAction flow, while the content render prop owns the selector-specific accessible structure.

  • Use variant="ghost" with a startIcon when the selector is triggered from a toolbar. Use alignment="end" when a wide surface should align its end edge to the trigger.
  • For staged editors, keep draft state in the composed content and call the provided onChange helper only from Apply. Cancel or dismiss without committing.
  • Compose the dialog content from the appropriate accessible structure for the job: RadioList for a simple choice, Calendar/date inputs for date picking, TreeList or a searchable list for hierarchy, or a custom grid when two-dimensional arrow navigation is useful.
  • Use the provided onChange helper from children; it already calls both onChange and changeAction and updates optimistic busy state.
  • Call close() from custom content when a selection should dismiss the popup. Keep it open for multi-step content or freeform entry flows.
  • Use Astryx focus hooks for custom content: useGridFocus for two-dimensional grids, useTreeFocus through TreeList for hierarchies, and useListFocus for custom linear collections.
  • Evaluate custom content against WCAG 2.2: keyboard operation, focus visible/not obscured, names and roles, labels/instructions, target size, and contrast/non-text contrast are especially relevant for selector popovers.
  • Do not rebuild trigger ARIA, popover focus management, or changeAction handling in product code.
  • Do not use ComplexSelector for a plain single-column text list; use Selector instead.

Typed props

PropType and behavior
labelstring · required
Label text for accessibility and the field label.
valueValue · required
Current controlled value.
onChange(value: Value) => void
Called when custom content commits a new value.
changeAction(value: Value) => void | Promise<void>
Async action after onChange. ComplexSelector exposes optimistic value and busy state while pending.
children(value: Value, onChange: (value: Value) => void, close: () => void, state: ComplexSelectorRenderState) => ReactNode · required
Custom dialog content. Receives positional value, onChange, close, and state helpers.
triggerLabelReactNode
Label/content shown in the closed trigger.
placeholderReactNode · default 'Select...'
Placeholder shown when triggerLabel is omitted.
isDisabledboolean
Disables the selector.
isLoadingboolean
Shows loading state on the trigger.
status{type: 'warning' | 'error' | 'success', message?: string}
Validation status.
size'sm' | 'md' | 'lg' · default 'md'
Exact trigger height: sm 28px, md 32px, or lg 36px.
variant'input' | 'ghost' · default 'input'
Visual trigger style. Input is the bordered form treatment; ghost matches toolbar buttons.
startIconReactNode | IconType
Icon displayed at the start of the trigger.
widthSizeValue
Width of the field.
placement'above' | 'below' | 'start' | 'end' · default 'below'
Popup placement.
alignment'start' | 'center' | 'end' · default 'start'
Popup alignment along the placement axis.
handleRefReact.Ref<ComplexSelectorHandle>
Imperative handle for programmatic control. Exposes open(), close(), toggle(), and isOpen().
onOpenChange(isOpen: boolean) => void
Called whenever the surface opens or closes, however it happened — trigger, keyboard, light dismiss, Escape, close(), or the imperative handle.
contentXstyleStyleXStyles
StyleX styles for the popup content container.

Anatomy

Field · required

Field shell that provides the label and optional supporting field content.

Trigger · required

Control that displays the current value or placeholder and opens the popup.

Icon-rendered start icon · optional

Optional leading semantic icon or icon component rendered through Icon.

Caller-rendered start content · optional

Optional arbitrary React content rendered directly at the start of the trigger.

Indicator icon · required

Trailing chevron that rotates to reflect whether the popup is open.

Popup · required

Mounted dialog surface that is painted and shown while open and hidden while closed.

Theming

Targets

astryx-complex-selector

Visual props: variant, size, status

astryx-complex-selector-indicator-icon

States: state

astryx-complex-selector-popup