diff --git a/.ua/index.json b/.ua/index.json index 65ab171..8b2aa15 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T07:22:32.759Z", + "generatedAt": "2026-10-26T07:24:00.739Z", "repoRoot": ".", "packages": [ { @@ -207,7 +207,8 @@ "typography(props):排版文本,variant 决定语义(标题出 h1-h5 / 段落出 p / 行内出 span),支持语义色、装饰位、对齐、多行截断与复制按钮", "grid(props, ...children):栅格容器,columns 固定列数或 min 走 auto-fit 自适应列宽,gutter 统一列间距,窄屏自动降为单列", "floatButton(props):悬浮操作按钮,固定停靠左下 / 右下角,label 缺省为纯图标圆形,badge 走角标,tooltip 兼作 aria-label", - "mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value" + "mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value", + "treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起" ], "keyTypes": [ "ButtonProps", @@ -3313,6 +3314,74 @@ "弹层贴在输入框下沿而非跟随光标行(多行输入里更稳定,也不会被行高算错),需要跟随光标请自绘", "要限制可选人范围就在 items 上做过滤,组件不做远程搜索;远程场景用 onInput 拉数据后重建 items" ] + }, + { + "id": "treeSelect", + "title": "树形选择 TreeSelect", + "demo": "sec-treeselect", + "props": [ + { + "n": "nodes", + "t": "TreeNode[]", + "r": 1, + "d": "根节点列表,结构同 tree:key / label / children / disabled" + }, + { + "n": "value", + "t": "Signal", + "d": "单选选中 key;不传则内部维护" + }, + { + "n": "checked", + "t": "Signal", + "d": "多选勾选 key 集合(multi 时生效);不传则内部维护" + }, + { + "n": "multi", + "t": "boolean", + "df": "false", + "d": "多选模式:显示勾选框、父子级联、选中后弹层不收起" + }, + { + "n": "searchable", + "t": "boolean", + "df": "false", + "d": "弹层内带搜索框(按标签过滤并保留父链)" + }, + { + "n": "placeholder", + "t": "string", + "df": "请选择", + "d": "未选中时触发器文案" + }, + { + "n": "disabled", + "t": "boolean", + "df": "false", + "d": "禁用整个触发器(不响应点击)" + }, + { + "n": "onSelect", + "t": "(node: TreeNode) => void", + "k": 1, + "d": "单选选中回调(此时弹层已收起)" + }, + { + "n": "onChange", + "t": "(keys: string[]) => void", + "k": 1, + "d": "多选勾选变更回调,传副本且父子已级联" + } + ], + "example": "const dept = signal(''); treeSelect({ nodes: [{ key: 'tech', label: '技术中心', children: [{ key: 'fe', label: '前端组' }] }], value: dept, searchable: true, onSelect: (n) => toast.info(n.label) })", + "steps": [ + "表单里选层级归属(部门 / 分类 / 区域)用它,不要用 cascader——cascader 要求逐级选到叶子,treeSelect 允许选中间节点,也允许任意深度", + "单选默认选中即收起;要连续看结构就传 multi,勾选后弹层保持展开,触发器回显「已选 N 项」", + "需要在大树里找人/找分类就开 searchable:搜索按标签过滤并保留命中节点的父链,路径不会断", + "Esc 与其他弹层共享键盘栈(只关最上层),点外部也会收起;展开时焦点自动落到搜索框", + "选中值只存 key(单选 value / 多选 checked 数组),显示名由 nodes 反查——改 label 不需要迁移数据", + "禁用单个节点用 nodes 里的 disabled(该节点不可选,子节点仍可选);禁用整个控件用 disabled" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index 78265ef..899cd01 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):64 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):65 个 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 29e0cef..4e3a43f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 64 个组件 + ui.css +│ │ └── ui/ # 65 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 1e52e5c..f713728 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 可查漂移) ``` -## 组件(共 64 个) +## 组件(共 65 个) -accordion · affix · alert · anchor · autoComplete · avatar · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · floatButton · form · grid · image · inputNumber · 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 · typography · upload · watermark +accordion · affix · alert · anchor · autoComplete · avatar · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · colorPicker · dataTable · datePicker · descriptions · divider · drawer · empty · floatButton · form · grid · image · inputNumber · 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 · 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 / anchor)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel / calendar / typography)、其他(floatButton / grid / 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)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel / calendar / typography)、其他(floatButton / grid / space / divider / splitter / backTop / affix / tour / watermark)。 ## 可访问性约定 @@ -32,7 +32,7 @@ accordion · affix · alert · anchor · autoComplete · avatar · backTop · ba |---|---| | `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:64 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:65 个 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 617223a..7a20a6c 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -39,6 +39,7 @@ import { grid, list, carousel, + treeSelect, tree, notification, result, @@ -258,6 +259,18 @@ const teamMembers = [ { name: 'zhaoliu', label: '赵六', description: 'QA · 回归', keywords: ['zl', '1004'] }, ]; +// —— 树形选择:单选存 key、多选存 key 集合,两个实例共用同一棵部门树 —— +const dept = signal(''); +const deptChecked = signal([]); +const orgTree = [ + { key: 'tech', label: '技术中心', children: [ + { key: 'fe', label: '前端组', children: [{ key: 'ui', label: '组件库' }, { key: 'web', label: '业务前端' }] }, + { key: 'be', label: '后端组', children: [{ key: 'order', label: '订单服务' }, { key: 'pay', label: '支付服务' }] }, + ] }, + { key: 'design', label: '设计中心', children: [{ key: 'ix', label: '交互' }, { key: 'vd', label: '视觉' }] }, + { key: 'qa', label: '质量中心', children: [{ key: 'reg', label: '回归测试', disabled: true }] }, +]; + const content = h( 'div', { class: 'page' }, @@ -1336,6 +1349,20 @@ const content = h( ), ), ), + section( + 'sec-treeselect', + '树形选择', + 'treeSelect = 触发器 + 弹层里嵌的 tree:搜索、多选、父子级联全复用 tree,Esc 与其他弹层共享同一个栈(只关最上层),点外部自动收起;单选选中即收起,多选显示已选数量。', + grid({ min: '280px' }, + card({ title: '单选(可搜索)' }, + treeSelect({ nodes: orgTree, value: dept, searchable: true, placeholder: '选择归属部门', onSelect: (n) => toast.info(`已选 ${n.label}`) }), + ), + card({ title: '多选(保持展开)' }, + treeSelect({ nodes: orgTree, multi: true, checked: deptChecked, onChange: (keys) => toast.info(`已勾选 ${keys.length} 项`) }), + typography({ variant: 'paragraph', type: 'secondary', content: '勾选父节点会级联到子节点,「回归测试」是禁用叶子,不可选。' }), + ), + ), + ), section( 'sec-floatbutton', '悬浮操作按钮', diff --git a/llms-full.txt b/llms-full.txt index 42fe4ed..0ce6c63 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T07:22:32.759Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T07:24:00.739Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -1786,6 +1786,37 @@ const text = signal(''); mentions({ items: [{ name: 'zhangsan', label: '张三', 现场演示:apps/docs/demo.html#sec-mentions +### treeSelect — 树形选择 TreeSelect + +参数: + +- nodes(必填):TreeNode[] —— 根节点列表,结构同 tree:key / label / children / disabled +- value:Signal —— 单选选中 key;不传则内部维护 +- checked:Signal —— 多选勾选 key 集合(multi 时生效);不传则内部维护 +- multi(默认 false):boolean —— 多选模式:显示勾选框、父子级联、选中后弹层不收起 +- searchable(默认 false):boolean —— 弹层内带搜索框(按标签过滤并保留父链) +- placeholder(默认 请选择):string —— 未选中时触发器文案 +- disabled(默认 false):boolean —— 禁用整个触发器(不响应点击) +- onSelect(回调):(node: TreeNode) => void —— 单选选中回调(此时弹层已收起) +- onChange(回调):(keys: string[]) => void —— 多选勾选变更回调,传副本且父子已级联 + +示例: + +``` +const dept = signal(''); treeSelect({ nodes: [{ key: 'tech', label: '技术中心', children: [{ key: 'fe', label: '前端组' }] }], value: dept, searchable: true, onSelect: (n) => toast.info(n.label) }) +``` + +手把手: + +1. 表单里选层级归属(部门 / 分类 / 区域)用它,不要用 cascader——cascader 要求逐级选到叶子,treeSelect 允许选中间节点,也允许任意深度 +2. 单选默认选中即收起;要连续看结构就传 multi,勾选后弹层保持展开,触发器回显「已选 N 项」 +3. 需要在大树里找人/找分类就开 searchable:搜索按标签过滤并保留命中节点的父链,路径不会断 +4. Esc 与其他弹层共享键盘栈(只关最上层),点外部也会收起;展开时焦点自动落到搜索框 +5. 选中值只存 key(单选 value / 多选 checked 数组),显示名由 nodes 反查——改 label 不需要迁移数据 +6. 禁用单个节点用 nodes 里的 disabled(该节点不可选,子节点仍可选);禁用整个控件用 disabled + +现场演示:apps/docs/demo.html#sec-treeselect + API 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交) @@ -1854,6 +1885,7 @@ API 速览: - grid(props, ...children):栅格容器,columns 固定列数或 min 走 auto-fit 自适应列宽,gutter 统一列间距,窄屏自动降为单列 - floatButton(props):悬浮操作按钮,固定停靠左下 / 右下角,label 缺省为纯图标圆形,badge 走角标,tooltip 兼作 aria-label - mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value +- treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起 一行用法: diff --git a/llms.txt b/llms.txt index 08f0175..bfb9206 100644 --- a/llms.txt +++ b/llms.txt @@ -113,6 +113,7 @@ - grid(props, ...children):栅格容器,columns 固定列数或 min 走 auto-fit 自适应列宽,gutter 统一列间距,窄屏自动降为单列 - floatButton(props):悬浮操作按钮,固定停靠左下 / 右下角,label 缺省为纯图标圆形,badge 走角标,tooltip 兼作 aria-label - mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value + - treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起 - 用法: `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/index.ts b/packages/skin/ui/src/index.ts index b226737..9fb60d6 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -64,3 +64,4 @@ export { typography } from './typography.js'; export { grid } from './grid.js'; export { floatButton } from './floatButton.js'; export { mentions } from './mentions.js'; +export { treeSelect } from './treeSelect.js'; diff --git a/packages/skin/ui/src/treeSelect.ts b/packages/skin/ui/src/treeSelect.ts new file mode 100644 index 0000000..171754f --- /dev/null +++ b/packages/skin/ui/src/treeSelect.ts @@ -0,0 +1,82 @@ +import { effect, h, signal } from '@ui-arch/frame'; +import { bindOutside, pushOverlay } from './overlay.js'; +import { tree } from './tree.js'; +import type { TreeNode, TreeSelectProps } from './types.js'; + +/*** 按 key 深度优先找节点,用于把选中 key 还原成显示名 ***/ +function findNode(nodes: TreeNode[], key: string): TreeNode | null { + for (const node of nodes) { + if (node.key === key) return node; // 命中即返回 + const hit = node.children ? findNode(node.children, key) : null; + if (hit) return hit; + } + return null; +} + +/*** 树形选择:按钮触发弹层,内部复用 tree(搜索 / 多选 / 级联由 tree 提供);单选选中即收起,多选保持展开 ***/ +export function treeSelect(props: TreeSelectProps): HTMLElement { + const open = signal(false); // 弹层开合 + const value = props.value ?? signal(''); // 单选 key + const checked = props.checked ?? signal([]); // 多选 key 集合 + let release: (() => void) | null = null; // Esc 栈注销句柄 + const labelEl = h('span', { class: 'ua-treeselect-label' }); + const trigger = h('button', { + class: 'ua-treeselect-trigger', + type: 'button', + 'aria-haspopup': 'tree', + 'aria-expanded': 'false', + }, labelEl, h('span', { class: 'ua-treeselect-arrow', 'aria-hidden': 'true' }, '▾')) as HTMLButtonElement; + const pop = h('div', { class: 'ua-treeselect-pop' }); + const inner = tree({ + nodes: props.nodes, + searchable: props.searchable, + multi: props.multi, + selected: value, + checked, + onSelect: (node) => { + if (!props.multi) close(); // 单选选中即收起,少一次点击 + props.onSelect?.(node); + }, + onCheck: (keys) => { + props.onChange?.(keys); + }, + }); + pop.append(inner); + const wrap = h('div', { class: 'ua-treeselect' }, trigger, pop); + + /*** 收起弹层并注销 Esc 栈 ***/ + function close(): void { + open(false); + release?.(); + release = null; + } + + /*** 触发器文案:单选回显节点名,多选回显已选数量,都没选则显示占位 ***/ + const paint = (): void => { + const keys = checked(); + if (props.multi) { + labelEl.textContent = keys.length ? `已选 ${keys.length} 项` : (props.placeholder ?? '请选择'); + } else { + const node = value() ? findNode(props.nodes, value()) : null; + labelEl.textContent = node ? node.label : (props.placeholder ?? '请选择'); + } + wrap.classList.toggle('is-empty', props.multi ? !keys.length : !value()); + trigger.disabled = Boolean(props.disabled); + }; + + trigger.addEventListener('click', () => { + if (props.disabled) return; // 卫语句:禁用不响应 + if (open()) return close(); + open(true); + release = pushOverlay(close); // 与其他弹层共享 Esc 栈,只关最上层 + }); + bindOutside(wrap, open, close); + effect(() => { + const on = open(); + wrap.classList.toggle('is-open', on); + trigger.setAttribute('aria-expanded', String(on)); + if (on) inner.querySelector('input')?.focus(); // 展开即聚焦搜索框,键盘可直接用 + }); + effect(paint); + return wrap; +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 0a55bc1..58d6202 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -795,4 +795,17 @@ export interface MentionsProps { onInput?: (text: string) => void; // 每次输入的回调,参数为当前全文 } +/*** 树形选择入参 ***/ +export interface TreeSelectProps { + nodes: TreeNode[]; // 根节点列表(结构同 tree) + value?: Signal; // 单选选中 key;不传则内部维护 + checked?: Signal; // 多选勾选 key 集合(multi 时生效);不传则内部维护 + multi?: boolean; // 多选模式:显示勾选框、父子级联、选中不收起;缺省 false + searchable?: boolean; // 弹层内带搜索框;缺省 false + placeholder?: string; // 未选中时触发器文案;缺省「请选择」 + disabled?: boolean; // 禁用整个触发器;缺省 false + onSelect?: (node: TreeNode) => void; // 单选选中回调(弹层已收起) + onChange?: (keys: string[]) => void; // 多选勾选变更回调(传副本,父子已级联) +} + export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 8db10ee..1e7183b 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -78,7 +78,8 @@ "typography(props):排版文本,variant 决定语义(标题出 h1-h5 / 段落出 p / 行内出 span),支持语义色、装饰位、对齐、多行截断与复制按钮", "grid(props, ...children):栅格容器,columns 固定列数或 min 走 auto-fit 自适应列宽,gutter 统一列间距,窄屏自动降为单列", "floatButton(props):悬浮操作按钮,固定停靠左下 / 右下角,label 缺省为纯图标圆形,badge 走角标,tooltip 兼作 aria-label", - "mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value" + "mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value", + "treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起" ], "keyTypes": [ "ButtonProps", @@ -3184,6 +3185,74 @@ "弹层贴在输入框下沿而非跟随光标行(多行输入里更稳定,也不会被行高算错),需要跟随光标请自绘", "要限制可选人范围就在 items 上做过滤,组件不做远程搜索;远程场景用 onInput 拉数据后重建 items" ] + }, + { + "id": "treeSelect", + "title": "树形选择 TreeSelect", + "demo": "sec-treeselect", + "props": [ + { + "n": "nodes", + "t": "TreeNode[]", + "r": 1, + "d": "根节点列表,结构同 tree:key / label / children / disabled" + }, + { + "n": "value", + "t": "Signal", + "d": "单选选中 key;不传则内部维护" + }, + { + "n": "checked", + "t": "Signal", + "d": "多选勾选 key 集合(multi 时生效);不传则内部维护" + }, + { + "n": "multi", + "t": "boolean", + "df": "false", + "d": "多选模式:显示勾选框、父子级联、选中后弹层不收起" + }, + { + "n": "searchable", + "t": "boolean", + "df": "false", + "d": "弹层内带搜索框(按标签过滤并保留父链)" + }, + { + "n": "placeholder", + "t": "string", + "df": "请选择", + "d": "未选中时触发器文案" + }, + { + "n": "disabled", + "t": "boolean", + "df": "false", + "d": "禁用整个触发器(不响应点击)" + }, + { + "n": "onSelect", + "t": "(node: TreeNode) => void", + "k": 1, + "d": "单选选中回调(此时弹层已收起)" + }, + { + "n": "onChange", + "t": "(keys: string[]) => void", + "k": 1, + "d": "多选勾选变更回调,传副本且父子已级联" + } + ], + "example": "const dept = signal(''); treeSelect({ nodes: [{ key: 'tech', label: '技术中心', children: [{ key: 'fe', label: '前端组' }] }], value: dept, searchable: true, onSelect: (n) => toast.info(n.label) })", + "steps": [ + "表单里选层级归属(部门 / 分类 / 区域)用它,不要用 cascader——cascader 要求逐级选到叶子,treeSelect 允许选中间节点,也允许任意深度", + "单选默认选中即收起;要连续看结构就传 multi,勾选后弹层保持展开,触发器回显「已选 N 项」", + "需要在大树里找人/找分类就开 searchable:搜索按标签过滤并保留命中节点的父链,路径不会断", + "Esc 与其他弹层共享键盘栈(只关最上层),点外部也会收起;展开时焦点自动落到搜索框", + "选中值只存 key(单选 value / 多选 checked 数组),显示名由 nodes 反查——改 label 不需要迁移数据", + "禁用单个节点用 nodes 里的 disabled(该节点不可选,子节点仍可选);禁用整个控件用 disabled" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 24072fd..c6155f5 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -4228,3 +4228,83 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); } transition: none; } } + +/* —— 树形选择:触发器 + 下拉弹层,弹层内直接嵌入 tree —— */ +.ua-treeselect { + position: relative; + display: inline-block; + min-width: 220px; +} +.ua-treeselect-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ua-space-2); + width: 100%; + padding: var(--ua-space-2) var(--ua-space-3); + border: 1px solid var(--ua-color-border); + border-radius: var(--ua-radius-md); + background: var(--ua-color-surface); + color: var(--ua-color-text); + font: inherit; + font-size: var(--ua-font-size-md); + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.ua-treeselect-trigger:hover { + border-color: color-mix(in srgb, var(--ua-color-accent) 55%, var(--ua-color-border)); +} +.ua-treeselect-trigger:focus-visible, +.ua-treeselect.is-open .ua-treeselect-trigger { + outline: none; + border-color: var(--ua-color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 20%, transparent); +} +.ua-treeselect-trigger:disabled { + cursor: not-allowed; + opacity: 0.6; +} +.ua-treeselect.is-empty .ua-treeselect-label { + color: var(--ua-color-text-muted); +} +.ua-treeselect-arrow { + color: var(--ua-color-text-muted); + font-size: 10px; + transition: transform 0.15s ease; +} +.ua-treeselect.is-open .ua-treeselect-arrow { + transform: rotate(180deg); +} +.ua-treeselect-pop { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 4px); + z-index: 30; + display: none; + max-height: 280px; + overflow: auto; + padding: var(--ua-space-2); + border: 1px solid var(--ua-color-border); + border-radius: var(--ua-radius-md); + background: var(--ua-color-surface); + box-shadow: 0 14px 30px color-mix(in srgb, var(--ua-color-text) 16%, transparent); +} +.ua-treeselect.is-open .ua-treeselect-pop { + display: block; + animation: ua-treeselect-in 0.15s ease; +} +@keyframes ua-treeselect-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { + .ua-treeselect.is-open .ua-treeselect-pop { + animation: none; + } + .ua-treeselect-arrow, + .ua-treeselect-trigger { + transition: none; + } +}