iter(g47): 新增 cascader 级联选择组件
This commit is contained in:
+54
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "ui-arch-registry-index/0.1",
|
||||
"generatedAt": "2026-10-26T04:07:52.893Z",
|
||||
"generatedAt": "2026-10-26T04:10:00.644Z",
|
||||
"repoRoot": ".",
|
||||
"packages": [
|
||||
{
|
||||
@@ -188,7 +188,8 @@
|
||||
"upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)",
|
||||
"notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭",
|
||||
"result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态",
|
||||
"transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选"
|
||||
"transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选",
|
||||
"cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
@@ -2101,6 +2102,57 @@
|
||||
"disabled 条目不可勾选、不计入全选基数、也不会被搬运",
|
||||
"传入 targetKeys 信号即为受控用法,外部写入即可程序化搬运(如「一键全选」)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cascader",
|
||||
"title": "级联选择 Cascader",
|
||||
"demo": "sec-cascader",
|
||||
"props": [
|
||||
{
|
||||
"n": "options",
|
||||
"t": "CascaderOption[]",
|
||||
"r": 1,
|
||||
"d": "根级选项,children 递归出列,value 逐级拼成路径"
|
||||
},
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string[]>",
|
||||
"d": "选中路径信号;不传则内部维护,传了即外部受控"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "未选时占位文案;缺省「请选择」"
|
||||
},
|
||||
{
|
||||
"n": "separator",
|
||||
"t": "string",
|
||||
"d": "路径分隔符;缺省「 / 」"
|
||||
},
|
||||
{
|
||||
"n": "changeOnSelect",
|
||||
"t": "boolean",
|
||||
"d": "允许停在中间层;缺省 false 必须选到叶子才提交"
|
||||
},
|
||||
{
|
||||
"n": "disabled",
|
||||
"t": "boolean | Signal<boolean>",
|
||||
"d": "禁用触发器;支持信号响应式禁用"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(path: string[]) => void",
|
||||
"d": "选中回调(叶子或中间层,传副本)"
|
||||
}
|
||||
],
|
||||
"example": "cascader({ value: areaPath, changeOnSelect: true, options: [{ value: 'east', label: '华东', children: [{ value: 'sh', label: '上海' }] }] })",
|
||||
"steps": [
|
||||
"传入嵌套 options,第一列是根级选项,悬停带 children 的项即展开下一列",
|
||||
"点击叶子提交;changeOnSelect 为 true 时点中间层也提交(适合「省/市」这类粗粒度就够用的场景)",
|
||||
"value 是路径数组(逐级 value),传信号即受控;触发器文案按 separator 拼接已提交路径",
|
||||
"键盘:↑↓ 同列移动、→ 下钻、← 回退、Enter/空格 提交、Esc 收起(与弹窗共用一个 Esc 栈)",
|
||||
"面板空间不足时自动向上翻转;disabled 选项不可点也不会展开下一列"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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):45 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌
|
||||
- `packages/skin/ui`(@ui-arch/ui):46 个 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
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ ui-arch-mvp/
|
||||
│ │ ├── table-core/ # 表格状态内核
|
||||
│ │ └── frame/ # 信号 + hyperscript 微框架
|
||||
│ ├── skin/ # 皮肤层(DOM 组件,可发布)
|
||||
│ │ └── ui/ # 45 个组件 + ui.css
|
||||
│ │ └── ui/ # 46 个组件 + ui.css
|
||||
│ ├── design/ # 资产层(可发布)
|
||||
│ │ └── tokens/ # --ua-* 设计令牌
|
||||
│ └── tools/ # 工程层(private,不发布)
|
||||
|
||||
@@ -11,13 +11,13 @@ npm run demo # 打开演示:apps/demo/dist/demo.html
|
||||
npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt
|
||||
```
|
||||
|
||||
## 组件(共 45 个)
|
||||
## 组件(共 46 个)
|
||||
|
||||
<!-- ua:components -->
|
||||
accordion · alert · avatar · badge · breadcrumb · button · carousel · card · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · spinner · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · transfer · tree · upload
|
||||
accordion · alert · avatar · badge · breadcrumb · button · carousel · card · cascader · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · list · menu · modal · notification · pagination · popover · progress · radioGroup · rate · result · segmented · select · skeleton · slider · spinner · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip · transfer · tree · upload
|
||||
<!-- /ua:components -->
|
||||
|
||||
分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / segmented / upload / form + formItem / transfer)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(divider)。
|
||||
分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / toast / notification / result / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree)、数据展示(dataTable / descriptions / card / list / timeline / tag / avatar / image / carousel)、其他(divider)。
|
||||
|
||||
## 可访问性约定
|
||||
|
||||
@@ -31,7 +31,7 @@ accordion · alert · avatar · badge · breadcrumb · button · carousel · car
|
||||
|---|---|
|
||||
| `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:45 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
|
||||
| `packages/skin/ui` | @ui-arch/ui:46 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
|
||||
| `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) |
|
||||
| `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server |
|
||||
| `apps/demo` | 交互演示(esbuild 打包成单文件) |
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
breadcrumb,
|
||||
button,
|
||||
card,
|
||||
cascader,
|
||||
checkbox,
|
||||
dataTable,
|
||||
transfer,
|
||||
@@ -181,6 +182,7 @@ const tableLoading = signal(false);
|
||||
const menuPick = signal('');
|
||||
const listPick = signal(''); // 列表点击选中的条目标题
|
||||
const slideIndex = signal(0); // 轮播当前下标(外部受控演示)
|
||||
const cascaderPath = signal<string[]>(['east', 'sh']); // 级联选择路径(外部受控演示)
|
||||
const treePick = signal('core'); // 树形选中节点 key
|
||||
const transferKeys = signal<string[]>(['a11y']); // 穿梭框右侧集合(外部受控演示)
|
||||
const cityPick = signal(''); // 可搜索下拉的选中城市
|
||||
@@ -677,6 +679,37 @@ const content = h(
|
||||
button({ label: '错误(可关)', variant: 'danger', onClick: () => notification({ title: '保存失败', description: '服务端返回 500,请稍后重试', variant: 'danger' }) }),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-cascader',
|
||||
'级联选择',
|
||||
'cascader:多列联动,悬停即下钻、点叶子提交;changeOnSelect 可停在中间层,Esc 收起,↑↓/→/←/Enter 全键盘可用。',
|
||||
h(
|
||||
'div',
|
||||
{ class: 'row' },
|
||||
cascader({
|
||||
value: cascaderPath,
|
||||
placeholder: '请选择所属区域',
|
||||
changeOnSelect: true,
|
||||
onChange: (p) => toast.success(`已选:${p.join(' / ')}`),
|
||||
options: [
|
||||
{ value: 'east', label: '华东', children: [
|
||||
{ value: 'sh', label: '上海', children: [{ value: 'pd', label: '浦东新区' }, { value: 'xh', label: '徐汇区' }] },
|
||||
{ value: 'hz', label: '杭州', children: [{ value: 'xh-hz', label: '西湖区' }, { value: 'yh', label: '余杭区' }] },
|
||||
] },
|
||||
{ value: 'south', label: '华南', children: [
|
||||
{ value: 'gz', label: '广州', children: [{ value: 'th', label: '天河区' }] },
|
||||
{ value: 'sz', label: '深圳', children: [{ value: 'nsh', label: '南山区' }] },
|
||||
] },
|
||||
{ value: 'overseas', label: '海外(暂不开放)', disabled: true },
|
||||
],
|
||||
}),
|
||||
cascader({ separator: ' · ', placeholder: '必须选到末级', options: [
|
||||
{ value: 'a', label: '研发中心', children: [{ value: 'a1', label: '前端组' }, { value: 'a2', label: '后端组' }] },
|
||||
{ value: 'b', label: '设计中心', children: [{ value: 'b1', label: '交互组' }, { value: 'b2', label: '视觉组' }] },
|
||||
] }),
|
||||
h('span', { class: 'out' }, () => (cascaderPath().length ? `当前路径:${cascaderPath().join(' / ')}` : '未选择')),
|
||||
),
|
||||
),
|
||||
section(
|
||||
'sec-transfer',
|
||||
'穿梭框',
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
# @ui-arch 组件库 Agent 文档(无图形版)
|
||||
|
||||
> 自动生成于 2026-10-26T04:07:52.893Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||
> 自动生成于 2026-10-26T04:10:00.644Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
|
||||
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
|
||||
|
||||
## 快速上手
|
||||
@@ -1201,6 +1201,34 @@ transfer({ targetKeys: enabled, titles: ['待选', '已启用'], onChange: (keys
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-transfer
|
||||
|
||||
### cascader — 级联选择 Cascader
|
||||
|
||||
参数:
|
||||
|
||||
- options(必填):CascaderOption[] —— 根级选项,children 递归出列,value 逐级拼成路径
|
||||
- value:Signal<string[]> —— 选中路径信号;不传则内部维护,传了即外部受控
|
||||
- placeholder:string —— 未选时占位文案;缺省「请选择」
|
||||
- separator:string —— 路径分隔符;缺省「 / 」
|
||||
- changeOnSelect:boolean —— 允许停在中间层;缺省 false 必须选到叶子才提交
|
||||
- disabled:boolean | Signal<boolean> —— 禁用触发器;支持信号响应式禁用
|
||||
- onChange:(path: string[]) => void —— 选中回调(叶子或中间层,传副本)
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
cascader({ value: areaPath, changeOnSelect: true, options: [{ value: 'east', label: '华东', children: [{ value: 'sh', label: '上海' }] }] })
|
||||
```
|
||||
|
||||
手把手:
|
||||
|
||||
1. 传入嵌套 options,第一列是根级选项,悬停带 children 的项即展开下一列
|
||||
2. 点击叶子提交;changeOnSelect 为 true 时点中间层也提交(适合「省/市」这类粗粒度就够用的场景)
|
||||
3. value 是路径数组(逐级 value),传信号即受控;触发器文案按 separator 拼接已提交路径
|
||||
4. 键盘:↑↓ 同列移动、→ 下钻、← 回退、Enter/空格 提交、Esc 收起(与弹窗共用一个 Esc 栈)
|
||||
5. 面板空间不足时自动向上翻转;disabled 选项不可点也不会展开下一列
|
||||
|
||||
现场演示:apps/docs/demo.html#sec-cascader
|
||||
|
||||
API 速览:
|
||||
|
||||
- button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用;type 缺省 button(表单内不隐式提交)
|
||||
@@ -1250,6 +1278,7 @@ API 速览:
|
||||
- notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭
|
||||
- result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态
|
||||
- transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选
|
||||
- cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作
|
||||
|
||||
一行用法:
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
- notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭
|
||||
- result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态
|
||||
- transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选
|
||||
- cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作
|
||||
- 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')`
|
||||
- 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
|
||||
- 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { pushOverlay } from './overlay.js';
|
||||
import { flip } from './position.js';
|
||||
import type { CascaderOption, CascaderProps } from './types.js';
|
||||
|
||||
/*** 沿路径解析节点链:路径失效时在断点截断,避免脏数据把列算错 ***/
|
||||
function chain(options: CascaderOption[], path: string[]): CascaderOption[] {
|
||||
const out: CascaderOption[] = [];
|
||||
let level = options;
|
||||
for (const v of path) {
|
||||
const hit = level.find((o) => o.value === v);
|
||||
if (!hit) break;
|
||||
out.push(hit);
|
||||
level = hit.children ?? [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/*** 级联选择:多列联动面板,悬停下钻 / 点击提交,value 信号双向绑定,Esc 与弹层栈共用逐层关闭 ***/
|
||||
export function cascader(props: CascaderProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 面板开合
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位:空间不足自动翻转
|
||||
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 已提交路径
|
||||
const path = signal<string[]>([]); // 浏览路径(悬停/下钻都会改,不等于已提交)
|
||||
const sep = props.separator ?? ' / '; // 路径分隔符
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||
|
||||
const panels = h('div', { class: 'ua-cascader-panels', role: 'menu' }) as HTMLElement; // 多列容器
|
||||
const labelText = (): string => chain(props.options, value()).map((o) => o.label).join(sep); // 触发器文案
|
||||
|
||||
/*** 由浏览路径推出各列选项:根列恒在,逐级 append 子级 ***/
|
||||
const columns = (): CascaderOption[][] => {
|
||||
const out: CascaderOption[][] = [props.options];
|
||||
for (let d = 0; d < path().length; d++) {
|
||||
const node = out[d].find((o) => o.value === path()[d]);
|
||||
if (!node?.children?.length) break; // 断在叶子:后续不再出列
|
||||
out.push(node.children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/*** 提交路径:写信号 + 回调 + 收起并把焦点还给触发器 ***/
|
||||
const commit = (p: string[]): void => {
|
||||
value(p);
|
||||
props.onChange?.([...p]);
|
||||
open(false);
|
||||
trigger.focus();
|
||||
};
|
||||
|
||||
/*** 造一列:当前路径高亮、已提交路径打标、有子级显示下钻箭头 ***/
|
||||
const column = (opts: CascaderOption[], depth: number): HTMLElement => {
|
||||
const cur = path()[depth]; // 本列浏览中的值
|
||||
const done = value()[depth]; // 本列已提交的值
|
||||
const rows = opts.map((o) => {
|
||||
const hasChild = Boolean(o.children?.length);
|
||||
const cls = `ua-cascader-opt${o.value === cur ? ' is-active' : ''}${o.value === done ? ' is-picked' : ''}${o.disabled ? ' is-disabled' : ''}`;
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: cls,
|
||||
role: 'menuitem',
|
||||
'aria-haspopup': hasChild ? 'true' : 'false',
|
||||
disabled: o.disabled === true, // 禁用项既不可选也不可下钻
|
||||
onClick: () => {
|
||||
path([...path().slice(0, depth), o.value]); // 点选即把路径截断到本列
|
||||
if (!hasChild || props.changeOnSelect) commit([...path().slice(0, depth), o.value]); // 叶子或允许中间层:立即提交
|
||||
},
|
||||
onmouseenter: () => {
|
||||
if (o.disabled || o.value === cur) return; // 卫语句:禁用项与当前项不触发联动
|
||||
path([...path().slice(0, depth), o.value]);
|
||||
},
|
||||
},
|
||||
h('span', { class: 'ua-cascader-label' }, o.label),
|
||||
hasChild ? h('span', { class: 'ua-cascader-arrow', 'aria-hidden': 'true' }, '›') : null,
|
||||
);
|
||||
});
|
||||
return h('ul', { class: 'ua-cascader-col', role: 'group' }, ...rows) as HTMLElement;
|
||||
};
|
||||
|
||||
/*** 重绘所有列:列数随浏览深度增长 ***/
|
||||
const render = (): void => {
|
||||
const cols = columns();
|
||||
panels.replaceChildren(...cols.map((opts, d) => column(opts, d)));
|
||||
};
|
||||
|
||||
/*** 把焦点送到某列第 i 项(重建后 DOM 已换,必须等下一帧) ***/
|
||||
const focusCol = (depth: number, i = 0): void => {
|
||||
requestAnimationFrame(() => {
|
||||
const ul = panels.children[depth] as HTMLElement | undefined;
|
||||
if (!ul) return; // 卫语句:列不存在
|
||||
(ul.children[Math.min(i, ul.children.length - 1)] as HTMLElement | undefined)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// —— 触发器 ——
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-cascader-trigger',
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': () => String(open()),
|
||||
disabled: props.disabled,
|
||||
onClick: () => { if (!open()) path([...value()]); open(!open()); },
|
||||
},
|
||||
h('span', { class: () => `ua-cascader-value${value().length ? '' : ' is-empty'}` }, () => labelText() || props.placeholder || '请选择'),
|
||||
h('span', { class: 'ua-cascader-caret', 'aria-hidden': 'true' }, '▾'),
|
||||
);
|
||||
|
||||
const pop = h('div', { class: () => `ua-cascader-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, panels);
|
||||
const root = h('div', { class: () => `ua-cascader${open() ? ' ua-open' : ''}` }, trigger, pop);
|
||||
|
||||
// 面板键盘导航:↑↓ 同列移动、→ 下钻、← 回退、Enter 提交,Esc 交给弹层栈
|
||||
panels.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const btn = document.activeElement as HTMLElement | null;
|
||||
const ul = btn?.parentElement as HTMLElement | null;
|
||||
if (!ul?.classList.contains('ua-cascader-col') || !btn) return; // 卫语句:焦点不在选项上
|
||||
const siblings = Array.from(ul.children); // 列内项,HTMLCollection 不能直接展开
|
||||
const depth = Array.from(panels.children).indexOf(ul); // 当前列序号
|
||||
const at = siblings.indexOf(btn);
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = siblings[(at + (e.key === 'ArrowDown' ? 1 : -1) + siblings.length) % siblings.length] as HTMLElement;
|
||||
next?.focus(); // 列内环形移动
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); btn.click(); if (open()) focusCol(depth + 1); return; } // 下钻并聚焦新列首项
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); focusCol(Math.max(depth - 1, 0), at); return; } // 回退到上一列同序项
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); } // 与鼠标点击同语义
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false); // 点外部收起
|
||||
});
|
||||
effect(render); // 依赖 open / path / value,任一变化即重绘
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈:与弹窗/抽屉共用逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
effect(() => { if (open()) place(flip(trigger, pop, 'bottom')); }); // 空间不足自动翻转
|
||||
return root;
|
||||
}
|
||||
@@ -45,3 +45,4 @@ export { drawer } from './drawer.js';
|
||||
export { form, formItem } from './form.js';
|
||||
export { dataTable } from './dataTable.js';
|
||||
export { transfer } from './transfer.js';
|
||||
export { cascader } from './cascader.js';
|
||||
|
||||
@@ -495,4 +495,23 @@ export interface TransferProps {
|
||||
onChange?: (keys: string[]) => void; // 右侧集合变化回调(搬运后触发,传副本)
|
||||
}
|
||||
|
||||
/*** 级联选项节点 ***/
|
||||
export interface CascaderOption {
|
||||
value: string; // 节点值:路径由逐级 value 组成
|
||||
label: string; // 显示文案
|
||||
children?: CascaderOption[]; // 子级选项;缺省为叶子
|
||||
disabled?: boolean; // 禁用:不可点选、不可下钻
|
||||
}
|
||||
|
||||
/*** 级联选择入参 ***/
|
||||
export interface CascaderProps {
|
||||
options: CascaderOption[]; // 根级选项
|
||||
value?: Signal<string[]>; // 选中路径(逐级 value 数组);不传则内部维护
|
||||
placeholder?: string; // 未选时占位文案;缺省「请选择」
|
||||
separator?: string; // 路径分隔符;缺省 ' / '
|
||||
changeOnSelect?: boolean; // 允许停在中间层;缺省 false 必须选到叶子
|
||||
disabled?: boolean | Signal<boolean>; // 禁用触发器
|
||||
onChange?: (path: string[]) => void; // 选中回调(叶子或中间层,传副本)
|
||||
}
|
||||
|
||||
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)",
|
||||
"notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭",
|
||||
"result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态",
|
||||
"transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选"
|
||||
"transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选",
|
||||
"cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
@@ -1972,6 +1973,57 @@
|
||||
"disabled 条目不可勾选、不计入全选基数、也不会被搬运",
|
||||
"传入 targetKeys 信号即为受控用法,外部写入即可程序化搬运(如「一键全选」)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cascader",
|
||||
"title": "级联选择 Cascader",
|
||||
"demo": "sec-cascader",
|
||||
"props": [
|
||||
{
|
||||
"n": "options",
|
||||
"t": "CascaderOption[]",
|
||||
"r": 1,
|
||||
"d": "根级选项,children 递归出列,value 逐级拼成路径"
|
||||
},
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string[]>",
|
||||
"d": "选中路径信号;不传则内部维护,传了即外部受控"
|
||||
},
|
||||
{
|
||||
"n": "placeholder",
|
||||
"t": "string",
|
||||
"d": "未选时占位文案;缺省「请选择」"
|
||||
},
|
||||
{
|
||||
"n": "separator",
|
||||
"t": "string",
|
||||
"d": "路径分隔符;缺省「 / 」"
|
||||
},
|
||||
{
|
||||
"n": "changeOnSelect",
|
||||
"t": "boolean",
|
||||
"d": "允许停在中间层;缺省 false 必须选到叶子才提交"
|
||||
},
|
||||
{
|
||||
"n": "disabled",
|
||||
"t": "boolean | Signal<boolean>",
|
||||
"d": "禁用触发器;支持信号响应式禁用"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(path: string[]) => void",
|
||||
"d": "选中回调(叶子或中间层,传副本)"
|
||||
}
|
||||
],
|
||||
"example": "cascader({ value: areaPath, changeOnSelect: true, options: [{ value: 'east', label: '华东', children: [{ value: 'sh', label: '上海' }] }] })",
|
||||
"steps": [
|
||||
"传入嵌套 options,第一列是根级选项,悬停带 children 的项即展开下一列",
|
||||
"点击叶子提交;changeOnSelect 为 true 时点中间层也提交(适合「省/市」这类粗粒度就够用的场景)",
|
||||
"value 是路径数组(逐级 value),传信号即受控;触发器文案按 separator 拼接已提交路径",
|
||||
"键盘:↑↓ 同列移动、→ 下钻、← 回退、Enter/空格 提交、Esc 收起(与弹窗共用一个 Esc 栈)",
|
||||
"面板空间不足时自动向上翻转;disabled 选项不可点也不会展开下一列"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2546,3 +2546,128 @@ button.ua-step-dot:hover {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
.ua-cascader {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 220px;
|
||||
}
|
||||
.ua-cascader-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ua-space-1);
|
||||
width: 100%;
|
||||
padding: 8px var(--ua-space-2);
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-text);
|
||||
background: var(--ua-color-surface);
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.ua-cascader-trigger:hover {
|
||||
border-color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-cascader-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ua-cascader.ua-open .ua-cascader-trigger {
|
||||
border-color: var(--ua-color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 18%, transparent);
|
||||
}
|
||||
.ua-cascader-value.is-empty {
|
||||
color: var(--ua-color-text-muted);
|
||||
}
|
||||
.ua-cascader-caret {
|
||||
font-size: 10px;
|
||||
color: var(--ua-color-text-muted);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.ua-cascader.ua-open .ua-cascader-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ua-cascader-pop {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
display: none;
|
||||
margin-top: 6px;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-surface);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ua-cascader-pop.ua-open {
|
||||
display: block;
|
||||
animation: ua-cascader-in 0.15s ease both;
|
||||
}
|
||||
.ua-cascader-pop.is-flip {
|
||||
margin-top: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ua-cascader-panels {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.ua-cascader-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 132px;
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
padding: var(--ua-space-1);
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
border-right: 1px solid var(--ua-color-border);
|
||||
}
|
||||
.ua-cascader-col:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.ua-cascader-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ua-space-1);
|
||||
padding: 6px var(--ua-space-1);
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--ua-radius-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.ua-cascader-opt:hover:not(:disabled),
|
||||
.ua-cascader-opt.is-active {
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 10%, transparent);
|
||||
}
|
||||
.ua-cascader-opt.is-picked {
|
||||
color: var(--ua-color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ua-cascader-opt:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ua-cascader-opt:focus-visible {
|
||||
outline: 2px solid var(--ua-color-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.ua-cascader-arrow {
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
@keyframes ua-cascader-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ua-cascader-pop.ua-open { animation: none; }
|
||||
.ua-cascader-trigger,
|
||||
.ua-cascader-caret,
|
||||
.ua-cascader-opt { transition: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user