feat(components): 添加 Select 组件

- 实现了一个功能完善的 Select 下拉选择组件
- 支持简单选项和分组选项两种类型
- 提供正常、下划线和沉浸三种样式变体
- 具备单选和受控组件功能
- 优化了可访问性和交互体验
This commit is contained in:
Vixalie
2025-08-14 23:01:05 +08:00
parent 8efd95127d
commit 34584ff9ef

207
src/components/Select.tsx Normal file
View File

@@ -0,0 +1,207 @@
import { Icon } from '@iconify-icon/solid';
import cx from 'clsx';
import { isNil, isNotNil } from 'es-toolkit';
import {
Component,
createEffect,
createMemo,
createSignal,
Index,
JSX,
Match,
mergeProps,
onMount,
Show,
Switch,
} from 'solid-js';
import { Dynamic, Portal } from 'solid-js/web';
import Divider from './Divider';
import ScrollArea from './ScrollArea';
interface Option {
label: JSX.Element;
value: string | number | symbol | boolean;
}
interface SimpleOptionsProps {
options: Option[];
active?: Option['value'];
onClick?: (option: Option) => void;
}
const SimpleOptions: Component<SimpleOptionsProps> = (props) => {
const handleClick = (value: Option['value']) => {
props.onClick?.(value);
};
return (
<div class="flex flex-col items-stretch gap-0.5">
<Index each={props.options}>
{(option) => (
<div
class={cx(
'rounded-sm px-2 py-1 transition-colors duration-100',
option().value === props.active && 'bg-primary-surface text-on-primary-surface',
option().value === props.active
? 'hover:bg-primary-surface-hover hover:text-on-primary-surface'
: 'hover:bg-neutral-hover hover:text-on-neutral',
)}
onClick={() => handleClick(option().value)}>
{option().label}
</div>
)}
</Index>
</div>
);
};
interface GroupedOptionsProps {
options: Record<string, Option[]>;
active?: Option['value'];
onClick?: (value: Option['value']) => void;
}
const GroupedOptions: Component<GroupedOptionsProps> = (props) => {
const entries = createMemo(() => Object.entries(props.options));
const handleClick = (value: Option['value']) => {
props.onClick?.(value);
};
return (
<div class="flex flex-col items-stretch gap-1">
<Index each={entries()}>
{(entry) => (
<div class="flex flex-col items-stretch gap-1">
<span class="px-1 text-on-surface/38 text-[0.6em]">{entry()[0]}</span>
<Divider faded />
<div class="flex flex-col items-stretch gap-0.5">
<Index each={entry()[1]}>
{(option) => (
<div
class={cx(
'rounded-sm px-2 py-1 transition-colors duration-100',
option().value === props.active &&
'bg-primary-surface text-on-primary-surface',
option().value === props.active
? 'hover:bg-primary-surface-hover hover:text-on-primary-surface'
: 'hover:bg-neutral-hover hover:text-on-neutral',
)}
onClick={() => handleClick(option().value)}>
{option().label}
</div>
)}
</Index>
</div>
</div>
)}
</Index>
</div>
);
};
interface SelectProps {
variant?: 'normal' | 'underlined' | 'immersive';
placeholder?: string;
name?: string;
options: Option[] | Record<string, Option[]>;
value?: Option['value'];
defaultValue?: Option['value'];
disabled?: boolean;
onChange?: (value: Option['value']) => void;
}
const Select: Component<SelectProps> = (props) => {
const mProps = mergeProps<SelectProps[]>(
{
variant: 'normal',
placeholder: '',
options: [],
disabled: false,
},
props,
);
let trigger: HTMLDivElement;
let optionFrame: HTMLDivElement;
const [selected, setSelected] = createSignal<Option['value'] | undefined>(undefined);
const [optionVisible, setOptionVisible] = createSignal(false);
onMount(() => {
if (isNotNil(mProps.defaultValue)) {
setSelected(mProps.defaultValue);
}
});
createEffect(() => {
if (isNotNil(mProps.value) && mProps.value !== selected()) {
setSelected(mProps.value);
}
});
const displayLabel = createMemo(() => {
const placeholderContent = <span>{mProps.placeholder}</span>;
if (isNil(selected())) {
return placeholderContent;
}
if (Array.isArray(mProps.options)) {
const selectedOption = mProps.options.find((option) => option.value === selected());
return selectedOption?.label ?? placeholderContent;
} else {
for (const [, entry] of Object.entries(mProps.options)) {
const option = entry.find((opt) => opt.value === selected());
if (isNotNil(option)) {
return option.label;
}
}
return placeholderContent;
}
});
return (
<div class="relative inline-block">
<div
ref={trigger}
aria-disabled={mProps.disabled}
class={cx(
'flex items-center gap-3 min-h-[1em] px-3 py-1.5 cursor-pointer aria-disabled:cursor-not-allowed',
mProps.variant === 'normal' &&
'rounded-sm bg-neutral text-on-neutral aria-disabled:text-neutral-disabled hover:not-aria-disabled:text-primary-hover',
mProps.variant === 'underlined' &&
'border-b border-solid pb-[calc(var(--spacing)*1.5-1px)] border-on-surface text-on-surface aria-disabled:border-neutral-disabled aria-disabled:text-neutral-disabled hover:not-aria-disabled:text-primary-hover hover:not-aria-disabled:border-primary-hover',
mProps.variant === 'immersive' &&
'text-on-surface hover:not-aria-disabled:text-primary-hover aria-disabled:text-neutral-disabled',
)}>
<div class="flex-1 flex flex-row items-center truncate">
<Dynamic component={displayLabel} />
</div>
<Icon
icon="hugeicons:arrow-down-01"
class={cx(
'transition-transform duration-300',
optionVisible() ? 'rotate-180' : 'rotate-none',
)}
/>
</div>
<Show when={optionVisible()}>
<Portal>
<div
ref={optionFrame}
class="absolute z-[15] w-auto rounded-sm bg-surface-active px-1 py-1 elevation-1">
<ScrollArea enableY fullSize>
<Switch fallback={<GroupedOptions options={mProps.options} />}>
<Match when={Array.isArray(mProps.options)}>
<SimpleOptions options={mProps.options} />
</Match>
</Switch>
</ScrollArea>
</div>
</Portal>
</Show>
<Show when={isNotNil(mProps.name)}>
<input type="hidden" name={mProps.name} value={selected()} />
</Show>
</div>
);
};
export default Select;