Compare commits

...

10 Commits

Author SHA1 Message Date
Vixalie
b7bbb3155f feat(demo): 添加 Input 组件示例
- 在 ComponentsDemo 页面中添加了多种类型的 Input 组件示例
- 包括基本输入、带图标的输入、不同变体的输入以及禁用状态的输入
- 添加了 Select 组件示例
- 优化了页面布局,增加了更多组件的展示
2025-08-24 11:34:18 +08:00
Vixalie
21426a27b2 feat(Segments): 添加只读属性并优化相关交互
- 在 Segments 组件中添加 readonly 属性
- 实现只读状态下的组件行为
- 为选项添加 aria-readonly 属性
- 调整只读状态下选项的样式
2025-08-24 11:27:11 +08:00
Vixalie
df1ccb783f feat(components): 为复选框和单选框组件添加只读属性
- 在 CheckBoxProps、RadioProps 和 RadioGroupProps 接口中添加 readonly 属性
- 在 Check、Radio 和 RadioGroup 组件中实现只读逻辑
- 修改 handleClick 和 handleSelect 方法,增加对只读属性的判断
2025-08-24 11:26:06 +08:00
Vixalie
79a7d4c92a feat(Select): 添加只读属性并优化相关交互
- 在 Select 组件的Props中添加 readonly 属性
- 当组件为只读状态时,禁用下拉框的展开和收缩功能
- 在触发器元素上添加 aria-readonly 属性,提高无障碍访问性
- 调整样式,使只读状态下的组件外观保持一致
2025-08-24 11:24:21 +08:00
Vixalie
9eeb223b97 feat(Input): 增加只读属性并优化相关交互
- 在 Input 组件的属性中添加 readonly
- 实现只读状态下的输入框行为:禁止输入但允许聚焦
- 为只读状态的输入框添加视觉反馈
2025-08-24 11:19:58 +08:00
Vixalie
9fe13bde17 fix(Input): 修复 Input 组件禁用状态样式
- 移除了多余的 disabled 属性相关样式
- 统一使用 aria-disabled 属性来控制禁用状态样式
- 在 input 元素上添加 disabled:text-neutral-disabled 样式,确保禁用状态下文本颜色正确
2025-08-24 11:07:55 +08:00
Vixalie
dd4d9b67ee refactor(Input): 调整 InputProps 接口定义
- 移除了 InputProps 接口中不必要的 JSX.HTMLAttributes<HTMLInputElement> 继承
- 这个改动可以简化接口定义,提高代码可读性
2025-08-22 10:12:28 +08:00
Vixalie
4b394f8143 feat(components): 添加 Input 组件
- 实现了一个通用的 Input 组件,支持多种变体和自定义样式
- 组件属性包括 name、variant、left、right、value、defaultValue 等
- 支持 onInput 事件处理
- 优化了组件的默认值设置和响应式行为
2025-08-22 10:11:51 +08:00
Vixalie
75291cda8c feat(components): 添加组件演示页面
- 在 ComponentsDemo.tsx 中添加了多种组件的演示
- 包括 Radio、RadioGroup、Check、Segments、Select 和 Switcher 组件
- 展示了不同配置和状态下的组件用法
2025-08-21 08:41:18 +08:00
Vixalie
6c81c22c72 feat(Select): 优化下拉框位置调整和交互逻辑
- 添加光标悬停样式,增强用户体验
- 实现下拉框位置动态调整,适应不同屏幕尺寸
- 增加鼠标移入移出事件处理,优化下拉框显示逻辑
- 修复下拉框颜色样式,提升视觉一致性
2025-08-21 06:09:44 +08:00
7 changed files with 433 additions and 13 deletions

View File

