a3s-flow 1.1.0

Durable workflow engine and Rust SDK for A3S
Documentation
import {
  Children,
  isValidElement,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  type AriaAttributes,
  type ReactNode,
  type SelectHTMLAttributes,
} from 'react';
import { loadA3SUIRuntime } from './a3s-ui-runtime';
import { DesignerIcon } from './designer-icons';

type SelectElement = HTMLElement & {
  _destroy?: () => void;
  refresh?: () => void;
  value?: string;
};

function loadSelectRuntime(): Promise<void> {
  return loadA3SUIRuntime('select', () => import('@a3s-lab/ui/select'));
}

export type SelectControlChangeEvent = {
  currentTarget: { value: string };
  target: { value: string };
};

export interface SelectControlProps extends Omit<
  SelectHTMLAttributes<HTMLSelectElement>,
  | 'children'
  | 'defaultValue'
  | 'multiple'
  | 'onBlur'
  | 'onChange'
  | 'onFocus'
  | 'value'
> {
  children: ReactNode;
  onBlur?: () => void;
  onChange?: (event: SelectControlChangeEvent) => void;
  onFocus?: () => void;
  value?: string;
}

type SelectOption = {
  disabled: boolean;
  label: string;
  value: string;
};

function selectOptions(children: ReactNode): SelectOption[] {
  return Children.toArray(children).flatMap((child) => {
    if (
      !isValidElement<{
        children?: ReactNode;
        disabled?: boolean;
        value?: unknown;
      }>(child)
    ) {
      return [];
    }
    if (child.type !== 'option') return [];
    const value = String(child.props.value ?? '');
    const label = Children.toArray(child.props.children).join('');
    return [{ disabled: Boolean(child.props.disabled), label, value }];
  });
}

/**
 * Controlled A3S UI Select adapter used by Flow composite widgets.
 * It preserves the familiar `event.target.value` callback without falling
 * back to the browser-native select surface.
 */
export function SelectControl({
  'aria-describedby': ariaDescribedBy,
  'aria-invalid': ariaInvalid,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledBy,
  children,
  className,
  disabled = false,
  id: suppliedId,
  name,
  onBlur,
  onChange,
  onFocus,
  required,
  value = '',
}: SelectControlProps) {
  const generatedId = useId();
  const id = suppliedId ?? `a3s-flow-select-${generatedId.replaceAll(':', '')}`;
  const options = useMemo(() => selectOptions(children), [children]);
  const selected =
    options.find((option) => option.value === value) ?? options[0];
  const elementRef = useRef<SelectElement | null>(null);
  const valueRef = useRef(value);
  const onChangeRef = useRef(onChange);
  valueRef.current = value;
  onChangeRef.current = onChange;

  const synchronize = useCallback((element: HTMLElement) => {
    const select = element as SelectElement;
    select.refresh?.();
    if (select.value !== valueRef.current) select.value = valueRef.current;
  }, []);

  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;
    let active = true;
    const handleChange = (event: Event) => {
      const detail = (event as CustomEvent<{ value?: unknown }>).detail;
      const nextValue =
        typeof detail?.value === 'string' ? detail.value : element.value;
      if (typeof nextValue !== 'string' || nextValue === valueRef.current)
        return;
      onChangeRef.current?.({
        currentTarget: { value: nextValue },
        target: { value: nextValue },
      });
    };
    element.addEventListener('change', handleChange);
    void loadSelectRuntime()
      .then(() => {
        if (active) synchronize(element);
      })
      .catch((error: unknown) => {
        if (!active || typeof window === 'undefined') return;
        console.error(
          '[A3S Flow] Failed to initialize the select control.',
          error,
        );
      });
    return () => {
      active = false;
      element.removeEventListener('change', handleChange);
      element._destroy?.();
    };
  }, [synchronize]);

  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;
    element.refresh?.();
    if (element.value !== value) element.value = value;
  }, [options, value]);

  const triggerAria: AriaAttributes = {
    'aria-describedby': ariaDescribedBy,
    'aria-invalid': ariaInvalid,
    'aria-label': ariaLabel,
    'aria-labelledby': ariaLabelledBy,
    'aria-required': required || undefined,
  };

  return (
    <div
      className={['select', 'a3s-flow-select-control', className]
        .filter(Boolean)
        .join(' ')}
      data-disabled={disabled || undefined}
      data-placeholder=""
      id={id}
      ref={(element) => {
        elementRef.current = element as SelectElement | null;
      }}
    >
      <button
        {...triggerAria}
        aria-controls={`${id}-listbox`}
        aria-expanded="false"
        aria-haspopup="listbox"
        disabled={disabled}
        id={`${id}-trigger`}
        onBlur={onBlur}
        onFocus={onFocus}
        role="combobox"
        type="button"
      >
        <span>{selected?.label ?? ''}</span>
        <DesignerIcon name="chevron-down" size={14} />
      </button>
      <div aria-hidden="true" data-popover id={`${id}-popover`}>
        <div
          aria-labelledby={`${id}-trigger`}
          aria-orientation="vertical"
          id={`${id}-listbox`}
          role="listbox"
        >
          {options.map((option, index) => (
            <div
              aria-disabled={option.disabled || undefined}
              aria-selected={option.value === value || undefined}
              data-label={option.label}
              data-value={option.value}
              id={`${id}-option-${index + 1}`}
              key={option.value}
              role="option"
            >
              {option.label}
            </div>
          ))}
        </div>
      </div>
      <input
        className="a3s-form-visually-hidden"
        name={name ?? id}
        readOnly
        type="hidden"
        value={value}
      />
    </div>
  );
}