iter(g60): 新增 multiSelect 多选下拉组件
This commit is contained in:
+54
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "ui-arch-registry-index/0.1",
|
"$schema": "ui-arch-registry-index/0.1",
|
||||||
"generatedAt": "2026-10-26T04:23:09.567Z",
|
"generatedAt": "2026-10-26T04:24:59.738Z",
|
||||||
"repoRoot": ".",
|
"repoRoot": ".",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
@@ -198,7 +198,8 @@
|
|||||||
"tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose",
|
"tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose",
|
||||||
"watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互",
|
"watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互",
|
||||||
"checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项",
|
"checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项",
|
||||||
"affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应"
|
"affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应",
|
||||||
|
"multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用"
|
||||||
],
|
],
|
||||||
"keyTypes": [
|
"keyTypes": [
|
||||||
"ButtonProps",
|
"ButtonProps",
|
||||||
@@ -2549,6 +2550,57 @@
|
|||||||
"固定期间宽度与横向位置跟随占位容器,窗口缩放或父容器变宽都自动贴合",
|
"固定期间宽度与横向位置跟随占位容器,窗口缩放或父容器变宽都自动贴合",
|
||||||
"内滚容器场景把该元素传给 target,判定改为基于它的滚动位置"
|
"内滚容器场景把该元素传给 target,判定改为基于它的滚动位置"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "multiSelect",
|
||||||
|
"title": "多选下拉 MultiSelect",
|
||||||
|
"demo": "sec-multiselect",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "options",
|
||||||
|
"t": "CheckOption[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "选项集合:value / label / 可选 disabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string[]>",
|
||||||
|
"d": "选中值数组信号;不传则内部维护"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "未选时占位文案;已选时让位给标签"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "max",
|
||||||
|
"t": "number",
|
||||||
|
"d": "最多选几项:到上限后未选项自动禁用"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "searchable",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "是否可输入过滤;缺省 true"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "disabled",
|
||||||
|
"t": "boolean | Signal<boolean>",
|
||||||
|
"d": "禁用整个下拉"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(values: string[]) => void",
|
||||||
|
"d": "变更回调(传副本)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [{ value: 'ts', label: 'TypeScript' }, { value: 'css', label: 'CSS 令牌' }] })",
|
||||||
|
"steps": [
|
||||||
|
"选中项以标签形式回显,点标签上的 × 或空输入时按退格即可删除末个标签",
|
||||||
|
"点输入区展开弹层并聚焦输入框,输入字符即时过滤(大小写不敏感)",
|
||||||
|
"点选项只切换该项、弹层不收起,适合连续多选;到 max 后未选项置灰不可点",
|
||||||
|
"↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)",
|
||||||
|
"value 传数组信号即受控,外部写入会同步标签区"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
- `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核
|
- `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核
|
||||||
- `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架
|
- `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架
|
||||||
- `packages/skin/ui`(@ui-arch/ui):55 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌
|
- `packages/skin/ui`(@ui-arch/ui):56 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌
|
||||||
- `packages/design/tokens`(@ui-arch/tokens):设计令牌
|
- `packages/design/tokens`(@ui-arch/tokens):设计令牌
|
||||||
- `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private)
|
- `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private)
|
||||||
- `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html
|
- `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ ui-arch-mvp/
|
|||||||
│ │ ├── table-core/ # 表格状态内核
|
│ │ ├── table-core/ # 表格状态内核
|
||||||
│ │ └── frame/ # 信号 + hyperscript 微框架
|
│ │ └── frame/ # 信号 + hyperscript 微框架
|
||||||
│ ├── skin/ # 皮肤层(DOM 组件,可发布)
|
│ ├── skin/ # 皮肤层(DOM 组件,可发布)
|
||||||
│ │ └── ui/ # 55 个组件 + ui.css
|
│ │ └── ui/ # 56 个组件 + ui.css
|
||||||
│ ├── design/ # 资产层(可发布)
|
│ ├── design/ # 资产层(可发布)
|
||||||
│ │ └── tokens/ # --ua-* 设计令牌
|
│ │ └── tokens/ # --ua-* 设计令牌
|
||||||
│ └── tools/ # 工程层(private,不发布)
|
│ └── tools/ # 工程层(private,不发布)
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ npm run demo # 打开演示:apps/demo/dist/demo.html
|
|||||||
npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt
|
npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## 组件(共 55 个)
|
## 组件(共 56 个)
|
||||||
|
|
||||||
<!-- ua:components -->
|
<!-- ua:components -->
|
||||||
accordion · affix · alert · autoComplete · avatar · backTop · badge · breadcrumb · button · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · upload · watermark
|
accordion · affix · alert · autoComplete · avatar · backTop · badge · breadcrumb · button · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · multiSelect · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · upload · watermark
|
||||||
<!-- /ua:components -->
|
<!-- /ua:components -->
|
||||||
|
|
||||||
分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(divider / splitter / backTop / affix / tour / watermark)。
|
分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(divider / splitter / backTop / affix / tour / watermark)。
|
||||||
|
|
||||||
## 可访问性约定
|
## 可访问性约定
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ accordion · affix · alert · autoComplete · avatar · backTop · badge · bre
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) |
|
| `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) |
|
||||||
| `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) |
|
| `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) |
|
||||||
| `packages/skin/ui` | @ui-arch/ui:55 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
|
| `packages/skin/ui` | @ui-arch/ui:56 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
|
||||||
| `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) |
|
| `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) |
|
||||||
| `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server |
|
| `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server |
|
||||||
| `apps/demo` | 交互演示(esbuild 打包成单文件) |
|
| `apps/demo` | 交互演示(esbuild 打包成单文件) |
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
watermark,
|
watermark,
|
||||||
menu,
|
menu,
|
||||||
modal,
|
modal,
|
||||||
|
multiSelect,
|
||||||
pagination,
|
pagination,
|
||||||
popover,
|
popover,
|
||||||
progress,
|
progress,
|
||||||
@@ -194,6 +195,7 @@ const listPick = signal(''); // 列表点击选中的条目标题
|
|||||||
const slideIndex = signal(0); // 轮播当前下标(外部受控演示)
|
const slideIndex = signal(0); // 轮播当前下标(外部受控演示)
|
||||||
const cascaderPath = signal<string[]>(['east', 'sh']); // 级联选择路径(外部受控演示)
|
const cascaderPath = signal<string[]>(['east', 'sh']); // 级联选择路径(外部受控演示)
|
||||||
const splitSizes = signal<number[]>([35, 65]); // 分栏比例(外部受控演示)
|
const splitSizes = signal<number[]>([35, 65]); // 分栏比例(外部受控演示)
|
||||||
|
const tagPick = signal<string[]>(['ts']); // 多选下拉选中值(外部受控演示)
|
||||||
const permPick = signal<string[]>(['read']); // 多选组选中值(外部受控演示)
|
const permPick = signal<string[]>(['read']); // 多选组选中值(外部受控演示)
|
||||||
const colorVal = signal('var(--ua-color-accent)'); // 颜色选择值(外部受控演示)
|
const colorVal = signal('var(--ua-color-accent)'); // 颜色选择值(外部受控演示)
|
||||||
const dateVal = signal('2026-10-26'); // 日期选择值(外部受控演示)
|
const dateVal = signal('2026-10-26'); // 日期选择值(外部受控演示)
|
||||||
@@ -698,6 +700,29 @@ const content = h(
|
|||||||
button({ label: '错误(可关)', variant: 'danger', onClick: () => notification({ title: '保存失败', description: '服务端返回 500,请稍后重试', variant: 'danger' }) }),
|
button({ label: '错误(可关)', variant: 'danger', onClick: () => notification({ title: '保存失败', description: '服务端返回 500,请稍后重试', variant: 'danger' }) }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
section(
|
||||||
|
'sec-multiselect',
|
||||||
|
'多选下拉',
|
||||||
|
'multiSelect:标签式回显 + 可搜索弹层,点选不收起弹层(连续多选),空输入退格删末个标签,max 到限后未选项禁用。',
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'row' },
|
||||||
|
multiSelect({
|
||||||
|
value: tagPick,
|
||||||
|
max: 3,
|
||||||
|
placeholder: '选择技术栈(最多 3 项)',
|
||||||
|
onChange: (v) => toast.info(`已选 ${v.length} 项`),
|
||||||
|
options: [
|
||||||
|
{ value: 'ts', label: 'TypeScript' },
|
||||||
|
{ value: 'dom', label: '原生 DOM' },
|
||||||
|
{ value: 'css', label: 'CSS 令牌' },
|
||||||
|
{ value: 'test', label: '测试' },
|
||||||
|
{ value: 'wasm', label: 'WASM(暂未支持)', disabled: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
h('span', { class: 'out' }, () => `已选:${tagPick().join(' / ') || '无'}`),
|
||||||
|
),
|
||||||
|
),
|
||||||
section(
|
section(
|
||||||
'sec-checkgroup',
|
'sec-checkgroup',
|
||||||
'多选组',
|
'多选组',
|
||||||
|
|||||||
+30
-1
@@ -1,6 +1,6 @@
|
|||||||
# @ui-arch 组件库 Agent 文档(无图形版)
|
# @ui-arch 组件库 Agent 文档(无图形版)
|
||||||
|
|
||||||
> 自动生成于 2026-10-26T04:23:09.567Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
> 自动生成于 2026-10-26T04:24:59.738Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||||
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
||||||
|
|
||||||
## 快速上手
|
## 快速上手
|
||||||
@@ -1465,6 +1465,34 @@ affix({ offset: 8 }, card({ title: '滚动时我会贴顶' }, h('p', null, '正
|
|||||||
|
|
||||||
现场演示:apps/docs/demo.html#sec-affix
|
现场演示:apps/docs/demo.html#sec-affix
|
||||||
|
|
||||||
|
### multiSelect — 多选下拉 MultiSelect
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
- options(必填):CheckOption[] —— 选项集合:value / label / 可选 disabled
|
||||||
|
- value:Signal<string[]> —— 选中值数组信号;不传则内部维护
|
||||||
|
- placeholder:string —— 未选时占位文案;已选时让位给标签
|
||||||
|
- max:number —— 最多选几项:到上限后未选项自动禁用
|
||||||
|
- searchable:boolean —— 是否可输入过滤;缺省 true
|
||||||
|
- disabled:boolean | Signal<boolean> —— 禁用整个下拉
|
||||||
|
- onChange:(values: string[]) => void —— 变更回调(传副本)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [{ value: 'ts', label: 'TypeScript' }, { value: 'css', label: 'CSS 令牌' }] })
|
||||||
|
```
|
||||||
|
|
||||||
|
手把手:
|
||||||
|
|
||||||
|
1. 选中项以标签形式回显,点标签上的 × 或空输入时按退格即可删除末个标签
|
||||||
|
2. 点输入区展开弹层并聚焦输入框,输入字符即时过滤(大小写不敏感)
|
||||||
|
3. 点选项只切换该项、弹层不收起,适合连续多选;到 max 后未选项置灰不可点
|
||||||
|
4. ↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)
|
||||||
|
5. value 传数组信号即受控,外部写入会同步标签区
|
||||||
|
|
||||||
|
现场演示:apps/docs/demo.html#sec-multiselect
|
||||||
|
|
||||||
API 速览:
|
API 速览:
|
||||||
|
|
||||||
- button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交)
|
- button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交)
|
||||||
@@ -1524,6 +1552,7 @@ API 速览:
|
|||||||
- watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互
|
- watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互
|
||||||
- checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项
|
- checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项
|
||||||
- affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应
|
- affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应
|
||||||
|
- multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用
|
||||||
|
|
||||||
一行用法:
|
一行用法:
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
- watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互
|
- watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互
|
||||||
- checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项
|
- checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项
|
||||||
- affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应
|
- affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应
|
||||||
|
- multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用
|
||||||
- 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')`
|
- 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')`
|
||||||
- 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
|
- 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
|
||||||
- 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
|
- 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export { checkbox } from './checkbox.js';
|
|||||||
export { checkboxGroup } from './checkboxGroup.js';
|
export { checkboxGroup } from './checkboxGroup.js';
|
||||||
export { switchButton } from './switch.js';
|
export { switchButton } from './switch.js';
|
||||||
export { select } from './select.js';
|
export { select } from './select.js';
|
||||||
|
export { multiSelect } from './multiSelect.js';
|
||||||
export { menu } from './menu.js';
|
export { menu } from './menu.js';
|
||||||
export { radioGroup } from './radioGroup.js';
|
export { radioGroup } from './radioGroup.js';
|
||||||
export { accordion } from './accordion.js';
|
export { accordion } from './accordion.js';
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { effect, h, signal } from '@ui-arch/frame';
|
||||||
|
import type { Signal } from '@ui-arch/frame';
|
||||||
|
import { pushOverlay } from './overlay.js';
|
||||||
|
import { flip } from './position.js';
|
||||||
|
import type { MultiSelectProps } from './types.js';
|
||||||
|
|
||||||
|
/*** 多选下拉:标签式回显 + 可搜索弹层,value 数组信号双向绑定,到 max 未选项自动禁用 ***/
|
||||||
|
export function multiSelect(props: MultiSelectProps): HTMLElement {
|
||||||
|
// —— 状态集中声明 ——
|
||||||
|
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 选中值
|
||||||
|
const open = signal(false); // 弹层开合
|
||||||
|
const query = signal(''); // 搜索词
|
||||||
|
const active = signal(-1); // 键盘高亮下标
|
||||||
|
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
|
||||||
|
const searchable = props.searchable !== false; // 缺省可搜索
|
||||||
|
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||||
|
|
||||||
|
/*** 按搜索词过滤候选:禁用项仍展示(便于解释为何点不了) ***/
|
||||||
|
const visible = () => {
|
||||||
|
const q = query().trim().toLowerCase();
|
||||||
|
return q ? props.options.filter((o) => o.label.toLowerCase().includes(q)) : props.options;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*** 提交变更:写信号 + 回调(上限由弹层禁用项兜住,这里不再拒绝) ***/
|
||||||
|
const update = (next: string[]): void => {
|
||||||
|
value(next);
|
||||||
|
props.onChange?.([...next]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/*** 单项切换:已选则移除,未选且未到上限则加入 ***/
|
||||||
|
const toggle = (v: string): void => {
|
||||||
|
const cur = new Set(value());
|
||||||
|
if (cur.has(v)) cur.delete(v);
|
||||||
|
else if (props.max == null || cur.size < props.max) cur.add(v);
|
||||||
|
update([...cur]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagWrap = h('span', { class: 'ua-multiselect-tags' }) as HTMLElement; // 标签区:每次重绘只换这里
|
||||||
|
const box = h('div', { class: 'ua-multiselect-box' }) as HTMLElement; // 标签 + 输入框容器
|
||||||
|
const input = h('input', {
|
||||||
|
type: 'text',
|
||||||
|
class: 'ua-multiselect-input',
|
||||||
|
placeholder: '',
|
||||||
|
'aria-label': props.placeholder ?? '多选下拉',
|
||||||
|
role: 'combobox',
|
||||||
|
'aria-expanded': () => String(open()),
|
||||||
|
disabled: props.disabled,
|
||||||
|
oninput: (e: Event) => {
|
||||||
|
query((e.target as HTMLInputElement).value);
|
||||||
|
active(-1);
|
||||||
|
open(true); // 输入即展开,便于边打边选
|
||||||
|
},
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
box.append(tagWrap, input); // 标签区在前、输入框在后,横向排布
|
||||||
|
const list = h('ul', { class: 'ua-multiselect-list', role: 'listbox', 'aria-multiselectable': 'true' }) as HTMLElement;
|
||||||
|
const pop = h('div', { class: () => `ua-multiselect-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement;
|
||||||
|
const root = h('div', { class: () => `ua-multiselect${open() ? ' ua-open' : ''}` }, box, pop) as HTMLElement;
|
||||||
|
|
||||||
|
/*** 重绘标签区与候选:标签带删除钮,候选带勾选态与禁用态 ***/
|
||||||
|
const render = (): void => {
|
||||||
|
const cur = value();
|
||||||
|
const full = props.max != null && cur.length >= props.max; // 是否已达上限
|
||||||
|
const tags = cur.map((v) => {
|
||||||
|
const label = props.options.find((o) => o.value === v)?.label ?? v;
|
||||||
|
const del = h('button', { type: 'button', class: 'ua-multiselect-x', 'aria-label': `移除 ${label}`, onClick: () => toggle(v) }, '×');
|
||||||
|
return h('span', { class: 'ua-multiselect-tag' }, h('span', {}, label), del);
|
||||||
|
});
|
||||||
|
tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容)
|
||||||
|
input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案
|
||||||
|
const rows = visible().map((o, i) => {
|
||||||
|
const on = cur.includes(o.value);
|
||||||
|
const dead = o.disabled === true || (full && !on); // 到上限:未选项不可再点
|
||||||
|
return h(
|
||||||
|
'li',
|
||||||
|
{
|
||||||
|
class: () => `ua-multiselect-opt${on ? ' is-selected' : ''}${active() === i ? ' is-active' : ''}${dead ? ' is-disabled' : ''}`,
|
||||||
|
role: 'option',
|
||||||
|
'aria-selected': String(on),
|
||||||
|
onmousedown: (e: Event) => { e.preventDefault(); if (!dead) toggle(o.value); }, // 阻止默认:保住输入焦点,弹层不收起
|
||||||
|
},
|
||||||
|
h('span', { class: 'ua-multiselect-check', 'aria-hidden': 'true' }, on ? '✓' : ''),
|
||||||
|
h('span', {}, o.label),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!rows.length) rows.push(h('li', { class: 'ua-multiselect-empty' }, '无匹配项'));
|
||||||
|
list.replaceChildren(...rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
box.addEventListener('click', () => { if (!(props.disabled === true)) { open(true); input.focus(); } }); // 点空白处展开并聚焦输入
|
||||||
|
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
const opts = visible();
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!open()) { open(true); active(0); return; }
|
||||||
|
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||||
|
active((active() + dir + opts.length) % opts.length); // 环形移动
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && open() && active() >= 0) { e.preventDefault(); toggle(opts[active()].value); return; }
|
||||||
|
if (e.key === 'Backspace' && !input.value && value().length) { update(value().slice(0, -1)); return; } // 空输入时退格删末个标签
|
||||||
|
if (e.key === 'Escape') { open(false); return; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||||
|
open(false);
|
||||||
|
});
|
||||||
|
effect(render);
|
||||||
|
effect(() => {
|
||||||
|
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
|
||||||
|
else { releaseEsc?.(); releaseEsc = null; }
|
||||||
|
});
|
||||||
|
effect(() => { if (open()) place(flip(box, pop, 'bottom')); });
|
||||||
|
return root;
|
||||||
|
}
|
||||||
@@ -626,4 +626,15 @@ export interface AffixProps {
|
|||||||
target?: HTMLElement | Window; // 监听的滚动容器;缺省 window
|
target?: HTMLElement | Window; // 监听的滚动容器;缺省 window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*** 多选下拉入参 ***/
|
||||||
|
export interface MultiSelectProps {
|
||||||
|
options: CheckOption[]; // 选项集合(复用 CheckOption:value / label / disabled)
|
||||||
|
value?: Signal<string[]>; // 选中值数组信号;不传则内部维护
|
||||||
|
placeholder?: string; // 未选时占位文案;缺省「请选择」
|
||||||
|
max?: number; // 最多选几项:到上限后未选项禁用
|
||||||
|
searchable?: boolean; // 是否可输入过滤;缺省 true
|
||||||
|
disabled?: boolean | Signal<boolean>; // 禁用整个下拉
|
||||||
|
onChange?: (values: string[]) => void; // 变更回调(传副本)
|
||||||
|
}
|
||||||
|
|
||||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||||
|
|||||||
@@ -69,7 +69,8 @@
|
|||||||
"tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose",
|
"tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose",
|
||||||
"watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互",
|
"watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互",
|
||||||
"checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项",
|
"checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项",
|
||||||
"affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应"
|
"affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应",
|
||||||
|
"multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用"
|
||||||
],
|
],
|
||||||
"keyTypes": [
|
"keyTypes": [
|
||||||
"ButtonProps",
|
"ButtonProps",
|
||||||
@@ -2420,6 +2421,57 @@
|
|||||||
"固定期间宽度与横向位置跟随占位容器,窗口缩放或父容器变宽都自动贴合",
|
"固定期间宽度与横向位置跟随占位容器,窗口缩放或父容器变宽都自动贴合",
|
||||||
"内滚容器场景把该元素传给 target,判定改为基于它的滚动位置"
|
"内滚容器场景把该元素传给 target,判定改为基于它的滚动位置"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "multiSelect",
|
||||||
|
"title": "多选下拉 MultiSelect",
|
||||||
|
"demo": "sec-multiselect",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"n": "options",
|
||||||
|
"t": "CheckOption[]",
|
||||||
|
"r": 1,
|
||||||
|
"d": "选项集合:value / label / 可选 disabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "value",
|
||||||
|
"t": "Signal<string[]>",
|
||||||
|
"d": "选中值数组信号;不传则内部维护"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "placeholder",
|
||||||
|
"t": "string",
|
||||||
|
"d": "未选时占位文案;已选时让位给标签"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "max",
|
||||||
|
"t": "number",
|
||||||
|
"d": "最多选几项:到上限后未选项自动禁用"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "searchable",
|
||||||
|
"t": "boolean",
|
||||||
|
"d": "是否可输入过滤;缺省 true"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "disabled",
|
||||||
|
"t": "boolean | Signal<boolean>",
|
||||||
|
"d": "禁用整个下拉"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": "onChange",
|
||||||
|
"t": "(values: string[]) => void",
|
||||||
|
"d": "变更回调(传副本)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example": "multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [{ value: 'ts', label: 'TypeScript' }, { value: 'css', label: 'CSS 令牌' }] })",
|
||||||
|
"steps": [
|
||||||
|
"选中项以标签形式回显,点标签上的 × 或空输入时按退格即可删除末个标签",
|
||||||
|
"点输入区展开弹层并聚焦输入框,输入字符即时过滤(大小写不敏感)",
|
||||||
|
"点选项只切换该项、弹层不收起,适合连续多选;到 max 后未选项置灰不可点",
|
||||||
|
"↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)",
|
||||||
|
"value 传数组信号即受控,外部写入会同步标签区"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3257,3 +3257,137 @@ button.ua-step-dot:hover {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
.ua-multiselect {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
.ua-multiselect-box {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ua-space-1);
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px var(--ua-space-1);
|
||||||
|
background: var(--ua-color-surface);
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-sm);
|
||||||
|
cursor: text;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.ua-multiselect.ua-open .ua-multiselect-box {
|
||||||
|
border-color: var(--ua-color-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
.ua-multiselect-tags {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.ua-multiselect-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||||
|
border-radius: var(--ua-radius-sm);
|
||||||
|
}
|
||||||
|
.ua-multiselect-tag > span {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ua-multiselect-x {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ua-multiselect-x:hover {
|
||||||
|
color: var(--ua-color-danger);
|
||||||
|
}
|
||||||
|
.ua-multiselect-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 70px;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.ua-multiselect-pop {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 40;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: none;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--ua-color-border);
|
||||||
|
border-radius: var(--ua-radius-md);
|
||||||
|
background: var(--ua-color-surface);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ua-multiselect-pop.ua-open {
|
||||||
|
display: block;
|
||||||
|
animation: ua-ms-in 0.15s ease both;
|
||||||
|
}
|
||||||
|
.ua-multiselect-pop.is-flip {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ua-multiselect-list {
|
||||||
|
max-height: 220px;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--ua-space-1);
|
||||||
|
overflow: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.ua-multiselect-opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ua-space-1);
|
||||||
|
padding: 6px var(--ua-space-1);
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-text);
|
||||||
|
border-radius: var(--ua-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.ua-multiselect-opt.is-active,
|
||||||
|
.ua-multiselect-opt:hover {
|
||||||
|
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
.ua-multiselect-opt.is-selected {
|
||||||
|
color: var(--ua-color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ua-multiselect-opt.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ua-multiselect-check {
|
||||||
|
width: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ua-color-accent);
|
||||||
|
}
|
||||||
|
.ua-multiselect-empty {
|
||||||
|
padding: var(--ua-space-2) 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--ua-font-size-sm);
|
||||||
|
color: var(--ua-color-text-muted);
|
||||||
|
}
|
||||||
|
@keyframes ua-ms-in {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ua-multiselect-pop.ua-open { animation: none; }
|
||||||
|
.ua-multiselect-box,
|
||||||
|
.ua-multiselect-opt { transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user