@@ -17,6 +17,7 @@ interface CheckBoxProps {
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
readonly?: boolean;
onChange?: (checked?: boolean) => void;
}
@@ -35,6 +36,7 @@ const Check: ParentComponent<CheckBoxProps> = (props) => {
const mProps = mergeProps<ParentProps<CheckBoxProps>[]>(
{
disabled: false,
readonly: false,
},
props,
);
@@ -54,7 +56,7 @@ const Check: ParentComponent<CheckBoxProps> = (props) => {
});
const handleClick = () => {
if (mProps.disabled) {
if (mProps.disabled || mProps.readonly) {
return;
}
setInternalChecked((prev) => !prev);

83
src/components/Input.tsx Normal file
View File

@@ -0,0 +1,83 @@
import cx from 'clsx';
import { isNotNil } from 'es-toolkit';
import { Component, createEffect, createSignal, JSX, mergeProps, onMount, Show } from 'solid-js';
interface InputProps {
name?: string;
variant?: 'normal' | 'underlined' | 'immersive';
left?: JSX.Element;
right?: JSX.Element;
value?: string;
defaultValue?: string;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
onInput?: (value: string | null) => void;
wrapperClass?: JSX.HTMLAttributes<HTMLDivElement>['class'];
inputClass?: JSX.HTMLAttributes<HTMLInputElement>['class'];
}
const Input: Component<InputProps> = (props) => {
const mProps = mergeProps<InputProps[]>(
{
variant: 'normal',
disabled: false,
readonly: false,
placeholder: '',
},
props,
);
const [internalValue, setInternalValue] = createSignal<string>('');
onMount(() => {
if (isNotNil(mProps.defaultValue)) {
setInternalValue(mProps.defaultValue);
}
});
createEffect(() => {
if (isNotNil(mProps.value) && mProps.value !== internalValue()) {
setInternalValue(mProps.value);
}
});
const handleInput: JSX.EventHandler<HTMLInputElement, InputEvent> = (evt) => {
if (mProps.readonly || mProps.disabled) {
return;
}
const value = evt.currentTarget.value;
setInternalValue(value);
mProps.onInput?.(value);
};
return (
<div
aria-disabled={mProps.disabled}
aria-readonly={mProps.readonly}
class={cx(
'flex flex-row items-center gap-2 min-h-[1em] px-3 py-1.5 aria-readonly:cursor-not-allowed',
mProps.variant === 'normal' &&
'rounded-sm bg-neutral text-on-neutral aria-disabled:cursor-not-allowed aria-disabled:text-neutral-disabled',
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',
mProps.variant === 'immersive' && 'text-on-surface aria-disabled:text-neutral-disabled',
mProps.wrapperClass,
)}>
<Show when={isNotNil(mProps.left)}>{mProps.left}</Show>
<input
name={mProps.name}
type={mProps.type}
placeholder={mProps.placeholder}
disabled={mProps.disabled}
readonly={mProps.readonly}
value={internalValue()}
class={cx(
'min-h-[1em] grow focus:outline-none placeholder:italic disabled:text-neutral-disabled',
mProps.inputClass,
)}
onInput={handleInput}
/>
<Show when={isNotNil(mProps.right)}>{mProps.right}</Show>
</div>
);
};
export default Input;

View File

@@ -17,6 +17,7 @@ interface RadioProps {
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
readonly?: boolean;
onChange?: (value?: boolean) => void;
}
@@ -35,6 +36,7 @@ const Radio: ParentComponent<RadioProps> = (props) => {
const mProps = mergeProps<ParentProps<RadioProps>[]>(
{
disabled: false,
readonly: false,
},
props,
);
@@ -54,7 +56,7 @@ const Radio: ParentComponent<RadioProps> = (props) => {
});
const handleClick = () => {
if (mProps.disabled) {
if (mProps.disabled || mProps.readonly) {
return;
}
setInternalChecked((prev) => !prev);

View File

@@ -23,6 +23,7 @@ interface RadioGroupProps {
value?: Option['value'];
defalutValue?: Option['value'];
disabled?: boolean;
readonly?: boolean;
onChange?: (value?: Option['value']) => void;
}
@@ -31,6 +32,7 @@ const RadioGroup: Component<RadioGroupProps> = (props) => {
{
options: [],
disabled: false,
readonly: false,
},
props,
);
@@ -50,7 +52,7 @@ const RadioGroup: Component<RadioGroupProps> = (props) => {
});
const handleSelect = (value: Option['value']) => {
if (mProps.disabled) {
if (mProps.disabled || mProps.readonly) {
return;
}
setSelectedValue(value);
@@ -64,6 +66,7 @@ const RadioGroup: Component<RadioGroupProps> = (props) => {
<Radio
checked={selectedValue() === option().value}
disabled={mProps.disabled}
readonly={mProps.readonly}
onChange={() => {
handleSelect(option().value);
}}>

View File

@@ -24,6 +24,7 @@ interface SegmentsProps {
defaultValue?: Option['value'];
direction?: 'horizontal' | 'vertical';
disabled?: boolean;
readonly?: boolean;
onChange?: (value: Option['value'] | undefined) => void;
}
@@ -32,6 +33,7 @@ const Segments: Component<SegmentsProps> = (props) => {
{
options: [],
disabled: false,
readonly: false,
direction: 'horizontal',
},
props,
@@ -69,7 +71,7 @@ const Segments: Component<SegmentsProps> = (props) => {
});
const handleSelect = (value: Option['value']) => {
if (mProps.disabled) {
if (mProps.disabled || mProps.readonly) {
return;
}
setSelected(value);
@@ -88,8 +90,9 @@ const Segments: Component<SegmentsProps> = (props) => {
<div
ref={(el) => (optionRefs[index] = el)}
aria-disabled={mProps.disabled}
aria-readonly={mProps.readonly}
class={cx(
'z-[5] cursor-pointer rounded-sm px-2 py-0.5',
'z-[5] cursor-pointer rounded-sm px-2 py-0.5 aria-readonly:cursor-not-allowed',
selected() === option().value
? 'not-aria-disabled:text-on-primary-surface aria-disabled:text-primary-disabled'
: 'not-aria-disabled:text-on-surface aria-disabled:text-neutral-disabled hover:not-aria-disabled:text-primary-hover',

View File

@@ -10,6 +10,7 @@ import {
JSX,
Match,
mergeProps,
onCleanup,
onMount,
Show,
Switch,
@@ -40,7 +41,7 @@ const SimpleOptions: Component<SimpleOptionsProps> = (props) => {
{(option) => (
<div
class={cx(
'rounded-sm px-2 py-1 transition-colors duration-100',
'rounded-sm px-2 py-1 transition-colors duration-100 cursor-pointer',
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'
@@ -79,7 +80,7 @@ const GroupedOptions: Component<GroupedOptionsProps> = (props) => {
{(option) => (
<div
class={cx(
'rounded-sm px-2 py-1 transition-colors duration-100',
'rounded-sm px-2 py-1 transition-colors duration-100 cursor-pointer',
option().value === props.active &&
'bg-primary-surface text-on-primary-surface',
option().value === props.active
@@ -107,6 +108,7 @@ interface SelectProps {
value?: Option['value'];
defaultValue?: Option['value'];
disabled?: boolean;
readonly?: boolean;
onChange?: (value: Option['value']) => void;
}
@@ -117,12 +119,14 @@ const Select: Component<SelectProps> = (props) => {
placeholder: '',
options: [],
disabled: false,
readonly: false,
},
props,
);
let trigger: HTMLDivElement;
let optionFrame: HTMLDivElement;
let hideOptionTimer: number | undefined = undefined;
const [selected, setSelected] = createSignal<Option['value'] | undefined>(undefined);
const [optionVisible, setOptionVisible] = createSignal(false);
@@ -156,21 +160,91 @@ const Select: Component<SelectProps> = (props) => {
return placeholderContent;
}
});
const adjustPosition = () => {
const triggerRect = trigger.getBoundingClientRect();
const optionsRect = optionFrame.getBoundingClientRect();
const distanceToBottom = window.innerHeight - triggerRect.bottom;
if (triggerRect.top < distanceToBottom) {
optionFrame.style.top = `${triggerRect.bottom + 4}px`;
optionFrame.style.height = `${Math.min(distanceToBottom * 0.6, window.innerHeight * 0.4)}px`;
} else {
optionFrame.style.top = `${triggerRect.top - optionsRect.height - 4}px`;
optionFrame.style.height = `${Math.min(distanceToBottom * 0.6, window.innerHeight * 0.4)}px`;
}
optionFrame.style.left = `${triggerRect.left}px`;
optionFrame.style.width = `clamp(${triggerRect.width}px, ${triggerRect.width * 1.5}px, ${
triggerRect.width * 2.5
}px)`;
};
const resizeObserver = new ResizeObserver(adjustPosition);
createEffect(() => {
if (optionVisible()) {
adjustPosition();
resizeObserver.observe(trigger);
}
});
onCleanup(() => {
resizeObserver.unobserve(trigger);
resizeObserver.disconnect();
clearTimeout(hideOptionTimer);
});
const handleMouseEnter = () => {
if (mProps.disabled) {
return;
}
clearTimeout(hideOptionTimer);
};
const handleMouseLeave = () => {
if (mProps.disabled) {
return;
}
clearTimeout(hideOptionTimer);
hideOptionTimer = setTimeout(() => {
setOptionVisible(false);
}, 500);
};
const handleActivation = () => {
if (mProps.disabled) {
return;
}
clearTimeout(hideOptionTimer);
if (!mProps.readonly) {
setOptionVisible((prev) => !prev);
}
};
const handleOptionClick = (value: Option['value']) => {
if (selected() !== value) {
setSelected(value);
mProps.onChange?.(value);
} else {
setSelected(undefined);
mProps.onChange?.(undefined);
}
setOptionVisible(false);
clearTimeout(hideOptionTimer);
};
return (
<div class="relative inline-block">
<div
ref={trigger}
aria-disabled={mProps.disabled}
aria-readonly={mProps.readonly}
class={cx(
'flex items-center gap-3 min-h-[1em] px-3 py-1.5 cursor-pointer aria-disabled:cursor-not-allowed',
'flex items-center gap-3 min-h-[1em] px-3 py-1.5 cursor-pointer aria-disabled:cursor-not-allowed aria-readonly: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',
)}>
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleActivation}>
<div class="flex-1 flex flex-row items-center truncate">
<Dynamic component={displayLabel} />
</div>
@@ -186,11 +260,24 @@ const Select: Component<SelectProps> = (props) => {
<Portal>
<div
ref={optionFrame}
class="absolute z-[15] w-auto rounded-sm bg-surface-active px-1 py-1 elevation-1">
class="absolute z-[15] w-auto rounded-sm bg-neutral-variant-active px-1 py-1 elevation-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<ScrollArea enableY fullSize>
<Switch fallback={<GroupedOptions options={mProps.options} />}>
<Switch
fallback={
<GroupedOptions
options={mProps.options}
active={selected()}
onClick={handleOptionClick}
/>
}>
<Match when={Array.isArray(mProps.options)}>
<SimpleOptions options={mProps.options} />
<SimpleOptions
options={mProps.options}
active={selected()}
onClick={handleOptionClick}
/>
</Match>
</Switch>
</ScrollArea>

View File

@@ -1,8 +1,248 @@
import { Icon } from '@iconify-icon/solid';
import { Component } from 'solid-js';
import Check from '../components/Check';
import Input from '../components/Input';
import Radio from '../components/Radio';
import RadioGroup from '../components/RadioGroup';
import ScrollArea from '../components/ScrollArea';
import Segments from '../components/Segments';
import Select from '../components/Select';
import Switcher from '../components/Switch';
const ComponentsDemo: Component = () => {
return (
<div class="workspace flex flex-col items-stretch gap-2 rounded-sm bg-swatch-neutral-20"></div>
<div class="workspace flex flex-col items-stretch gap-2 rounded-sm bg-swatch-neutral-20 p-4">
<ScrollArea enableY flexExtended>
<div class="flex flex-col items-stretch gap-2">
<div class="flex flex-row gap-1">
<Radio name="t_radio_1" defaultChecked>
Test Radio button
</Radio>
</div>
<div class="flex flex-row items-center gap-3">
<RadioGroup
name="t_radio_group_1"
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
defalutValue="2"
/>
</div>
<div class="flex flex-row gap-1">
<Check name="t_check_1">Test Checkbox</Check>
</div>
<div class="flex flex-row gap-1 items-center">
<Segments
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
/>
<Select
placeholder="Select an option"
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
/>
<Select
placeholder="Select an option"
variant="underlined"
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
/>
<Select
placeholder="Select an option"
variant="immersive"
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
{ label: 'Option 4', value: '4' },
{ label: 'Option 5', value: '5' },
{ label: 'Option 6', value: '6' },
{ label: 'Option 7', value: '7' },
{ label: 'Option 8', value: '8' },
{ label: 'Option 9', value: '9' },
{ label: 'Option 1', value: 'a' },
{ label: 'Option 2', value: 'b' },
{ label: 'Option 3', value: 'c' },
{ label: 'Option 4', value: 'd' },
{ label: 'Option 5', value: 'e' },
{ label: 'Option 6', value: 'f' },
{ label: 'Option 7', value: 'g' },
]}
/>
</div>
<div class="flex flex-row gap-1 items-center">
<Segments
disabled
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
/>
<Select
placeholder="Select an option"
disabled
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
/>
<Select
placeholder="Select an option"
variant="underlined"
disabled
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
/>
<Select
placeholder="Select an option"
variant="immersive"
disabled
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
/>
</div>
<div class="flex flex-row gap-1 text-xs">
<Segments
direction="vertical"
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
]}
defaultValue="3"
/>
</div>
<div class="flex flex-row gap-1">
<Switcher />
</div>
<div class="flex flex-row gap-1 items-center">
<Select
placeholder="Select an option"
options={[
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
{ label: 'Option 3', value: '3' },
{ label: 'Option 4', value: '4' },
{ label: 'Option 5', value: '5' },
{ label: 'Option 6', value: '6' },
{ label: 'Option 7', value: '7' },
{ label: 'Option 8', value: '8' },
{ label: 'Option 9', value: '9' },
{ label: 'Option 1', value: 'a' },
{ label: 'Option 2', value: 'b' },
{ label: 'Option 3', value: 'c' },
{ label: 'Option 4', value: 'd' },
{ label: 'Option 5', value: 'e' },
{ label: 'Option 6', value: 'f' },
{ label: 'Option 7', value: 'g' },
]}
/>
<Input placeholder="输入一些文字内容" />
<Input
placeholder="搜索一些内容"
value="comfy models"
left={<Icon icon="hugeicons:search-01" />}
/>
</div>
<div class="flex flex-row gap-1 items-center">
<Input
placeholder="搜索一些内容"
value="comfy models"
left={<Icon icon="hugeicons:search-01" />}
/>
<Input
variant="underlined"
placeholder="搜索一些内容"
left={<Icon icon="hugeicons:search-01" />}
/>
<Input
variant="immersive"
placeholder="搜索一些内容"
left={<Icon icon="hugeicons:search-01" />}
/>
</div>
<div class="flex flex-row gap-1 items-center">
<Input
placeholder="搜索一些内容"
disabled
value="comfy models"
left={<Icon icon="hugeicons:search-01" />}
/>
<Input
variant="underlined"
disabled
placeholder="搜索一些内容"
value="comfy models"
left={<Icon icon="hugeicons:search-01" />}
/>
<Input
variant="immersive"
disabled
placeholder="搜索一些内容"
value="comfy models"
left={<Icon icon="hugeicons:search-01" />}
/>
</div>
<div class="flex flex-row gap-1 items-center">
<div class="h-[6em] w-[35em] flex flex-row">
<ScrollArea flexExtended enableY>
<div class="flex flex-col gap-[2em]">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla pharetra quam
tincidunt, ornare orci quis, commodo quam. Etiam finibus ex dolor, id feugiat
augue porttitor eget. Morbi ornare nisl sed laoreet efficitur. Phasellus
consectetur lacus id sem elementum dignissim. Aliquam pulvinar facilisis eros,
eget scelerisque magna pharetra ac. Suspendisse potenti. Sed eget finibus magna,
vitae laoreet lorem. Vestibulum gravida accumsan ipsum. Duis eget orci porta,
fringilla velit viverra, ultricies est. Ut turpis erat, lobortis nec hendrerit
eu, malesuada eu dui.
</p>
<p>
Morbi elementum eleifend augue non ultrices. Aenean venenatis tincidunt risus,
sit amet tincidunt dui feugiat ac. Morbi eu aliquam nisi, et ultrices tortor.
Quisque eget faucibus lacus. Integer diam arcu, gravida in pellentesque in,
pharetra sit amet orci. Vestibulum molestie porta lectus, ac laoreet justo
fringilla et. Curabitur convallis ipsum erat, sed blandit lorem consequat ac.
Maecenas aliquet lorem a tellus lacinia placerat. Donec id ante malesuada,
rhoncus diam at, sollicitudin ante. Vivamus iaculis nisl urna.
</p>
<p>
Duis sodales tempus nulla, vel accumsan ex. Curabitur sit amet auctor dolor, non
gravida diam. Quisque ac velit felis. Proin imperdiet varius vestibulum. Mauris
hendrerit id felis et maximus. Phasellus interdum felis sit amet urna semper,
nec tincidunt quam molestie. Nam vitae quam laoreet, blandit velit et, molestie
libero. Sed elit augue, convallis quis dictum vel, eleifend sed mi. In tincidunt
sagittis sapien ac consectetur. Nulla volutpat, turpis pellentesque imperdiet
convallis, ipsum felis pulvinar elit, nec luctus massa ipsum ut ligula. Fusce
tristique sit amet lacus nec ullamcorper. Integer eros dolor, sodales eget
maximus eget, efficitur quis nulla. Curabitur ac mollis elit, sed ultrices erat.
</p>
</div>
</ScrollArea>
</div>
</div>
</div>
</ScrollArea>
</div>
);
};