// 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 (
);
});
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`;
}