iter(g48): 新增 splitter 可拖拽分栏容器

This commit is contained in:
HIsheR
2026-10-26 12:11:44 +08:00
parent 5c10eb2a5f
commit d7bfabb3ab
12 changed files with 301 additions and 10 deletions
+1
View File
@@ -46,3 +46,4 @@ export { form, formItem } from './form.js';
export { dataTable } from './dataTable.js';
export { transfer } from './transfer.js';
export { cascader } from './cascader.js';
export { splitter } from './splitter.js';
+98
View File
@@ -0,0 +1,98 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import type { SplitterProps } from './types.js';
/*** 均分百分比:总数为 n 时每栏等分,末栏吃掉舍入余量保证和为 100 ***/
function even(n: number): number[] {
const each = Number((100 / n).toFixed(2));
const out = Array.from({ length: n }, () => each);
out[n - 1] = Number((100 - each * (n - 1)).toFixed(2));
return out;
}
/*** 分栏容器:可拖拽分隔条调整比例,sizes 信号双向绑定,分隔条可聚焦并用方向键微调、双击复位 ***/
export function splitter(props: SplitterProps): HTMLElement {
// —— 状态集中声明 ——
const dir = props.direction ?? 'horizontal'; // 分割方向
const min = Math.max(props.min ?? 10, 0); // 每栏最小百分比,负值归零
const sizes: Signal<number[]> = props.sizes ?? signal<number[]>(even(props.panes.length)); // 各栏比例
const bars: HTMLElement[] = []; // 分隔条,索引 i 位于第 i / i+1 栏之间
const cells: HTMLElement[] = []; // 栏容器,与 panes 一一对应
const isH = dir === 'horizontal'; // 横向:改 flex-basis(宽度)
/*** 同步几何:只写 flex-basis,避免触发重排风暴 ***/
const sync = (): void => {
const pct = sizes();
cells.forEach((cell, i) => { cell.style.flexBasis = `${pct[i] ?? 0}%`; });
bars.forEach((bar, i) => { bar.setAttribute('aria-valuenow', String(Math.round(pct[i] ?? 0))); }); // 读屏播报当前分界位置
};
/*** 成对调整:把 i 与 i+1 两栏按 delta 对调,各自不低于 min ***/
const shift = (i: number, delta: number): void => {
const cur = [...sizes()];
const pair = (cur[i] ?? 0) + (cur[i + 1] ?? 0); // 两栏总量恒定
const next = Math.min(Math.max((cur[i] ?? 0) + delta, min), pair - min); // 下限 min、上限 pair-min
if (next === cur[i]) return; // 卫语句:贴边不动
cur[i] = Number(next.toFixed(2));
cur[i + 1] = Number((pair - next).toFixed(2));
sizes(cur);
props.onChange?.([...cur]);
};
/*** 拖动:按下分隔条后按指针位移折算百分比,松手即解绑 ***/
const startDrag = (i: number, down: PointerEvent): void => {
const bar = down.currentTarget as HTMLElement;
const box = root.getBoundingClientRect();
const total = (isH ? box.width : box.height) || 1; // 容器主轴尺寸,防 0 除
const from = isH ? down.clientX : down.clientY; // 按下时指针主轴坐标
const startPct = sizes()[i] ?? 0; // 起拖比例,位移全部相对它计算
bar.setPointerCapture(down.pointerId); // 捕获指针:拖出分隔条也不丢事件
bar.classList.add('is-dragging');
const onMove = (e: PointerEvent): void => {
const moved = (((isH ? e.clientX : e.clientY) - from) / total) * 100; // 位移折算成百分比
shift(i, Number((startPct + moved - (sizes()[i] ?? 0)).toFixed(2))); // 增量驱动,夹逼交给 shift
};
const onUp = (): void => {
bar.classList.remove('is-dragging');
bar.removeEventListener('pointermove', onMove);
bar.removeEventListener('pointerup', onUp);
bar.removeEventListener('pointercancel', onUp);
};
bar.addEventListener('pointermove', onMove);
bar.addEventListener('pointerup', onUp);
bar.addEventListener('pointercancel', onUp);
};
const root = h('div', { class: `ua-splitter ua-splitter-${dir}` }) as HTMLElement;
props.panes.forEach((pane, i) => {
const cell = h('div', { class: 'ua-splitter-pane' }, pane) as HTMLElement; // pane 可为节点或纯文本
cells.push(cell);
root.append(cell);
if (i === props.panes.length - 1) return; // 最后一栏后面不再放分隔条
const bar = h('div', {
class: 'ua-splitter-bar',
role: 'separator',
tabindex: '0',
'aria-orientation': isH ? 'vertical' : 'horizontal', // 左右分栏时分隔条是竖向的
'aria-label': `调整第 ${i + 1} 栏宽度`, // 读屏可辨识
'aria-valuemin': String(min),
'aria-valuemax': String(100 - min),
onpointerdown: (e: PointerEvent) => { e.preventDefault(); startDrag(i, e); },
ondblclick: () => { sizes(even(props.panes.length)); props.onChange?.([...even(props.panes.length)]); }, // 双击复位均分
}) as HTMLElement;
bar.addEventListener('keydown', (e: KeyboardEvent) => {
const step = e.shiftKey ? 10 : 2; // Shift 加速
const back = isH ? 'ArrowLeft' : 'ArrowUp';
const fwd = isH ? 'ArrowRight' : 'ArrowDown';
if (e.key !== back && e.key !== fwd) return; // 卫语句:非方向键放行
e.preventDefault();
shift(i, e.key === back ? -step : step);
});
bars.push(bar);
root.append(bar);
});
effect(sync);
return root;
}
+9
View File
@@ -514,4 +514,13 @@ export interface CascaderProps {
onChange?: (path: string[]) => void; // 选中回调(叶子或中间层,传副本)
}
/*** 分栏容器入参 ***/
export interface SplitterProps {
panes: unknown[]; // 各栏内容,顺序即排列顺序
direction?: 'horizontal' | 'vertical'; // 分割方向;缺省 horizontal(左右)
sizes?: Signal<number[]>; // 各栏百分比(和为 100);不传则内部均分
min?: number; // 每栏最小百分比;缺省 10
onChange?: (sizes: number[]) => void; // 拖拽/键盘调整后回调(传副本)
}
export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方
+43 -1
View File
@@ -60,7 +60,8 @@
"notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭",
"result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态",
"transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选",
"cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作"
"cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作",
"splitter(props):可拖拽分栏容器,分隔条支持指针拖拽 / 方向键微调 / 双击复位,sizes 为百分比数组信号"
],
"keyTypes": [
"ButtonProps",
@@ -2024,6 +2025,47 @@
"键盘:↑↓ 同列移动、→ 下钻、← 回退、Enter/空格 提交、Esc 收起(与弹窗共用一个 Esc 栈)",
"面板空间不足时自动向上翻转;disabled 选项不可点也不会展开下一列"
]
},
{
"id": "splitter",
"title": "可拖拽分栏 Splitter",
"demo": "sec-splitter",
"props": [
{
"n": "panes",
"t": "unknown[]",
"r": 1,
"d": "各栏内容(节点或纯文本),顺序即排列顺序"
},
{
"n": "direction",
"t": "'horizontal' | 'vertical'",
"d": "分割方向;缺省 horizontal(左右)"
},
{
"n": "sizes",
"t": "Signal<number[]>",
"d": "各栏百分比数组(和为 100);不传则内部均分"
},
{
"n": "min",
"t": "number",
"d": "每栏最小百分比;缺省 10,拖到边界即停"
},
{
"n": "onChange",
"t": "(sizes: number[]) => void",
"d": "拖拽 / 键盘调整后回调(传副本)"
}
],
"example": "splitter({ sizes: ratio, min: 15, panes: [leftPanel, rightPanel], onChange: (s) => ratio([...s]) })",
"steps": [
"panes 长度决定栏数与分隔条数(n 栏对应 n-1 条),栏宽由 sizes 百分比驱动",
"指针按住分隔条拖动即可改比例,拖动过程两栏之和恒定、各自不低于 min",
"分隔条可 Tab 聚焦,← → / ↑ ↓ 每次调 2%,按住 Shift 调 10%;双击复位为均分",
"direction 传 vertical 变为上下分栏,键盘方向键与光标同步切换",
"传入 sizes 信号即受控,外部写入可做「预设布局」切换(如 1:1 / 2:1)"
]
}
]
}
+50
View File
@@ -2671,3 +2671,53 @@ button.ua-step-dot:hover {
.ua-cascader-caret,
.ua-cascader-opt { transition: none; }
}
.ua-splitter {
display: flex;
width: 100%;
min-height: 160px;
border: 1px solid var(--ua-color-border);
border-radius: var(--ua-radius-md);
background: var(--ua-color-surface);
overflow: hidden;
}
.ua-splitter-vertical {
flex-direction: column;
}
.ua-splitter-pane {
flex: 0 0 auto;
min-width: 0;
min-height: 0;
padding: var(--ua-space-2);
overflow: auto;
}
.ua-splitter-bar {
position: relative;
flex: 0 0 6px;
background: var(--ua-color-border);
cursor: col-resize;
transition: background 0.15s ease;
touch-action: none;
}
.ua-splitter-vertical > .ua-splitter-bar {
cursor: row-resize;
}
.ua-splitter-bar::after {
content: '';
position: absolute;
inset: -4px;
/* 扩大命中区:视觉 6px,实际可抓 14px */
}
.ua-splitter-bar:hover,
.ua-splitter-bar.is-dragging,
.ua-splitter-bar:focus-visible {
background: var(--ua-color-accent);
}
.ua-splitter-bar:focus-visible {
outline: 2px solid var(--ua-color-accent);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
.ua-splitter-bar {
transition: none;
}
}