5180 fichiers 38.5 Mo 0 téléchargements 127 vues mis à jour il y a 4 heures ui
Select.tsx 7.4 Ko Télécharger le fichier
import { Select as BaseSelect } from '@base-ui/react/select';
import { Children, CSSProperties, FC, Fragment, isValidElement, ReactElement, ReactNode, useState } from 'react';
import { nativeTextChildren } from './native-text/nativeTextChildren';
import { isNativeTextEnabled } from './native-text/nativeTextEnabled';

export interface SelectChangeEvent {
    target: { value: string; name?: string };
    currentTarget: { value: string; name?: string };
    preventDefault: () => void;
    stopPropagation: () => void;
}

export interface SelectProps {
    value?: string | number;
    /** Initial value when `value` is not given: the select then keeps its own choice, like an uncontrolled native one. */
    defaultValue?: string | number;
    /** Same shape as a native select's change event: read `event.target.value` (always a string). */
    onChange?: (event: SelectChangeEvent) => void;
    /** `<option>` / `<optgroup>` elements, written exactly like a native select. */
    children?: ReactNode;
    className?: string;
    style?: CSSProperties;
    disabled?: boolean;
    id?: string;
    name?: string;
    title?: string;
    'aria-label'?: string;
    'aria-labelledby'?: string;
    'data-testid'?: string;
}

interface SelectOption {
    value: string;
    label: ReactNode;
    disabled: boolean;
}

interface SelectGroup {
    label: ReactNode;
    options: SelectOption[];
}

type SelectEntry = SelectOption | SelectGroup;

const isGroup = (entry: SelectEntry): entry is SelectGroup => 'options' in entry;

const textOf = (node: ReactNode): string => {
    if (node === null || node === undefined || typeof node === 'boolean') return '';
    if (typeof node === 'string' || typeof node === 'number') return String(node);
    if (Array.isArray(node)) return node.map(textOf).join('');
    if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);

    return '';
};

const collect = (nodes: ReactNode, out: SelectEntry[]) => {
    Children.forEach(nodes, (child) => {
        if (!isValidElement(child)) return;

        const element = child as ReactElement<{ value?: string | number; disabled?: boolean; hidden?: boolean; label?: ReactNode; children?: ReactNode }>;

        if (element.type === Fragment) return collect(element.props.children, out);

        if (element.type === 'optgroup') {
            const options: SelectOption[] = [];

            collect(element.props.children, options);
            out.push({ label: element.props.label, options: options.filter((entry): entry is SelectOption => !isGroup(entry)) });

            return;
        }

        if (element.type === 'option') {
            if (element.props.hidden) return;

            out.push({
                value: element.props.value !== undefined ? String(element.props.value) : textOf(element.props.children),
                label: element.props.children,
                disabled: !!element.props.disabled
            });
        }
    });
};

const makeEvent = (value: string, name?: string): SelectChangeEvent => ({
    target: { value, name },
    currentTarget: { value, name },
    preventDefault: () => undefined,
    stopPropagation: () => undefined
});

/**
 * Base UI select that is a drop-in for a native `<select value onChange>` with
 * `<option>` children. The trigger keeps the caller's className (e.g. `form-select`),
 * and carries `octane-select-trigger` so the css written for `select` also applies.
 */
export const Select: FC<SelectProps> = (props) => {
    const { value, defaultValue, onChange, children, className, style, disabled, id, name, title, ...aria } = props;
    const entries: SelectEntry[] = [];

    collect(children, entries);

    const flat = entries.flatMap((entry) => (isGroup(entry) ? entry.options : [entry]));
    const [innerValue, setInnerValue] = useState(defaultValue);
    const selected = value !== undefined ? value : innerValue;
    const current = selected === undefined || selected === null ? '' : String(selected);
    // A native select shows its first option when the value matches none.
    const shown = flat.find((option) => option.value === current) ?? flat[0];

    // The option text is an AIR label keyed by the item state, so it is drawn again in the highlighted / selected colours.
    const renderOption = (option: SelectOption) => (
        <BaseSelect.Item
            key={option.value}
            className="octane-select-item"
            disabled={option.disabled}
            label={textOf(option.label)}
            render={(itemProps, state) => (
                <div {...itemProps}>
                    <BaseSelect.ItemText>{nativeTextChildren(option.label, `${state.highlighted}|${state.selected}|${state.disabled}`)}</BaseSelect.ItemText>
                </div>
            )}
            value={option.value}
        />
    );

    return (
        <BaseSelect.Root
            disabled={disabled}
            name={name}
            value={current}
            onValueChange={(next) => {
                const picked = next === null || next === undefined ? '' : String(next);

                if (value === undefined) setInnerValue(picked);
                onChange?.(makeEvent(picked, name));
            }}
        >
            <BaseSelect.Trigger
                className={className ? `octane-select-trigger ${className}` : 'octane-select-trigger'}
                id={id}
                style={style}
                title={title}
                {...aria}
            >
                <span className="octane-select-body">
                    <BaseSelect.Value className="octane-select-value">
                        {() => nativeTextChildren(shown?.label ?? '', `${className}|${disabled}`)}
                    </BaseSelect.Value>
                    {/* Reserves the width of the widest option, like a native select, without duplicating text nodes. */}
                    {/* With AIR text it measures in AIR's Ubuntu (wider than the condensed cut), so the drawn value is not cut off. */}
                    <span aria-hidden="true" className={isNativeTextEnabled() ? 'octane-select-sizer is-native' : 'octane-select-sizer'}>
                        {flat.map((option) => (
                            <span key={option.value} data-label={textOf(option.label)} />
                        ))}
                    </span>
                </span>
            </BaseSelect.Trigger>
            <BaseSelect.Portal>
                <BaseSelect.Positioner alignItemWithTrigger={false} className="octane-select-positioner" sideOffset={2}>
                    <BaseSelect.Popup className="octane-select-popup">
                        <BaseSelect.List className="octane-select-list">
                            {entries.map((entry, index) =>
                                isGroup(entry) ? (
                                    <BaseSelect.Group key={`group-${index}`}>
                                        <BaseSelect.GroupLabel className="octane-select-group-label">{nativeTextChildren(entry.label)}</BaseSelect.GroupLabel>
                                        {entry.options.map(renderOption)}
                                    </BaseSelect.Group>
                                ) : (
                                    renderOption(entry)
                                )
                            )}
                        </BaseSelect.List>
                    </BaseSelect.Popup>
                </BaseSelect.Positioner>
            </BaseSelect.Portal>
        </BaseSelect.Root>
    );
};
15Sujets
1Dépôts
20Membres
7En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser