iter: d01 新增下拉菜单 menu 组件(弹层/键盘导航/reduced-motion)

This commit is contained in:
ui-arch-bot
2026-10-23 16:46:00 +08:00
parent f9324ddc6a
commit 84277a744e
11 changed files with 357 additions and 9 deletions
+1
View File
@@ -5,6 +5,7 @@ export { textarea } from './textarea.js';
export { checkbox } from './checkbox.js';
export { switchButton } from './switch.js';
export { select } from './select.js';
export { menu } from './menu.js';
export { radioGroup } from './radioGroup.js';
export { card } from './card.js';
export { badge } from './badge.js';
+82
View File
@@ -0,0 +1,82 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { MenuItem, MenuProps } from './types.js';
/*** 下拉菜单:触发器 + 自绘弹层,项支持禁用 / 危险 / 分隔线;点项 / 点外部 / Esc 关闭,↑↓ 环形导航 ***/
export function menu(props: MenuProps): HTMLElement {
// —— 状态集中声明 ——
const open = signal(false); // 弹层开合
const focusables: HTMLButtonElement[] = []; // 可聚焦项(跳过分隔线与禁用项),供键盘导航
const pick = (item: MenuItem): void => {
open(false); // 选中即收起
props.onSelect?.(item.value);
};
// —— 菜单项:分隔线走独立分支,其余为原生 button 保证键盘可达 ——
const rows = props.items.map((item) => {
if (item.divider) return h('div', { class: 'ua-menu-divider', role: 'separator' });
const btn = h(
'button',
{
type: 'button',
class: `ua-menu-item${item.danger ? ' is-danger' : ''}`,
role: 'menuitem',
disabled: item.disabled ? true : undefined,
onClick: () => pick(item),
},
item.label,
) as HTMLButtonElement;
if (!item.disabled) focusables.push(btn); // 禁用项不参与导航
return btn;
});
const trigger = h(
'button',
{
type: 'button',
class: `ua-btn ua-btn-${props.variant ?? 'ghost'} ua-menu-trigger`,
'aria-haspopup': 'menu',
'aria-expanded': () => String(open()),
onClick: () => open(!open()),
},
props.label,
h('span', { class: 'ua-menu-caret' }, '▾'),
);
const pop = h(
'div',
{
class: () => `ua-menu-pop${open() ? ' ua-open' : ''}${props.align === 'end' ? ' is-end' : ''}`,
role: 'menu',
},
...rows,
);
const root = h('div', { class: () => `ua-menu${open() ? ' ua-open' : ''}` }, trigger, pop);
root.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
open(false);
trigger.focus(); // 收起后焦点回到触发器,键盘流不断
return;
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; // 卫语句:只接管上下键
e.preventDefault();
if (!open()) {
open(true);
focusables[0]?.focus(); // 首次按下即展开并进首项
return;
}
const at = focusables.indexOf(document.activeElement as HTMLButtonElement);
const step = e.key === 'ArrowDown' ? 1 : -1;
focusables[(at + step + focusables.length) % focusables.length]?.focus(); // 环形移动
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false); // 点外部收起
});
effect(() => open()); // 建立依赖,保证开合信号生效
return root;
}
+18
View File
@@ -77,6 +77,24 @@ export interface RadioGroupProps {
onChange?: (value: string) => void; // 变更回调
}
/*** 菜单项:divider 渲染成分隔线,其余为可选条目 ***/
export interface MenuItem {
value: string; // 条目标识(经 onSelect 透传)
label: string; // 条目文案
disabled?: boolean; // 禁用该条目:置灰且不参与键盘导航
danger?: boolean; // 危险操作条目:文案取 danger 语义色
divider?: boolean; // 该条目渲染为分隔线(忽略 value / label)
}
/*** 下拉菜单入参 ***/
export interface MenuProps {
label: string; // 触发器文案
items: MenuItem[]; // 菜单条目列表
variant?: 'primary' | 'ghost'; // 触发器视觉强度;缺省 ghost
align?: 'start' | 'end'; // 弹层对齐方向;缺省 start(与触发器左对齐)
onSelect?: (value: string) => void; // 选中回调(禁用项不触发)
}
/*** 多行输入入参 ***/
export interface TextareaProps {
placeholder?: string; // 占位文案
+50 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@ui-arch/ui",
"type": "skin-dom",
"description": "DOM 皮肤组件库:button / textField / textarea / checkbox / switch / select / radioGroup / card / badge / alert / toast / tooltip / skeleton / spinner / progress / tabs / pagination / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
"description": "DOM 皮肤组件库:button / textField / textarea / checkbox / switch / select / menu / radioGroup / card / badge / alert / toast / tooltip / skeleton / spinner / progress / tabs / pagination / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
"tags": [
"components",
"dom",
@@ -19,6 +19,7 @@
"checkbox(props):复选框,checked 信号双向绑定,disabled 支持布尔或信号禁用",
"switchButton(props):开关,checked 信号双向绑定,disabled 支持布尔或信号禁用",
"select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画",
"menu(props):下拉菜单,触发器 + 自绘弹层,条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部 / Esc 关闭",
"radioGroup(props):单选组,options + value 信号双向绑定",
"card(props, ...children):卡片容器",
"badge(props):徽标,variant = default | success | warning | danger",
@@ -43,6 +44,8 @@
"SwitchProps",
"SelectProps",
"RadioGroupProps",
"MenuItem",
"MenuProps",
"OptionItem",
"CardProps",
"BadgeProps",
@@ -69,7 +72,7 @@
"语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色"
],
"docs": {
"intro": "20 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
"intro": "21 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
"steps": [
"安装依赖:pnpm add @ui-arch/ui @ui-arch/frame(workspace 内自动链接)",
"引入样式:在应用入口 import '@ui-arch/ui/ui.css',暗色主题在根元素设 data-theme='dark'",
@@ -292,6 +295,51 @@
"选中项在弹层中用淡色弱化标记,不抢视觉焦点"
]
},
{
"id": "menu",
"title": "下拉菜单 Menu",
"demo": "sec-menu",
"props": [
{
"n": "label",
"t": "string",
"r": 1,
"d": "触发器文案"
},
{
"n": "items",
"t": "MenuItem[]",
"r": 1,
"d": "菜单条目:{ value, label, disabled?, danger?, divider? }"
},
{
"n": "variant",
"t": "'primary' | 'ghost'",
"d": "触发器视觉强度;缺省 ghost",
"df": "ghost"
},
{
"n": "align",
"t": "'start' | 'end'",
"d": "弹层对齐方向;缺省 start(与触发器左对齐)",
"df": "start"
},
{
"n": "onSelect",
"t": "(value: string) => void",
"k": 1,
"d": "选中回调,禁用项不触发"
}
],
"example": "menu({ label: '操作', items: [{ value: 'edit', label: '编辑' }, { value: 'del', label: '删除', danger: true }], onSelect: (v) => console.log(v) })",
"steps": [
"准备 items 数组:value 是回调标识、label 是展示文案,插一条 { divider: true } 即可分出条目组",
"传 label 决定触发器文案;默认 ghost 轻量样式,主操作用 variant: 'primary'",
"点触发器展开弹层,点条目收起并触发 onSelect;点外部或按 Esc 也会收起(Esc 后焦点回到触发器)",
"键盘:Tab 聚焦触发器,↑↓ 展开并在可用条目间环形移动,Enter / Space 选中;disabled 项自动跳过",
"靠右对齐避免溢出时传 align: 'end';危险操作给条目加 danger: true"
]
},
{
"id": "radioGroup",
"title": "单选组 RadioGroup",
+79
View File
@@ -308,6 +308,82 @@
background: color-mix(in srgb, var(--ua-color-accent) 14%, transparent);
}
/* —— 下拉菜单 —— */
.ua-menu {
position: relative;
display: inline-block;
}
.ua-menu-trigger {
gap: 6px;
}
.ua-menu-caret {
font-size: 10px;
opacity: 0.7;
transition: transform 0.2s;
}
.ua-menu.ua-open .ua-menu-caret {
transform: rotate(180deg);
}
.ua-menu-pop {
position: absolute;
z-index: 20;
top: calc(100% + 4px);
left: 0;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-md);
background: var(--ua-color-surface);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
opacity: 0;
transform: translateY(-4px);
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
}
.ua-menu-pop.is-end {
left: auto;
right: 0;
}
.ua-menu-pop.ua-open {
opacity: 1;
transform: none;
visibility: visible;
}
.ua-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 6px 10px;
border: none;
border-radius: var(--ua-radius-sm);
background: none;
color: var(--ua-color-text);
font-size: var(--ua-font-size-md);
cursor: pointer;
transition: background 0.12s;
}
.ua-menu-item:hover:not(:disabled) {
background: color-mix(in srgb, var(--ua-color-accent) 8%, transparent);
}
.ua-menu-item.is-danger {
color: var(--ua-color-danger);
}
.ua-menu-item.is-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--ua-color-danger) 10%, transparent);
}
.ua-menu-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ua-menu-divider {
height: 1px;
margin: 4px 2px;
background: var(--ua-color-border);
}
/* —— 多行输入 —— */
.ua-textarea {
width: 100%;
@@ -795,6 +871,9 @@
.ua-select-arrow,
.ua-select-pop,
.ua-option,
.ua-menu-pop,
.ua-menu-caret,
.ua-menu-item,
.ua-switch-track,
.ua-switch-thumb,
.ua-tab,