From 84277a744e2fdde7a326f51c9278dccba1557a3c Mon Sep 17 00:00:00 2001 From: ui-arch-bot Date: Fri, 23 Oct 2026 16:46:00 +0800 Subject: [PATCH] =?UTF-8?q?iter:=20d01=20=E6=96=B0=E5=A2=9E=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E8=8F=9C=E5=8D=95=20menu=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E5=BC=B9=E5=B1=82/=E9=94=AE=E7=9B=98=E5=AF=BC?= =?UTF-8?q?=E8=88=AA/reduced-motion=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 10 +++- .ua/index.json | 54 ++++++++++++++++++-- .ua/iterations.log | 1 + apps/demo/main.ts | 35 +++++++++++++ llms-full.txt | 31 +++++++++++- llms.txt | 3 +- packages/skin/ui/src/index.ts | 1 + packages/skin/ui/src/menu.ts | 82 +++++++++++++++++++++++++++++++ packages/skin/ui/src/types.ts | 18 +++++++ packages/skin/ui/ua.manifest.json | 52 +++++++++++++++++++- packages/skin/ui/ui.css | 79 +++++++++++++++++++++++++++++ 11 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 packages/skin/ui/src/menu.ts diff --git a/.ua/backlog.json b/.ua/backlog.json index 248802b..fefbf3a 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -56,6 +56,14 @@ "acceptance": "TextFieldProps / TextareaProps 新增可选 disabled(boolean | Signal),禁用时不响应输入、置灰 + not-allowed 光标;demo 禁用示例区补一个信号绑定输入框;manifest/文档/llms-full.txt 同步;npm run check 全绿", "status": "done", "notes": "与 c01 同模式:disabled 透传 h() 即得布尔/信号双支持;禁用态 CSS 追加 .ua-input / .ua-textarea;demo 复用 locked 信号联动开关与输入框" + }, + { + "id": "d01", + "title": "新增下拉菜单 menu 组件", + "motivation": "工具栏/行操作场景缺省可折叠的动作集合,select 只能选值不能派发动作;可复用自绘弹层与微动画既有模式", + "acceptance": "packages/skin/ui 新增 menu.ts:触发器 + 自绘弹层,items 支持 disabled / danger / divider,点条目 / 点外部 / Esc 关闭,↑↓ 环形导航(禁用项跳过),Enter/Space 选中,Esc 收起后焦点回触发器;只消费 --ua-* 令牌并带 prefers-reduced-motion 兜底;导出 + manifest + 文档 + llms-full.txt + demo 同步;npm run check 全绿", + "status": "done", + "notes": "API menu({ label, items, variant, align, onSelect });MenuItem = { value, label, disabled?, danger?, divider? };弹层 ua-open 同时挂在根与弹层(根管箭头旋转、弹层管出入场),危险条目取 danger 色;键盘 ↑↓ 环形导航 + Esc 回焦触发器;demo 新增 sec-menu 双示例(含禁用/危险/分隔线与右对齐)" } ] -} \ No newline at end of file +} diff --git a/.ua/index.json b/.ua/index.json index d834188..b885ddf 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-23T08:10:07.974Z", + "generatedAt": "2026-10-23T08:45:45.013Z", "repoRoot": ".", "packages": [ { @@ -130,7 +130,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", @@ -148,6 +148,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", @@ -172,6 +173,8 @@ "SwitchProps", "SelectProps", "RadioGroupProps", + "MenuItem", + "MenuProps", "OptionItem", "CardProps", "BadgeProps", @@ -198,7 +201,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'", @@ -421,6 +424,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", diff --git a/.ua/iterations.log b/.ua/iterations.log index bb141e0..a6f3fb6 100644 --- a/.ua/iterations.log +++ b/.ua/iterations.log @@ -5,3 +5,4 @@ {"time":"2026-10-23T16:03:46+08:00","id":"b05","result":"done","summary":"文档站核查确认已 manifest 驱动;补齐默认值信息量缺口:CLI propsTable 新增默认值列(df 字段)+ llms-full 默认标注,16 处默认值提取;check 全绿"} {"time":"2026-10-23T16:08:17+08:00","id":"c01","result":"done","summary":"button/checkbox/switchButton 补 disabled(boolean|Signal),复用 h() 布尔/信号既有语义零辅助代码;CSS 通用禁用态 + demo 信号联动示例;check 全绿"} {"time":"2026-10-23T16:10:31+08:00","id":"c02","result":"done","summary":"textField/textarea 补 disabled(boolean|Signal),与 c01 同模式;禁用态 CSS 覆盖输入类;demo locked 信号联动;check 全绿"} +{"time": "2026-10-23T16:55:00+08:00", "id": "d01", "result": "done", "summary": "新增下拉菜单 menu:触发器 + 自绘弹层,disabled/danger/divider 条目,↑↓ 环形导航 + Esc 回焦,reduced-motion 兜底;manifest/llms-full/demo 同步,check 全绿"} diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 65d3183..9c006f7 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -8,6 +8,7 @@ import { dataTable, form, formItem, + menu, modal, pagination, progress, @@ -71,6 +72,9 @@ const formResult = signal(''); // —— 分页联动状态:dataTable 与独立 pagination 共用同一页码信号 —— const tablePage = signal(1); +// —— 菜单选中结果:演示 onSelect 回调 —— +const menuPick = signal(''); + // —— 禁用态演示:开关信号同时绑定 switch 的 checked 与 checkbox 的 disabled —— const locked = signal(true); @@ -191,6 +195,37 @@ const app = h( h('div', { class: 'row' }, button({ label: '知道了', onClick: () => modalOpen(false) }), badge({ label: 'modal', variant: 'success' })), ), ), + section( + 'sec-menu', + '下拉菜单', + 'menu 触发器 + 自绘弹层:条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部或 Esc 收起。', + h( + 'div', + { class: 'row' }, + menu({ + label: '文件操作', + items: [ + { value: 'rename', label: '重命名' }, + { value: 'copy', label: '创建副本' }, + { value: 'archive', label: '归档', disabled: true }, + { divider: true, value: '', label: '' }, + { value: 'delete', label: '删除', danger: true }, + ], + onSelect: (v) => menuPick(v), + }), + menu({ + label: '右对齐', + align: 'end', + variant: 'primary', + items: [ + { value: 'profile', label: '个人资料' }, + { value: 'settings', label: '偏好设置' }, + ], + onSelect: (v) => menuPick(v), + }), + h('span', { class: 'out' }, () => (menuPick() ? `已选:${menuPick()}` : '未选择')), + ), + ), section( 'sec-table', '数据表格', diff --git a/llms-full.txt b/llms-full.txt index b730ce3..05c70ef 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-23T08:10:07.974Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-23T08:45:45.013Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -40,7 +40,7 @@ ## @ui-arch/ui(skin-dom)v0.1.0 -20 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。 +21 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。 ### button — 按钮 Button @@ -182,6 +182,32 @@ const city = signal(''); select({ options: [{ value: 'bj', label: '北京' }], v 现场演示:apps/docs/demo.html#sec-form +### menu — 下拉菜单 Menu + +参数: + +- label(必填):string —— 触发器文案 +- items(必填):MenuItem[] —— 菜单条目:{ value, label, disabled?, danger?, divider? } +- variant(默认 ghost):'primary' | 'ghost' —— 触发器视觉强度;缺省 ghost +- align(默认 start):'start' | 'end' —— 弹层对齐方向;缺省 start(与触发器左对齐) +- onSelect(回调):(value: string) => void —— 选中回调,禁用项不触发 + +示例: + +``` +menu({ label: '操作', items: [{ value: 'edit', label: '编辑' }, { value: 'del', label: '删除', danger: true }], onSelect: (v) => console.log(v) }) +``` + +手把手: + +1. 准备 items 数组:value 是回调标识、label 是展示文案,插一条 { divider: true } 即可分出条目组 +2. 传 label 决定触发器文案;默认 ghost 轻量样式,主操作用 variant: 'primary' +3. 点触发器展开弹层,点条目收起并触发 onSelect;点外部或按 Esc 也会收起(Esc 后焦点回到触发器) +4. 键盘:Tab 聚焦触发器,↑↓ 展开并在可用条目间环形移动,Enter / Space 选中;disabled 项自动跳过 +5. 靠右对齐避免溢出时传 align: 'end';危险操作给条目加 danger: true + +现场演示:apps/docs/demo.html#sec-menu + ### radioGroup — 单选组 RadioGroup 参数: @@ -517,6 +543,7 @@ API 速览: - 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 diff --git a/llms.txt b/llms.txt index dfe6b68..01e4c4c 100644 --- a/llms.txt +++ b/llms.txt @@ -45,7 +45,7 @@ ## @ui-arch/ui (v0.1.0) - 类型: skin-dom · 目录: packages/skin/ui -- 描述: 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-* 令牌。 +- 描述: 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-* 令牌。 - API: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用 - textField(props):文本输入,value 信号双向绑定,disabled 支持布尔或信号禁用 @@ -53,6 +53,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 diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index ba30e68..35085fe 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -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'; diff --git a/packages/skin/ui/src/menu.ts b/packages/skin/ui/src/menu.ts new file mode 100644 index 0000000..c80fd2a --- /dev/null +++ b/packages/skin/ui/src/menu.ts @@ -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; +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index faecced..7df012f 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -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; // 占位文案 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index b2cf451..6fea3f3 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -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", diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 10a22bd..8e2771d 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -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,