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