// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ComplexSelector.tsx * @input Uses React, StyleX, Field, Icon slots, Layer positioning, and usePopover * @output Exports a rich-selector shell with exact token-sized input and ghost triggers, plus an imperative open/close handle * @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 * - /packages/cli/assets/templates/blocks/components/ComplexSelector/ (showcase blocks) */ import React, { useCallback, useId, useImperativeHandle, useOptimistic, useRef, useTransition, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import type { StyleXStyles } from "../style-runtime.js"; import type { BaseProps } from "../BaseProps.js"; import { Field, inputWrapperStyles, type FieldStatusVariant } from "../Field/index.js"; import { Icon, renderIconSlot, type IconType } from "../Icon/index.js"; import { Spinner } from "../Spinner/index.js"; import { useTranslator } from "../i18n/index.js"; import { layerAnimations } from "../Layer/layerAnimations.stylex.js"; import type { LayerAlignment, LayerPlacement } from "../Layer/useLayer.js"; import { usePopover } from "../Popover/usePopover.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import "../theme/tokens.stylex.js"; import { colorVars, durationVars, easeVars, fontWeightVars, radiusVars, sizeVars, spacingVars, typographyVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { isRenderable, mergeProps } from "../utils/index.js"; import { composeEventHandlers } from "../utils/composeEventHandlers.js"; import { focusOutlineStyles } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import type { SizeValue } from "../utils/types.js"; import { themeProps } from "../utils/themeProps.js"; const styles = { triggerContainer: { kVAEAm: "x1n2onr6", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kOIVth: "x1txdalj", kzqmXN: "xh8yej3", k8WAf4: "xce4md1", kg3NbH: "xrrkdod", kMv6JI: "x9ynric", kGuDYH: "xcr08ib x1w61h2b", kLWn49: "x1kq96og", kMwMTN: "x1tgivj0", kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, trigger: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kOIVth: "x1txdalj", kzQI83: "x1iyjqo2", kmuXW: "xs83m0k", kCS8Yb: "x1r8uery", k7Eaqz: "xeuugli", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kMv6JI: "xjb2p0i", kGuDYH: "x1qlqyl8", kLWn49: "x15bjb6t", kMwMTN: "x1heor9g", kkrTdU: "x1ypdohk x16khyan", kI3sdo: "x1a2a7pz", kaIpWk: "xh6dtrn", $$css: true as const }, triggerText: { kzQI83: "x1iyjqo2", k7Eaqz: "xeuugli", kVQacm: "xb3r6kr", kg5iWk: "xlyipyv", khDVqt: "xuxw1ft", k9WMMc: "x1yc453h", $$css: true as const }, placeholder: { kMwMTN: "xv1l7n4", $$css: true as const }, triggerGhost: { kzqmXN: "x14atkfc", kMzoRj: "xc342km", kWkggS: "xjbqb8w", kGVxlE: "x1gnnqk1 x1s17dad x15ysqaf", k63SB2: "x1e4wzip", k1ekBW: "xrafxwg", k3aq6I: "x3oybdh xk4oym4", $$css: true as const }, triggerGhostDisabled: { kKwaWg: "x18o3ruo", k3aq6I: "x1c071of x1pdlv7q", $$css: true as const }, triggerIcon: { kmuXW: "x2lah0s", $$css: true as const }, triggerIconRotation: { k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", k3nNDw: "x1g0ag68", $$css: true as const }, triggerIconOpen: { k3aq6I: "x19jd1h0", $$css: true as const }, popover: { k7Eaqz: "xrzjruh", $$css: true as const }, content: { kB7OPa: "x9f619", kskxy: "xpnbb6b", kVQacm: "xysyzu8", kmVPX3: "x1b2ylru", $$css: true as const }, sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const }, disabled: { kkrTdU: "xt0e3qv", $$css: true as const } }; export type ComplexSelectorVariant = 'input' | 'ghost'; 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; } /** * Imperative control surface for ComplexSelector, accessed via the `handleRef` * prop. Methods drive the same popover machinery as the built-in trigger, so * they respect focus restoration, light dismiss, and Escape. Prefer these * callbacks over mirroring open state in the parent — the selector owns its * visibility, and imperative calls avoid the focus-management pitfalls of * syncing an external `isOpen` prop. Pair with `onOpenChange` to observe every * open and close, including the ones the selector performs itself. */ export interface ComplexSelectorHandle { /** Open the selector surface. No-op when disabled or already open. */ open(): void; /** Close the selector surface. Restores focus to the trigger. */ close(): void; /** Toggle the selector surface open or closed. */ toggle(): void; /** Whether the selector surface is currently open. Reads live state. */ isOpen(): boolean; } export interface ComplexSelectorStatus { type: 'warning' | 'error' | 'success'; message?: string; } export interface ComplexSelectorProps extends Omit, '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; /** Visual trigger style. Ghost matches toolbar buttons. */ variant?: ComplexSelectorVariant; /** Icon displayed at the start of the trigger. */ startIcon?: ReactNode | IconType; /** Width of the field. */ width?: SizeValue; /** Popup placement. */ placement?: LayerPlacement; /** Popup alignment along the placement axis. */ alignment?: LayerAlignment; /** * Imperative handle for programmatic open/close control. Exposes open, * close, toggle, and the isOpen query. Use this instead of mirroring open * state in the parent — the selector owns its visibility. */ handleRef?: React.Ref; /** * Called whenever the selector surface opens or closes, however it happened * — the trigger, the keyboard, a light dismiss, Escape, content that calls * `close()`, or the imperative handle. Pair it with `handleRef` to drive the * surface from outside without mirroring its state. */ onOpenChange?: (isOpen: boolean) => void; /** 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', variant = 'input', startIcon, width, placement = 'below', alignment = 'start', handleRef, onOpenChange, contentXstyle, xstyle, className, style, 'data-testid': testId, onClick: onClickProp, ...props }: ComplexSelectorProps) { const t = useTranslator(); const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder'); const effectiveStatusVariant = variant === 'ghost' && statusVariant === 'attached' ? 'detached' : statusVariant; 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 triggerRef = useRef(null); const [isPending, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isBusy = isLoading || isPending; const handlePopoverShow = useCallback(() => { onOpenChange?.(true); }, [onOpenChange]); const handlePopoverHide = useCallback(() => { // Focus is restored first so a consumer that moves focus elsewhere from // the callback wins, instead of being overwritten a line later. triggerRef.current?.focus(); onOpenChange?.(false); }, [onOpenChange]); const popover = usePopover({ dialogLabel: label, hasCloseButton: false, hasAutoFocus: true, surfaceTarget: 'complex-selector-popup', onShow: handlePopoverShow, onHide: handlePopoverHide }); const isOpen = popover.isOpen; const handleTriggerClick = useCallback(() => { if (isDisabled) { return; } if (popover.isOpen) { popover.hide(); } else { popover.show(); } }, [isDisabled, popover]); const close = useCallback(() => { popover.hide(); }, [popover]); useImperativeHandle(handleRef, () => ({ open: () => { if (!isDisabled) { popover.show(); } }, close: () => popover.hide(), toggle: () => { if (isDisabled) { return; } if (popover.isOpen) { popover.hide(); } else { popover.show(); } }, isOpen: () => popover.isOpen }), [isDisabled, popover]); 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 startIconSlot = renderIconSlot(startIcon, { size: 'sm', color: 'secondary' }); const content =
{children(optimisticValue, commitValue, close, { isOpen, isBusy, triggerId, contentId })}
; const selectorContent = <>
` sits // inside it), but it must still be a KEYBOARD ring: `:focus-within` // matched a mouse click on the trigger and drew the outline for // pointer users too. `focusWithin` here is `:has(:focus-visible)`. focusOutlineStyles.focusWithin, variant === 'ghost' && styles.triggerGhost, variant === 'ghost' && interactionOverlayStyles.backgroundImage, isDisabled && inputWrapperStyles.disabled, variant === 'ghost' && isDisabled && styles.triggerGhostDisabled, isDisabled && styles.disabled, triggerLabel == null && styles.placeholder, xstyle), className, style)}> {isRenderable(startIconSlot) && startIconSlot} {isBusy && }
{popover.render(content, { placement, alignment, offset: spacingVars['--spacing-1'], xstyle: [styles.popover, layerAnimations[placement]] })} ; return {selectorContent} ; } ComplexSelector.displayName = 'ComplexSelector';