iter(g08): 新增 segmented 分段控制器组件

This commit is contained in:
HIsheR
2026-10-26 10:20:45 +08:00
parent 7cf1f2877a
commit 0e385cdb3a
12 changed files with 302 additions and 14 deletions
+1
View File
@@ -25,6 +25,7 @@ export { pagination } from './pagination.js';
export { spinner } from './spinner.js';
export { progress } from './progress.js';
export { tabs } from './tabs.js';
export { segmented } from './segmented.js';
export { stepper } from './stepper.js';
export { rate } from './rate.js';
export { empty } from './empty.js';
+63
View File
@@ -0,0 +1,63 @@
import { effect, h } from '@ui-arch/frame';
import type { SegmentedProps } from './types.js';
/*** 分段控制器:胶囊分段条 + 滑动滑块,value 信号双向绑定,←→/Home/End 环形导航,整组或单项可禁用 ***/
export function segmented(props: SegmentedProps): HTMLElement {
// —— 状态集中声明 ——
const buttons: HTMLButtonElement[] = []; // 分段按钮,顺序与 options 一致
const thumb = h('span', { class: 'ua-seg-thumb', 'aria-hidden': 'true' }); // 滑块:仅装饰,不参与可访问性树
const root = h('div', { class: `ua-segmented ua-segmented-${props.size ?? 'md'}`, role: 'radiogroup' }, thumb);
/*** 整组禁用判定:布尔直接取,信号在此读取以纳入 effect 依赖 ***/
const groupOff = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true);
/*** 同步选中态、禁用态与滑块几何(挂载前量不到宽度,由 rAF 二次测量兜底) ***/
const sync = (): void => {
const off = groupOff();
const box = root.getBoundingClientRect();
root.classList.toggle('is-disabled', off);
for (const btn of buttons) {
const on = btn.dataset.value === props.value();
const dead = off || btn.dataset.dead === '1'; // 整组禁用或单项禁用
btn.classList.toggle('is-active', on);
btn.setAttribute('aria-checked', String(on));
btn.setAttribute('aria-disabled', String(dead));
btn.disabled = dead;
btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅选中项可 Tab 聚焦
if (!on) continue; // 滑块只跟选中项走
const rect = btn.getBoundingClientRect();
thumb.style.width = `${rect.width}px`;
thumb.style.transform = `translateX(${rect.left - box.left - root.clientLeft}px)`; // 减去容器边框,滑块与按钮严丝合缝
}
};
for (const opt of props.options) {
const btn = h('button', { type: 'button', class: 'ua-seg-btn', role: 'radio' }, opt.label) as HTMLButtonElement;
btn.dataset.value = opt.value;
if (opt.disabled) btn.dataset.dead = '1'; // 单项禁用标记,交给 sync 统一置 disabled
btn.addEventListener('click', () => {
if (btn.disabled) return; // 卫语句:禁用项不响应
props.value(opt.value); // 用户切换写回信号
props.onChange?.(opt.value);
});
buttons.push(btn);
root.append(btn);
}
root.addEventListener('keydown', (e: KeyboardEvent) => {
const map: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }; // 左右移动,首尾环形
const steer = map[e.key];
if (!steer) return; // 卫语句:非方向键放行
const list = buttons.filter((b) => !b.disabled);
const i = list.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return; // 卫语句:焦点不在分段项上
e.preventDefault();
const next = list[(i + steer + list.length) % list.length]; // 环形取模
next.focus();
next.click();
});
effect(sync);
requestAnimationFrame(sync); // 挂载后再量一次布局,否则滑块宽度为 0
return root;
}
+11 -2
View File
@@ -59,11 +59,20 @@ export interface DataTableProps<T> {
selected?: Signal<RowKey[]>; // 受控选中行信号:与内核选择双向同步,供批量操作读取
}
/*** 选项条目:select / radioGroup / tabs 共用的选项形状 ***/
/*** 选项条目:select / radioGroup / tabs / segmented 共用的选项形状 ***/
export interface OptionItem {
value: string; // 选项值(提交/匹配用)
label: string; // 选项文案(展示用)
disabled?: boolean; // 仅 tabs 消费:禁用该页签
disabled?: boolean; // tabs / segmented 消费:禁用该选项
}
/*** 分段控制器入参 ***/
export interface SegmentedProps {
options: OptionItem[]; // 分段选项(互斥)
value: Signal<string>; // 当前选中值信号,双向绑定(点击写回、外部赋值同步)
size?: 'sm' | 'md'; // 尺寸档位;缺省 md
disabled?: boolean | Signal<boolean>; // 整组禁用:布尔直接置位,传信号则动态同步
onChange?: (value: string) => void; // 切换回调(禁用项不触发)
}
/*** 复选框入参 ***/