// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { memo } from 'react'; import type { FC, ReactNode } from 'react'; import { Select } from 'radix-ui'; import { AxoBaseMenu } from './_internal/AxoBaseMenu.js'; import { AxoSymbol } from './AxoSymbol.js'; import type { TailwindStyles } from './tw.js'; import { tw } from './tw.js'; import { ExperimentalAxoBadge } from './AxoBadge.js'; const Namespace = 'AxoSelect'; /** * Displays a list of options for the user to pick from—triggered by a button. * * @example Anatomy * ```tsx * export default () => ( * * * * * * * * * * * * * * * * * ); * ``` */ export namespace AxoSelect { /** * Component: * --------------------------- */ export type RootProps = Readonly<{ name?: string; form?: string; autoComplete?: string; disabled?: boolean; required?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; value: string | null; onValueChange: (value: string) => void; children: ReactNode; }>; /** * Contains all the parts of a select. */ export const Root: FC = memo(props => { return ( {props.children} ); }); Root.displayName = `${Namespace}.Root`; /** * Component: * --------------------------- */ export type TriggerVariant = 'default' | 'floating' | 'borderless'; export type TriggerWidth = 'hug' | 'full'; export type TriggerChevron = 'always' | 'on-hover'; const baseTriggerStyles = tw( 'group relative flex items-center', 'rounded-full text-start type-body-medium text-label-primary', 'disabled:text-label-disabled', 'outline-0 outline-border-focused focused:outline-[2.5px]', 'forced-colors:border' ); const TriggerVariants: Record = { default: tw( baseTriggerStyles, 'bg-fill-secondary', 'pressed:bg-fill-secondary-pressed' ), floating: tw( baseTriggerStyles, 'bg-fill-floating', 'shadow-elevation-1', 'pressed:bg-fill-floating-pressed' ), borderless: tw( baseTriggerStyles, 'bg-transparent', 'hovered:bg-fill-secondary', 'pressed:bg-fill-secondary-pressed' ), }; const TriggerWidths: Record = { hug: tw(), full: tw('w-full'), }; type TriggerChevronConfig = { chevronStyles: TailwindStyles; contentStyles: TailwindStyles; }; const baseContentStyles = tw('flex min-w-0 flex-1'); const TriggerChevrons: Record = { always: { chevronStyles: tw('ps-2 pe-2.5'), contentStyles: tw(baseContentStyles, 'py-[5px] ps-3'), }, 'on-hover': { chevronStyles: tw( 'absolute inset-y-0 end-0 w-9.5', 'flex items-center justify-end pe-2', 'opacity-0 group-focus:opacity-100 group-data-[state=open]:opacity-100 group-hovered:opacity-100', 'transition-opacity duration-150' ), contentStyles: tw( baseContentStyles, 'px-3 py-[5px]', '[--axo-select-trigger-mask-start:black]', 'group-hovered:[--axo-select-trigger-mask-start:transparent]', 'group-focus:[--axo-select-trigger-mask-start:transparent]', 'group-data-[state=open]:[--axo-select-trigger-mask-start:transparent]', '[mask-image:linear-gradient(to_left,var(--axo-select-trigger-mask-start)_19px,black_38px)]', 'rtl:[mask-image:linear-gradient(to_right,var(--axo-select-trigger-mask-start)_19px,black_38px)]', '[mask-repeat:no-repeat]', '[mask-position:right] rtl:[mask-position:left]', '[transition-property:--axo-select-trigger-mask-start] duration-150' ), }, }; export type TriggerProps = Readonly<{ variant?: TriggerVariant; width?: TriggerWidth; chevron?: TriggerChevron; placeholder: string; children?: ReactNode; }>; /** * The button that toggles the select. * The {@link AxoSelect.Content} will position itself by aligning over the * trigger. */ export const Trigger: FC = memo(props => { const variant = props.variant ?? 'default'; const width = props.width ?? 'hug'; const chevron = props.chevron ?? 'always'; const variantStyles = TriggerVariants[variant]; const widthStyles = TriggerWidths[width]; const chevronConfig = TriggerChevrons[chevron]; return (
{props.children}
); }); Trigger.displayName = `${Namespace}.Trigger`; /** * Component: * ------------------------------ */ export type ContentPosition = 'item-aligned' | 'dropdown'; type ContentPositionConfig = { position: Select.SelectContentProps['position']; alignOffset?: Select.SelectContentProps['alignOffset']; collisionPadding?: Select.SelectContentProps['collisionPadding']; sideOffset?: Select.SelectContentProps['sideOffset']; }; const ContentPositions: Record = { 'item-aligned': { position: 'item-aligned', }, dropdown: { position: 'popper', alignOffset: 0, collisionPadding: 6, sideOffset: 8, }, }; export type ContentProps = Readonly<{ position?: ContentPosition; children: ReactNode; }>; /** * The component that pops out when the select is open. * Uses a portal to render the content part into the `body`. */ export const Content: FC = memo(props => { const position = props.position ?? 'item-aligned'; const positionConfig = ContentPositions[position]; return (
{props.children}
); }); Content.displayName = `${Namespace}.Content`; /** * Component: * --------------------------- */ export type ItemProps = Readonly<{ value: string; disabled?: boolean; textValue?: string; symbol?: AxoSymbol.IconName; children: ReactNode; }>; /** * The component that contains the select items. */ export const Item: FC = memo(props => { return ( {props.symbol && ( )} {props.children} ); }); Item.displayName = `${Namespace}.Content`; /** * Component: */ export type ItemTextProps = Readonly<{ children: ReactNode; }>; export const ItemText: FC = memo(props => { return ( {props.children} ); }); ItemText.displayName = `${Namespace}.ItemText`; /** * Component: * -------------------------------- */ export type ExperimentalItemBadgeProps = Omit< ExperimentalAxoBadge.RootProps, 'size' >; export const ExperimentalItemBadge = memo( (props: ExperimentalItemBadgeProps) => { return ( ); } ); ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`; /** * Component: * ---------------------------- */ export type GroupProps = Readonly<{ children: ReactNode; }>; /** * Used to group multiple items. * Use in conjunction with {@link AxoSelect.Label to ensure good accessibility * via automatic labelling. */ export const Group: FC = memo(props => { return ( {props.children} ); }); Group.displayName = `${Namespace}.Group`; /** * Component: * --------------------------- */ export type LabelProps = Readonly<{ children: ReactNode; }>; /** * Used to render the label of a group. It won't be focusable using arrow keys. */ export const Label: FC = memo(props => { return ( {props.children} ); }); Label.displayName = `${Namespace}.Label`; /** * Component: * --------------------------- */ export type SeparatorProps = Readonly<{ // N/A }>; /** * Used to visually separate items in the select. */ export const Separator: FC = memo(() => { return ; }); Separator.displayName = `${Namespace}.Separator`; }