feat(components): 添加 Select 组件
- 实现了一个功能完善的 Select 下拉选择组件 - 支持简单选项和分组选项两种类型 - 提供正常、下划线和沉浸三种样式变体 - 具备单选和受控组件功能 - 优化了可访问性和交互体验
This commit is contained in:
207
src/components/Select.tsx
Normal file
207
src/components/Select.tsx
Normal 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;
|
Reference in New Issue
Block a user