iter(g188): 新增命令面板组件
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<boolean>; // 显隐信号:外部写 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; // 关闭:归还打开前焦点
|
||||
}
|
||||
|
||||
@@ -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<boolean>",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user