diff --git a/.ua/index.json b/.ua/index.json index 9451dd4..6a39aa7 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T04:23:09.567Z", + "generatedAt": "2026-10-26T04:24:59.738Z", "repoRoot": ".", "packages": [ { @@ -198,7 +198,8 @@ "tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose", "watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互", "checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项", - "affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应" + "affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应", + "multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用" ], "keyTypes": [ "ButtonProps", @@ -2549,6 +2550,57 @@ "固定期间宽度与横向位置跟随占位容器,窗口缩放或父容器变宽都自动贴合", "内滚容器场景把该元素传给 target,判定改为基于它的滚动位置" ] + }, + { + "id": "multiSelect", + "title": "多选下拉 MultiSelect", + "demo": "sec-multiselect", + "props": [ + { + "n": "options", + "t": "CheckOption[]", + "r": 1, + "d": "选项集合:value / label / 可选 disabled" + }, + { + "n": "value", + "t": "Signal", + "d": "选中值数组信号;不传则内部维护" + }, + { + "n": "placeholder", + "t": "string", + "d": "未选时占位文案;已选时让位给标签" + }, + { + "n": "max", + "t": "number", + "d": "最多选几项:到上限后未选项自动禁用" + }, + { + "n": "searchable", + "t": "boolean", + "d": "是否可输入过滤;缺省 true" + }, + { + "n": "disabled", + "t": "boolean | Signal", + "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 传数组信号即受控,外部写入会同步标签区" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index f709ff0..32ce49d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核 - `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/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private) - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6a7501c..2bb95c2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 55 个组件 + ui.css +│ │ └── ui/ # 56 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index b06bdda..1393bb2 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ npm run demo # 打开演示:apps/demo/dist/demo.html npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt ``` -## 组件(共 55 个) +## 组件(共 56 个) -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 -分组:表单(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/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/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server | | `apps/demo` | 交互演示(esbuild 打包成单文件) | diff --git a/apps/demo/main.ts b/apps/demo/main.ts index f01d9e4..ac409ef 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -42,6 +42,7 @@ import { watermark, menu, modal, + multiSelect, pagination, popover, progress, @@ -194,6 +195,7 @@ const listPick = signal(''); // 列表点击选中的条目标题 const slideIndex = signal(0); // 轮播当前下标(外部受控演示) const cascaderPath = signal(['east', 'sh']); // 级联选择路径(外部受控演示) const splitSizes = signal([35, 65]); // 分栏比例(外部受控演示) +const tagPick = signal(['ts']); // 多选下拉选中值(外部受控演示) const permPick = signal(['read']); // 多选组选中值(外部受控演示) const colorVal = signal('var(--ua-color-accent)'); // 颜色选择值(外部受控演示) const dateVal = signal('2026-10-26'); // 日期选择值(外部受控演示) @@ -698,6 +700,29 @@ const content = h( 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( 'sec-checkgroup', '多选组', diff --git a/llms-full.txt b/llms-full.txt index b71c302..8111a17 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @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)。 ## 快速上手 @@ -1465,6 +1465,34 @@ affix({ offset: 8 }, card({ title: '滚动时我会贴顶' }, h('p', null, '正 现场演示:apps/docs/demo.html#sec-affix +### multiSelect — 多选下拉 MultiSelect + +参数: + +- options(必填):CheckOption[] —— 选项集合:value / label / 可选 disabled +- value:Signal —— 选中值数组信号;不传则内部维护 +- placeholder:string —— 未选时占位文案;已选时让位给标签 +- max:number —— 最多选几项:到上限后未选项自动禁用 +- searchable:boolean —— 是否可输入过滤;缺省 true +- disabled:boolean | Signal —— 禁用整个下拉 +- 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 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交) @@ -1524,6 +1552,7 @@ API 速览: - watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互 - checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项 - affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应 +- multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用 一行用法: diff --git a/llms.txt b/llms.txt index 9c5dd1c..d3eef5c 100644 --- a/llms.txt +++ b/llms.txt @@ -104,6 +104,7 @@ - watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互 - checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项 - affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应 + - multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用 - 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')` - 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号 - 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调 diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index 382d6eb..b74ae9a 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -8,6 +8,7 @@ export { checkbox } from './checkbox.js'; export { checkboxGroup } from './checkboxGroup.js'; export { switchButton } from './switch.js'; export { select } from './select.js'; +export { multiSelect } from './multiSelect.js'; export { menu } from './menu.js'; export { radioGroup } from './radioGroup.js'; export { accordion } from './accordion.js'; diff --git a/packages/skin/ui/src/multiSelect.ts b/packages/skin/ui/src/multiSelect.ts new file mode 100644 index 0000000..52aa9af --- /dev/null +++ b/packages/skin/ui/src/multiSelect.ts @@ -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 = props.value ?? signal([]); // 选中值 + 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; +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 2823348..56f04dc 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -626,4 +626,15 @@ export interface AffixProps { target?: HTMLElement | Window; // 监听的滚动容器;缺省 window } +/*** 多选下拉入参 ***/ +export interface MultiSelectProps { + options: CheckOption[]; // 选项集合(复用 CheckOption:value / label / disabled) + value?: Signal; // 选中值数组信号;不传则内部维护 + placeholder?: string; // 未选时占位文案;缺省「请选择」 + max?: number; // 最多选几项:到上限后未选项禁用 + searchable?: boolean; // 是否可输入过滤;缺省 true + disabled?: boolean | Signal; // 禁用整个下拉 + onChange?: (values: string[]) => void; // 变更回调(传副本) +} + export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index c4a7f15..4404bee 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -69,7 +69,8 @@ "tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose", "watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互", "checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项", - "affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应" + "affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应", + "multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用" ], "keyTypes": [ "ButtonProps", @@ -2420,6 +2421,57 @@ "固定期间宽度与横向位置跟随占位容器,窗口缩放或父容器变宽都自动贴合", "内滚容器场景把该元素传给 target,判定改为基于它的滚动位置" ] + }, + { + "id": "multiSelect", + "title": "多选下拉 MultiSelect", + "demo": "sec-multiselect", + "props": [ + { + "n": "options", + "t": "CheckOption[]", + "r": 1, + "d": "选项集合:value / label / 可选 disabled" + }, + { + "n": "value", + "t": "Signal", + "d": "选中值数组信号;不传则内部维护" + }, + { + "n": "placeholder", + "t": "string", + "d": "未选时占位文案;已选时让位给标签" + }, + { + "n": "max", + "t": "number", + "d": "最多选几项:到上限后未选项自动禁用" + }, + { + "n": "searchable", + "t": "boolean", + "d": "是否可输入过滤;缺省 true" + }, + { + "n": "disabled", + "t": "boolean | Signal", + "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 传数组信号即受控,外部写入会同步标签区" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 9d2a6a0..dfcaf83 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -3257,3 +3257,137 @@ button.ua-step-dot:hover { position: fixed; 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; } +}