diff --git a/.ua/index.json b/.ua/index.json index 0fddc07..4ad322b 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T08:10:50.397Z", + "generatedAt": "2026-10-26T08:12:34.257Z", "repoRoot": ".", "packages": [ { @@ -703,7 +703,7 @@ "n": "options", "t": "OptionItem[]", "r": 1, - "d": "选项列表,每项 { value, label }" + "d": "选项列表,每项 { value, label };每项可加 group 指定分组小标题" }, { "n": "value", @@ -745,7 +745,8 @@ "选中项在弹层中用淡色弱化标记,不抢视觉焦点", "弹层展开时按 Esc 只收起下拉并回焦触发器;若下拉在弹窗/抽屉内,再按一次 Esc 才关外层(逐层关闭)", "searchable: true 时打开即聚焦搜索框,输入实时过滤;↑↓/Home/End 只在可见项间移动,无命中显示「无匹配选项」", - "需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价" + "需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价", + "候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项" ] }, { @@ -3014,7 +3015,7 @@ "n": "options", "t": "CheckOption[]", "r": 1, - "d": "选项集合:value / label / 可选 disabled" + "d": "选项集合:value / label / 可选 disabled;每项可加 group 指定分组小标题" }, { "n": "value", @@ -3059,7 +3060,8 @@ "点选项只切换该项、弹层不收起,适合连续多选;到 max 后未选项置灰不可点", "↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)", "value 传数组信号即受控,外部写入会同步标签区", - "选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算" + "选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算", + "候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index b10c22e..306f1c5 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -162,6 +162,7 @@ const sheetOpen = signal(false); // 底部抽屉开合 const stickyOpen = signal(false); // 防误关弹窗开合 const sound = signal(true); // 无可见文案的开关演示 const city = signal(''); +const stackPick = signal(''); // 分组下拉的选中值 const size = signal('m'); const bio = signal(''); @@ -323,6 +324,18 @@ const content = h( h('div', { class: 'row' }, switchButton({ label: '夜间模式', checked: nightMode }), h('span', { class: 'out' }, () => (nightMode() ? '开' : '关'))), h('div', { class: 'row' }, switchButton({ checked: sound, ariaLabel: '通知声音' }), h('span', { class: 'out' }, () => `无可见文案的开关(ariaLabel 兜底,读屏念「通知声音」):${sound() ? '开' : '关'}`)), h('div', { class: 'row' }, select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '选择城市…', clearable: true }), h('span', { class: 'out' }, () => (city() ? `城市:${city()}(点 × 或按 Delete 清空)` : '未选择'))), + h('div', { class: 'row' }, select({ // 分组选项:相邻同组合成一段,过滤后空组标题自动隐藏 + options: [ + { value: 'vue', label: 'Vue 3', group: '前端框架' }, + { value: 'react', label: 'React 19', group: '前端框架' }, + { value: 'svelte', label: 'Svelte 5', group: '前端框架' }, + { value: 'node', label: 'Node.js 22', group: '服务端运行时' }, + { value: 'deno', label: 'Deno 2', group: '服务端运行时' }, + { value: 'bun', label: 'Bun 1.2', group: '服务端运行时' }, + ], + value: stackPick, + placeholder: '按分类选技术栈…', + }), h('span', { class: 'out' }, () => (stackPick() ? `技术栈:${stackPick()}` : '分组下拉:试试搜索「d」'))), h('div', { class: 'row' }, radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }, { value: 'l', label: '大' }], value: size })), textarea({ placeholder: '一句话介绍…', value: bio, rows: 2, maxCount: 30 }), h('span', { class: 'out' }, () => `简介 ${bio().length} 字`), diff --git a/llms-full.txt b/llms-full.txt index a3f5836..aa2f53a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T08:10:50.397Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T08:12:34.257Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -275,7 +275,7 @@ const night = signal(true); switchButton({ label: '夜间模式', checked: night 参数: -- options(必填):OptionItem[] —— 选项列表,每项 { value, label } +- options(必填):OptionItem[] —— 选项列表,每项 { value, label };每项可加 group 指定分组小标题 - value:Signal —— 选中值信号,双向绑定 - placeholder:string —— 未选中时触发器显示的占位文案 - searchable:boolean —— 弹层顶部带搜索框,按标签包含匹配过滤;缺省 false @@ -298,6 +298,7 @@ const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], v 5. 弹层展开时按 Esc 只收起下拉并回焦触发器;若下拉在弹窗/抽屉内,再按一次 Esc 才关外层(逐层关闭) 6. searchable: true 时打开即聚焦搜索框,输入实时过滤;↑↓/Home/End 只在可见项间移动,无命中显示「无匹配选项」 7. 需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价 +8. 候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项 现场演示:apps/docs/demo.html#sec-form @@ -1597,7 +1598,7 @@ affix({ offset: 8 }, card({ title: '滚动时我会贴顶' }, h('p', null, '正 参数: -- options(必填):CheckOption[] —— 选项集合:value / label / 可选 disabled +- options(必填):CheckOption[] —— 选项集合:value / label / 可选 disabled;每项可加 group 指定分组小标题 - value:Signal —— 选中值数组信号;不传则内部维护 - placeholder:string —— 未选时占位文案;已选时让位给标签 - max:number —— 最多选几项:到上限后未选项自动禁用 @@ -1620,6 +1621,7 @@ multiSelect({ value: tagPick, max: 3, placeholder: '选择技术栈', options: [ 4. ↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈) 5. value 传数组信号即受控,外部写入会同步标签区 6. 选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算 +7. 候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序 现场演示:apps/docs/demo.html#sec-multiselect diff --git a/packages/skin/ui/src/multiSelect.ts b/packages/skin/ui/src/multiSelect.ts index 68366ff..6d45456 100644 --- a/packages/skin/ui/src/multiSelect.ts +++ b/packages/skin/ui/src/multiSelect.ts @@ -73,10 +73,14 @@ export function multiSelect(props: MultiSelectProps): HTMLElement { } tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容) input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案 - const rows = visible().map((o, i) => { + const rows: HTMLElement[] = []; // 候选行:分组小标题与选项混排(小标题不可聚焦,不占 active 下标) + let prevGroup = ''; // 上一个候选的分组名,只在组首插一次标题 + visible().forEach((o, i) => { + if (o.group && o.group !== prevGroup) rows.push(h('li', { class: 'ua-multiselect-group', role: 'presentation' }, o.group)); + prevGroup = o.group ?? ''; // 无分组的候选打断连续段,后续同名组会重新起标题 const on = cur.includes(o.value); const dead = o.disabled === true || (full && !on); // 到上限:未选项不可再点 - return h( + rows.push(h( 'li', { class: () => `ua-multiselect-opt${on ? ' is-selected' : ''}${active() === i ? ' is-active' : ''}${dead ? ' is-disabled' : ''}`, @@ -86,7 +90,7 @@ export function multiSelect(props: MultiSelectProps): HTMLElement { }, 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); diff --git a/packages/skin/ui/src/select.ts b/packages/skin/ui/src/select.ts index 9e4f5e7..d504bd3 100644 --- a/packages/skin/ui/src/select.ts +++ b/packages/skin/ui/src/select.ts @@ -60,6 +60,19 @@ export function select(props: SelectProps): HTMLElement { opt.label, ), ); + // —— 分组:相邻同组选项合成一段,组内首项前插小标题;记下区间供过滤时整组显隐 —— + const nodes: HTMLElement[] = []; // 弹层内容序列(小标题与选项混排) + const runs: { el: HTMLElement; from: number; to: number }[] = []; // 每段标题及其首尾选项下标 + props.options.forEach((opt, i) => { + let run = runs[runs.length - 1] ?? null; + if (opt.group && (!run || run.to !== i - 1)) { // 新起一段:标题插在本组首项之前 + const el = h('div', { class: 'ua-option-group', role: 'presentation' }, opt.group); + run = { el, from: i, to: i }; + runs.push(run); + nodes.push(el); + } else if (opt.group && run) run.to = i; // 同组续接:只拉长区间 + nodes.push(options[i]); + }); const search = h('input', { type: 'search', class: 'ua-select-search', @@ -75,7 +88,7 @@ export function select(props: SelectProps): HTMLElement { 'div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'listbox' }, props.searchable ? search : null, - ...options, + ...nodes, blank, ); @@ -129,6 +142,11 @@ export function select(props: SelectProps): HTMLElement { el.hidden = !on; // 不匹配即折叠,键盘只在可见项之间移动 if (on) shown += 1; }); + for (const run of runs) { // 整组都无命中时连小标题一起藏,不留孤零零的分组名 + let any = false; + for (let i = run.from; i <= run.to; i++) if (match(i)) { any = true; break; } + run.el.hidden = !any; + } blank.hidden = shown > 0 || !q; // 仅「有关键字且无命中」时才提示 }); effect(() => { diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 41aae99..8bf25ee 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -75,6 +75,7 @@ export interface OptionItem { value: string; // 选项值(提交/匹配用) label: string; // 选项文案(展示用) disabled?: boolean; // tabs / segmented 消费:禁用该选项 + group?: string; // select 消费:分组小标题,相邻同组选项合成一段(组内首项前插标题) } /*** 分段控制器入参 ***/ @@ -687,6 +688,7 @@ export interface CheckOption { value: string; // 选项值 label: string; // 显示文案 disabled?: boolean; // 单项禁用:不可勾选,也不计入全选 + group?: string; // 分组小标题:相邻同组选项合成一段(组内首项前插标题),过滤后整组无命中则标题一并隐藏 } /*** 多选组入参 ***/ diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 1497524..8fa1f1b 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -574,7 +574,7 @@ "n": "options", "t": "OptionItem[]", "r": 1, - "d": "选项列表,每项 { value, label }" + "d": "选项列表,每项 { value, label };每项可加 group 指定分组小标题" }, { "n": "value", @@ -616,7 +616,8 @@ "选中项在弹层中用淡色弱化标记,不抢视觉焦点", "弹层展开时按 Esc 只收起下拉并回焦触发器;若下拉在弹窗/抽屉内,再按一次 Esc 才关外层(逐层关闭)", "searchable: true 时打开即聚焦搜索框,输入实时过滤;↑↓/Home/End 只在可见项间移动,无命中显示「无匹配选项」", - "需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价" + "需要允许「不选」的筛选项就开 clearable:有值时右缘出现 ×,点它把值写成空串并回调 onChange(''),键盘在收起态按 Delete / Backspace 等价", + "候选多时给选项加 group:相邻同组合成一段、组首插灰色小标题(段间有分隔线),过滤后整组无命中时标题一起隐藏——搜索路径与键盘 ↑↓ 仍只走真实选项" ] }, { @@ -2885,7 +2886,7 @@ "n": "options", "t": "CheckOption[]", "r": 1, - "d": "选项集合:value / label / 可选 disabled" + "d": "选项集合:value / label / 可选 disabled;每项可加 group 指定分组小标题" }, { "n": "value", @@ -2930,7 +2931,8 @@ "点选项只切换该项、弹层不收起,适合连续多选;到 max 后未选项置灰不可点", "↑↓ 环形移动高亮、Enter 切换当前项、Esc 收起(与其它弹层共用一个 Esc 栈)", "value 传数组信号即受控,外部写入会同步标签区", - "选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算" + "选中项多时传 maxTagCount: 2 折叠:前 2 枚标签照常逐个删除,其余折成虚线「+N」,悬停 title 给出被折叠的项名,点它直接展开弹层;受控数组一变折叠数就重算", + "候选分组同样用 group 字段:小标题是不可点击的 li(不占键盘高亮下标),选中后的标签顺序仍按点击顺序而非分组顺序" ] }, { diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index d36dc18..4cf46a0 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -407,6 +407,20 @@ body.ua-col-resizing { color: var(--ua-color-text-muted); text-align: center; } +.ua-option-group { + padding: 6px 10px 2px; + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-sm); + font-weight: 600; + letter-spacing: 0.02em; + cursor: default; + user-select: none; +} +.ua-option-group:not(:first-child) { + margin-top: 4px; + border-top: 1px solid var(--ua-color-border); + padding-top: 8px; +} .ua-option { display: block; width: 100%; @@ -3767,6 +3781,14 @@ button.ua-step-dot:hover { overflow: auto; list-style: none; } +.ua-multiselect-group { + padding: 6px var(--ua-space-1) 2px; + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-sm); + font-weight: 600; + cursor: default; + user-select: none; +} .ua-multiselect-opt { display: flex; align-items: center;