diff --git a/.ua/index.json b/.ua/index.json index 199db9d..f135feb 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T09:47:35.873Z", + "generatedAt": "2026-10-26T09:49:59.016Z", "repoRoot": ".", "packages": [ { @@ -211,7 +211,8 @@ "treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起", "link(props):链接,站内跳转与站外跳转统一入口,external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报", "avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名", - "virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄" + "virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄", + "commandPalette(props):命令面板,⌘K / Ctrl+K 唤起,输入即过滤(主文案/说明/关键词三层),↑↓ 环形选择 + 回车执行 + Esc 收起,选中与关闭都归还焦点;返回 { el, open, close } 句柄" ], "keyTypes": [ "ButtonProps", @@ -3703,6 +3704,56 @@ "数据整体变化(换数据源)请销毁重建实例:items 是创建时快照,不响应外部数组突变", "滚动事件内部已 rAF 合帧,滚动回调无需节流" ] + }, + { + "id": "commandPalette", + "title": "命令面板 CommandPalette", + "demo": "sec-cmdk", + "props": [ + { + "n": "open", + "t": "Signal", + "r": 1, + "d": "显隐信号:外部写 true 唤起,组件内部关闭也会写回" + }, + { + "n": "items", + "t": "CommandPaletteItem[]", + "r": 1, + "d": "全量命令,创建时快照;含 id / label / group / hint / keywords" + }, + { + "n": "placeholder", + "t": "string", + "d": "搜索框占位文案;缺省「搜索命令…」" + }, + { + "n": "emptyText", + "t": "string", + "d": "无匹配时的空状态文案;缺省「没有匹配的命令」" + }, + { + "n": "hotkey", + "t": "boolean", + "d": "是否注册 ⌘K / Ctrl+K 全局唤起;缺省 true,多实例场景可关掉自己绑", + "df": "true" + }, + { + "n": "onSelect", + "t": "(item) => void", + "r": 1, + "d": "回车或点击选中后回调:先收起并归还焦点,再回调" + } + ], + "example": "const cmdk = commandPalette({ open: signal(false), items: [{ id: 'save', label: '保存当前页', group: '文件', hint: '⌘S' }, { id: 'invite', label: '邀请成员', group: '成员' }], onSelect: (it) => toast.info(`执行 ${it.label}`) }); document.body.append(cmdk.el); cmdk.open();", + "steps": [ + "把返回的 el 挂到 body(面板是 fixed + 遮罩,挂在 section 里也定位正确),之后用 open 信号或句柄 open()/close() 控制显隐", + "items 是创建时快照:命令表变化(权限变化后菜单增减)请重建实例,不要直接改数组", + "过滤看 label / hint / keywords 三层:中文别名与拼音首字母塞进 keywords 即可支持,不必改组件", + "同组连续排列才合并成一段并画组标题;分组顺序按 items 顺序,组件不排序", + "键盘:↑↓ 环形选择、Home/End 跳首尾、回车执行、Esc 交给弹层栈(嵌套弹窗时只关最上层);鼠标划过即高亮", + "hotkey 缺省注册 ⌘K / Ctrl+K,同一页面多个面板请只留一个开 hotkey,其余传 hotkey: false" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index 3d9a340..68a4249 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):68 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):69 个 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 6aa7023..5506b39 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 68 个组件 + ui.css +│ │ └── ui/ # 69 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 4dc3757..f7402e3 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全 npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移) ``` -## 组件(共 68 个) +## 组件(共 69 个) -accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · floatButton · form · grid · image · inputNumber · link · list · menu · modal · mentions · multiSelect · notification · pagination · popover · popconfirm · progress · radioGroup · rate · result · segmented · select · skeleton · slider · space · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark +accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · commandPalette · dataTable · datePicker · descriptions · divider · drawer · empty · floatButton · form · grid · image · inputNumber · link · list · menu · modal · mentions · multiSelect · notification · pagination · popover · popconfirm · progress · radioGroup · rate · result · segmented · select · skeleton · slider · space · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark -分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / popconfirm / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / link / space / divider / splitter / backTop / affix / tour / watermark)。 +分组:表单(button / textField / inputNumber / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / popconfirm / modal / drawer / menu / commandPalette)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / link / space / divider / splitter / backTop / affix / tour / watermark)。 ## 可访问性约定 @@ -32,7 +32,7 @@ accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup |---|---| | `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:68 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:69 个 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 2d76129..a81c567 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -25,6 +25,7 @@ import { checkbox, checkboxGroup, colorPicker, + commandPalette, dataTable, datePicker, transfer, @@ -1530,6 +1531,35 @@ const content = h( ), ), ), + section( + 'sec-cmdk', + '命令面板', + 'commandPalette 是 ⌘K / Ctrl+K 唤起的高频入口:输入即过滤(主文案 / 说明 / 关键词三层),↑↓ 环形选择、回车执行、Esc 收起,选中或关闭都归还焦点;组标题按 items 顺序合并,组件不替你排序。', + grid({ min: '320px' }, + card({ title: '⌘K 命令面板' }, + (() => { + const open = signal(false); // 显隐信号:按钮与快捷键共用同一份状态 + const cmdk = commandPalette({ + open, + items: [ + { id: 'save', label: '保存当前页面', group: '文件', hint: '⌘S' }, + { id: 'export', label: '导出为 Markdown', group: '文件', hint: '⌘E' }, + { id: 'invite', label: '邀请成员', group: '协作', hint: '⌘I', keywords: ['yaoqing'] }, + { id: 'role', label: '调整成员权限', group: '协作' }, + { id: 'theme', label: '切换暗色主题', group: '偏好', keywords: ['dark', 'anse'] }, + { id: 'lang', label: '切换界面语言', group: '偏好', hint: '⌘L' }, + ], + onSelect: (it) => toast.info(`执行:${it.label}`), + }); + return h('div', null, button({ label: '打开命令面板(⌘/Ctrl + K)', onClick: () => open(true) }), cmdk.el); + })(), + typography({ variant: 'paragraph', type: 'secondary', content: '试试输入「协」或拼音 yaoqing / dark:keywords 让中文别名与拼音也能命中,不用改组件。' }), + ), + card({ title: '为什么不用原生 input + datalist' }, + typography({ variant: 'paragraph', type: 'secondary', content: 'datalist 无法分组、不搜说明文字、也控制不了高亮与焦点归还;命令面板要的是「输入即缩小 + 键盘走完整个流程」,所以自绘列表并用 aria-activedescendant 把高亮交给读屏。' }), + ), + ), + ), ); // 悬浮按钮:fixed 定位,挂 body 不参与页面布局 diff --git a/llms-full.txt b/llms-full.txt index 7e66448..7a91bb5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T09:47:35.873Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T09:49:59.016Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1952,6 +1952,34 @@ const vlist = virtualList({ items: users, itemHeight: 44, height: 320, renderIte 现场演示:apps/docs/demo.html#sec-vlist +### commandPalette — 命令面板 CommandPalette + +参数: + +- open(必填):Signal —— 显隐信号:外部写 true 唤起,组件内部关闭也会写回 +- items(必填):CommandPaletteItem[] —— 全量命令,创建时快照;含 id / label / group / hint / keywords +- placeholder:string —— 搜索框占位文案;缺省「搜索命令…」 +- emptyText:string —— 无匹配时的空状态文案;缺省「没有匹配的命令」 +- hotkey(默认 true):boolean —— 是否注册 ⌘K / Ctrl+K 全局唤起;缺省 true,多实例场景可关掉自己绑 +- onSelect(必填):(item) => void —— 回车或点击选中后回调:先收起并归还焦点,再回调 + +示例: + +``` +const cmdk = commandPalette({ open: signal(false), items: [{ id: 'save', label: '保存当前页', group: '文件', hint: '⌘S' }, { id: 'invite', label: '邀请成员', group: '成员' }], onSelect: (it) => toast.info(`执行 ${it.label}`) }); document.body.append(cmdk.el); cmdk.open(); +``` + +手把手: + +1. 把返回的 el 挂到 body(面板是 fixed + 遮罩,挂在 section 里也定位正确),之后用 open 信号或句柄 open()/close() 控制显隐 +2. items 是创建时快照:命令表变化(权限变化后菜单增减)请重建实例,不要直接改数组 +3. 过滤看 label / hint / keywords 三层:中文别名与拼音首字母塞进 keywords 即可支持,不必改组件 +4. 同组连续排列才合并成一段并画组标题;分组顺序按 items 顺序,组件不排序 +5. 键盘:↑↓ 环形选择、Home/End 跳首尾、回车执行、Esc 交给弹层栈(嵌套弹窗时只关最上层);鼠标划过即高亮 +6. hotkey 缺省注册 ⌘K / Ctrl+K,同一页面多个面板请只留一个开 hotkey,其余传 hotkey: false + +现场演示:apps/docs/demo.html#sec-cmdk + API 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交) @@ -2024,6 +2052,7 @@ API 速览: - link(props):链接,站内跳转与站外跳转统一入口,external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报 - avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名 - virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄 +- commandPalette(props):命令面板,⌘K / Ctrl+K 唤起,输入即过滤(主文案/说明/关键词三层),↑↓ 环形选择 + 回车执行 + Esc 收起,选中与关闭都归还焦点;返回 { el, open, close } 句柄 一行用法: diff --git a/llms.txt b/llms.txt index 408a05c..8e07b93 100644 --- a/llms.txt +++ b/llms.txt @@ -117,6 +117,7 @@ - link(props):链接,站内跳转与站外跳转统一入口,external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报 - avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名 - virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄 + - commandPalette(props):命令面板,⌘K / Ctrl+K 唤起,输入即过滤(主文案/说明/关键词三层),↑↓ 环形选择 + 回车执行 + Esc 收起,选中与关闭都归还焦点;返回 { el, open, close } 句柄 - 用法: `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/commandPalette.ts b/packages/skin/ui/src/commandPalette.ts new file mode 100644 index 0000000..125c361 --- /dev/null +++ b/packages/skin/ui/src/commandPalette.ts @@ -0,0 +1,152 @@ +import { effect, h, signal } from '@ui-arch/frame'; +import type { CommandPaletteHandle, CommandPaletteItem, CommandPaletteProps } from './types.js'; +import { lockScroll, pushOverlay } from './overlay.js'; + +let seq = 0; // 实例序号:选项与列表的 id 靠它唯一 + +/*** 作用:条目是否命中搜索词——主文案 / 辅助说明 / 额外关键词任一包含即命中(大小写不敏感) ***/ +function matches(item: CommandPaletteItem, q: string): boolean { + if (!q) return true; // 空词不过滤 + const hay = [item.label, item.hint ?? '', ...(item.keywords ?? [])].join(' ').toLowerCase(); + return hay.includes(q); +} + +/*** 命令面板:⌘K 唤起,输入即过滤,↑↓ 选择、回车执行、Esc 收起;返回 { el, open, close } 句柄 ***/ +export function commandPalette(props: CommandPaletteProps): CommandPaletteHandle { + // —— 状态集中声明 —— + const uid = `ua-cmdk-${++seq}`; // 实例 id 前缀 + const query = signal(''); // 搜索词:输入事件写入,供过滤 + const active = signal(0); // 当前高亮项在「过滤后序列」中的下标 + let visible: CommandPaletteItem[] = []; // 过滤后的可见条目 + let nodes: HTMLElement[] = []; // 与 visible 同序的节点,供高亮就地切换(不整树重绘) + let lastFocus: HTMLElement | null = null; // 打开前焦点,关闭归还 + let release: (() => void) | null = null; // Esc 栈注销函数 + let releaseLock: (() => void) | null = null; // 滚动锁解锁函数 + + // —— DOM 骨架:遮罩 → 面板 → 输入框 + 列表 —— + const input = h('input', { + class: 'ua-cmdk-input', + type: 'text', + placeholder: props.placeholder ?? '搜索命令…', + 'aria-controls': `${uid}-list`, + role: 'combobox', + 'aria-expanded': 'true', + }) as HTMLInputElement; + const list = h('div', { class: 'ua-cmdk-list', id: `${uid}-list`, role: 'listbox', 'aria-label': '命令列表' }); + const panel = h('div', { class: 'ua-cmdk-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '命令面板' }, input, list); + const root = h('div', { class: 'ua-cmdk', onClick: (e: Event) => { if (e.target === root) close(); } }, panel); + + /*** 选中即执行:先收起(归还焦点)再回调,避免回调里再开弹层时焦点打转 ***/ + const run = (item: CommandPaletteItem): void => { + close(); + props.onSelect(item); + }; + + /*** 生成一行的节点:label 左、hint 右,点击与悬停都能选中 ***/ + const row = (item: CommandPaletteItem, idx: number): HTMLElement => + h('div', { + class: 'ua-cmdk-item', + id: `${uid}-opt-${idx}`, + role: 'option', + 'aria-selected': 'false', + onClick: () => run(item), + onPointermove: () => setActive(idx), // 鼠标划过即高亮,点之前就知道会选中谁 + }, h('span', { class: 'ua-cmdk-label' }, item.label), item.hint ? h('span', { class: 'ua-cmdk-hint' }, item.hint) : null); + + /*** 高亮第 idx 项(环形):只切 class + scrollIntoView,不重建列表 ***/ + function setActive(idx: number): void { + if (!visible.length) return; // 卫语句:空列表无需高亮 + const next = (idx + visible.length) % visible.length; // 环形:首尾相接 + active(next); + nodes.forEach((el, i) => { + const on = i === next; + el.classList.toggle('is-active', on); + el.setAttribute('aria-selected', String(on)); + }); + nodes[next].scrollIntoView({ block: 'nearest' }); // 窄容器内跟随滚动 + input.setAttribute('aria-activedescendant', `${uid}-opt-${next}`); + } + + /*** 按当前搜索词重绘列表:同组连续,组间插标题;无命中给空状态 ***/ + function paint(): void { + const q = query().trim().toLowerCase(); + visible = props.items.filter((it) => matches(it, q)); + nodes = []; + const parts: HTMLElement[] = []; + let group = ''; // 上一个已画过的组名,用于判定是否该插标题 + visible.forEach((item, idx) => { + if (item.group && item.group !== group) { + group = item.group; + parts.push(h('div', { class: 'ua-cmdk-group' }, group)); + } + const el = row(item, idx); + nodes.push(el); + parts.push(el); + }); + list.replaceChildren(...(parts.length ? parts : [h('div', { class: 'ua-cmdk-empty' }, props.emptyText ?? '没有匹配的命令')])); + input.removeAttribute('aria-activedescendant'); + if (visible.length) setActive(0); + } + + /*** 打开:清搜索词、重绘、抢焦点(写回信号,由下面的 effect 统一处理副作用) ***/ + function open(): void { + if (props.open()) return; // 卫语句:已打开不重复触发 + props.open(true); + } + + /*** 关闭:写回信号即可,副作用(出栈 / 解锁 / 还焦点)由 effect 收口 ***/ + function close(): void { + if (!props.open()) return; // 卫语句:已关闭不重复触发 + props.open(false); + } + + input.addEventListener('input', () => { + query(input.value); + paint(); + }); + + // —— 键盘:↑↓ 环形选择、Home/End 跳首尾、回车执行(Esc 交给弹层栈,保证只关最上层) —— + panel.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active() + 1); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active() - 1); return; } + if (e.key === 'Home') { e.preventDefault(); setActive(0); return; } + if (e.key === 'End') { e.preventDefault(); setActive(visible.length - 1); return; } + if (e.key === 'Enter') { + e.preventDefault(); + const item = visible[active()]; + if (item) run(item); // 无命中时回车不做事 + } + }); + + // —— 显隐副作用收口:焦点、Esc 栈、背景滚动锁 —— + effect(() => { + const on = props.open(); + root.classList.toggle('ua-open', on); + if (on) { + lastFocus = document.activeElement as HTMLElement | null; + query(''); + input.value = ''; + paint(); + release ??= pushOverlay(close); + releaseLock ??= lockScroll(); + input.focus(); + } else { + release?.(); + release = null; + releaseLock?.(); + releaseLock = null; + lastFocus?.focus(); // 归还焦点:键盘用户不会被困在隐藏层里 + lastFocus = null; + } + }); + + // —— 全局快捷键:⌘K / Ctrl+K 切换;hotkey=false 时不注册(多实例场景由调用方自己绑) —— + if (props.hotkey !== false) { + document.addEventListener('keydown', (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return; // 卫语句:只认 ⌘K / Ctrl+K + e.preventDefault(); + if (props.open()) close(); else open(); // 再按一次收起 + }); + } + return { el: root, open, close }; +} diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index 26af268..17360c1 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -68,3 +68,4 @@ export { treeSelect } from './treeSelect.js'; export { link } from './link.js'; export { avatarGroup } from './avatarGroup.js'; export { virtualList } from './virtualList.js'; +export { commandPalette } from './commandPalette.js'; diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 56f1c49..d27eca0 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -895,3 +895,29 @@ export interface VirtualListHandle { el: HTMLElement; // 列表根节点(本身即滚动容器) scrollTo: (index: number) => void; // 滚动到指定行下标(越界自动钳制) } + +/*** 命令面板条目 ***/ +export interface CommandPaletteItem { + id: string; // 唯一 id:选中回传与 aria-activedescendant 都用它 + label: string; // 主文案:可访问名与过滤依据 + group?: string; // 分组名:相邻同组归一段,组间画标题 + hint?: string; // 右侧辅助说明(快捷键 / 作用域),参与过滤 + keywords?: string[]; // 额外过滤词(拼音 / 别名),不显示 +} + +/*** 命令面板入参 ***/ +export interface CommandPaletteProps { + open: Signal; // 显隐信号:外部写 true 唤起,组件内部关闭也写回 + items: CommandPaletteItem[]; // 全量命令,创建时快照 + placeholder?: string; // 搜索框占位文案;缺省「搜索命令…」 + emptyText?: string; // 无匹配文案;缺省「没有匹配的命令」 + hotkey?: boolean; // 是否注册 ⌘K / Ctrl+K 全局唤起;缺省 true + onSelect: (item: CommandPaletteItem) => void; // 回车或点击选中后回调(先关闭再回调) +} + +/*** 命令面板句柄 ***/ +export interface CommandPaletteHandle { + el: HTMLElement; // 根节点(含遮罩与面板),挂到页面即可 + open: () => void; // 打开:清空搜索词并聚焦输入框 + close: () => void; // 关闭:归还打开前焦点 +} diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 0c1b0db..5e0c965 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -82,7 +82,8 @@ "treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起", "link(props):链接,站内跳转与站外跳转统一入口,external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报", "avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名", - "virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄" + "virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄", + "commandPalette(props):命令面板,⌘K / Ctrl+K 唤起,输入即过滤(主文案/说明/关键词三层),↑↓ 环形选择 + 回车执行 + Esc 收起,选中与关闭都归还焦点;返回 { el, open, close } 句柄" ], "keyTypes": [ "ButtonProps", @@ -3574,6 +3575,56 @@ "数据整体变化(换数据源)请销毁重建实例:items 是创建时快照,不响应外部数组突变", "滚动事件内部已 rAF 合帧,滚动回调无需节流" ] + }, + { + "id": "commandPalette", + "title": "命令面板 CommandPalette", + "demo": "sec-cmdk", + "props": [ + { + "n": "open", + "t": "Signal", + "r": 1, + "d": "显隐信号:外部写 true 唤起,组件内部关闭也会写回" + }, + { + "n": "items", + "t": "CommandPaletteItem[]", + "r": 1, + "d": "全量命令,创建时快照;含 id / label / group / hint / keywords" + }, + { + "n": "placeholder", + "t": "string", + "d": "搜索框占位文案;缺省「搜索命令…」" + }, + { + "n": "emptyText", + "t": "string", + "d": "无匹配时的空状态文案;缺省「没有匹配的命令」" + }, + { + "n": "hotkey", + "t": "boolean", + "d": "是否注册 ⌘K / Ctrl+K 全局唤起;缺省 true,多实例场景可关掉自己绑", + "df": "true" + }, + { + "n": "onSelect", + "t": "(item) => void", + "r": 1, + "d": "回车或点击选中后回调:先收起并归还焦点,再回调" + } + ], + "example": "const cmdk = commandPalette({ open: signal(false), items: [{ id: 'save', label: '保存当前页', group: '文件', hint: '⌘S' }, { id: 'invite', label: '邀请成员', group: '成员' }], onSelect: (it) => toast.info(`执行 ${it.label}`) }); document.body.append(cmdk.el); cmdk.open();", + "steps": [ + "把返回的 el 挂到 body(面板是 fixed + 遮罩,挂在 section 里也定位正确),之后用 open 信号或句柄 open()/close() 控制显隐", + "items 是创建时快照:命令表变化(权限变化后菜单增减)请重建实例,不要直接改数组", + "过滤看 label / hint / keywords 三层:中文别名与拼音首字母塞进 keywords 即可支持,不必改组件", + "同组连续排列才合并成一段并画组标题;分组顺序按 items 顺序,组件不排序", + "键盘:↑↓ 环形选择、Home/End 跳首尾、回车执行、Esc 交给弹层栈(嵌套弹窗时只关最上层);鼠标划过即高亮", + "hotkey 缺省注册 ⌘K / Ctrl+K,同一页面多个面板请只留一个开 hotkey,其余传 hotkey: false" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index a9eeba6..202c1aa 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -4777,3 +4777,97 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } .ua-vlist-item + .ua-vlist-item { border-top: 1px solid color-mix(in srgb, var(--ua-color-border) 60%, transparent); } + +/* —— 命令面板:⌘K 唤起,遮罩 + 居中面板 —— */ +.ua-cmdk { + display: none; + position: fixed; + inset: 0; + z-index: var(--ua-z-modal); + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + background: rgba(0, 0, 0, 0.4); +} +.ua-cmdk.ua-open { + display: flex; + animation: ua-fade var(--ua-duration-base) var(--ua-ease); +} +.ua-cmdk-panel { + display: flex; + flex-direction: column; + width: min(560px, 92vw); + max-height: 62vh; + border: 1px solid var(--ua-color-border); + border-radius: var(--ua-radius-lg); + background: var(--ua-color-surface); + box-shadow: var(--ua-shadow-4); + overflow: hidden; + animation: ua-pop-in var(--ua-duration-base) var(--ua-ease); +} +.ua-cmdk-input { + flex-shrink: 0; + padding: var(--ua-space-3) var(--ua-space-4); + border: none; + border-bottom: 1px solid var(--ua-border-subtle); + background: none; + color: var(--ua-color-text); + font-family: inherit; + font-size: var(--ua-font-size-lg); + outline: none; +} +.ua-cmdk-input::placeholder { + color: var(--ua-color-text-muted); +} +.ua-cmdk-list { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--ua-space-1); +} +.ua-cmdk-group { + padding: var(--ua-space-15) var(--ua-space-25) var(--ua-space-05); + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-xs); + font-weight: var(--ua-weight-semibold); + letter-spacing: 0.04em; + text-transform: uppercase; +} +.ua-cmdk-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ua-space-2); + padding: var(--ua-space-15) var(--ua-space-25); + border-radius: var(--ua-radius-sm); + color: var(--ua-color-text); + font-size: var(--ua-font-size-md); + cursor: pointer; + transition: background var(--ua-duration-fast) var(--ua-ease), color var(--ua-duration-fast) var(--ua-ease); +} +.ua-cmdk-item.is-active { + background: color-mix(in srgb, var(--ua-color-accent) 12%, transparent); /* 选中态低视觉比重:淡色底不抢文字 */ + color: var(--ua-color-accent); +} +.ua-cmdk-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ua-cmdk-hint { + flex-shrink: 0; + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-xs); +} +.ua-cmdk-empty { + padding: var(--ua-space-4) var(--ua-space-25); + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-sm); + text-align: center; +} +@media (prefers-reduced-motion: reduce) { + .ua-cmdk.ua-open, + .ua-cmdk-panel { + animation: none; + } +}