iter(g08): 新增 segmented 分段控制器组件
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; // 切换回调(禁用项不触发)
|
||||
}
|
||||
|
||||
/*** 复选框入参 ***/
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄",
|
||||
"dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态;loading 为真时数据行换骨架行且筛选/翻页禁用,内核为 table-core",
|
||||
"image(props):图片,缩略图点击 / 回车开遮罩大图预览(Esc 或点击遮罩关闭),width 控制缩略宽",
|
||||
"timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点"
|
||||
"timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点",
|
||||
"segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
@@ -87,7 +88,8 @@
|
||||
"DrawerProps",
|
||||
"AvatarProps",
|
||||
"TimelineItem",
|
||||
"TimelineProps"
|
||||
"TimelineProps",
|
||||
"SegmentedProps"
|
||||
]
|
||||
},
|
||||
"usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')",
|
||||
@@ -1487,6 +1489,51 @@
|
||||
"需要轴线居右(如右侧栏时间流)时传 mode: 'right',条目与节点整体镜像",
|
||||
"content 为纯文本;需要按钮等交互内容时改用 accordion / card 组合"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "segmented",
|
||||
"title": "分段控制器 Segmented",
|
||||
"demo": "sec-segmented",
|
||||
"props": [
|
||||
{
|
||||
"n": "options",
|
||||
"t": "OptionItem[]",
|
||||
"r": 1,
|
||||
"d": "分段选项列表(互斥),每项含 value / label / disabled"
|
||||
},
|
||||
{
|
||||
"n": "value",
|
||||
"t": "Signal<string>",
|
||||
"r": 1,
|
||||
"d": "当前选中值信号,双向绑定(点击写回,外部赋值即同步滑块)"
|
||||
},
|
||||
{
|
||||
"n": "size",
|
||||
"t": "'sm' | 'md'",
|
||||
"d": "尺寸档位;缺省 md",
|
||||
"df": "md"
|
||||
},
|
||||
{
|
||||
"n": "disabled",
|
||||
"t": "boolean | Signal<boolean>",
|
||||
"d": "整组禁用:布尔直接置位,传信号则动态同步;单项禁用走 options[].disabled"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(value: string) => void",
|
||||
"k": 1,
|
||||
"d": "切换回调(禁用项与重复点击当前项都不触发)"
|
||||
}
|
||||
],
|
||||
"example": "segmented({ options: [{ value: 'day', label: '日' }, { value: 'week', label: '周' }], value: range })",
|
||||
"steps": [
|
||||
"和 tabs 的分工:tabs 是页签(下划线指示条 + 面板语义),segmented 是紧凑互斥切换(视图 / 单位 / 时间粒度)",
|
||||
"value 必须传信号:点击写回信号,外部改信号滑块自动平移,不要在回调里另存一份状态",
|
||||
"options[].disabled 置灰单项;disabled 传布尔或信号则整组失效(连滑块一起置灰)",
|
||||
"键盘:←→ 在可用项间环形移动并选中,Tab 只落在当前项上(漫游 tabindex)",
|
||||
"滑块几何由组件测量按钮位置得到,不要在外部改按钮宽度 / 内边距,否则需自行重渲染",
|
||||
"需要带图标或更复杂布局的分段,改用 radioGroup + 自定义样式"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1754,3 +1754,65 @@ button.ua-step-dot:hover {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— 分段控制器:胶囊容器 + 滑动滑块,选中态低视觉比重 —— */
|
||||
.ua-segmented {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ua-color-text) 4%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ua-seg-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--ua-color-bg);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--ua-color-text) 12%, transparent);
|
||||
transition: transform 0.18s ease, width 0.18s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ua-seg-btn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 5px 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ua-color-text-muted);
|
||||
font-size: var(--ua-font-size-md);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ua-seg-btn:hover:not(:disabled) {
|
||||
color: var(--ua-color-text);
|
||||
}
|
||||
.ua-seg-btn:focus-visible {
|
||||
outline: 2px solid var(--ua-color-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.ua-seg-btn.is-active {
|
||||
color: var(--ua-color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ua-seg-btn:disabled {
|
||||
color: color-mix(in srgb, var(--ua-color-text-muted) 45%, transparent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ua-segmented-sm .ua-seg-btn {
|
||||
padding: 3px 10px;
|
||||
font-size: var(--ua-font-size-sm);
|
||||
}
|
||||
.ua-segmented.is-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ua-seg-thumb,
|
||||
.ua-seg-btn {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user