iter(g160): 滑块支持双滑块区间

This commit is contained in:
HIsheR
2026-10-26 16:03:55 +08:00
parent 084ffcd980
commit ef564fc72b
7 changed files with 133 additions and 33 deletions
+20 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "ui-arch-registry-index/0.1",
"generatedAt": "2026-10-26T08:02:08.033Z",
"generatedAt": "2026-10-26T08:03:40.841Z",
"repoRoot": ".",
"packages": [
{
@@ -573,6 +573,23 @@
"t": "{ value: number; label?: string }[]",
"d": "刻度标注:按值定位在轨道下方,label 缺省显示数值;纯装饰不进读屏(数值由原生 input 播报)"
},
{
"n": "range",
"t": "boolean",
"df": "false",
"d": "双滑块区间模式:value 当下限、end 当上限,两拇指之间用强调色连成已选段"
},
{
"n": "end",
"t": "Signal<number>",
"d": "区间上限信号;仅 range 模式使用,不传则组件内部维护"
},
{
"n": "onRangeChange",
"t": "(low: number, high: number) => void",
"k": 1,
"d": "区间两端变更回调,参数已保证 low ≤ high(越界那一端会被压平)"
},
{
"n": "onChange",
"t": "(value: number) => void",
@@ -587,7 +604,8 @@
"拖动或按 ←→ 键改变值:原生 range 自带键盘与触屏交互,无需额外处理",
"只想看拖动条不显示数字时传 showValue: false;需要自定义展示可自行读 signal",
"禁用场景传 disabled(布尔或信号),置灰且不可拖",
"需要在轨道下方标刻度(如 0 / 50 / 100 的档位含义)传 marks:[{ value, label }],按 min-max 换算百分比定位,容器宽度变化时自动跟随"
"需要在轨道下方标刻度(如 0 / 50 / 100 的档位含义)传 marks:[{ value, label }],按 min-max 换算百分比定位,容器宽度变化时自动跟随",
"要选数值区间就传 range: true 并给 end 信号:两条原生 range 叠在同一轨道,只有拇指可拖;拖下限越过上限会自动压平,回调走 onRangeChange(low, high)"
]
},
{
+4 -1
View File
@@ -200,6 +200,8 @@ const chipOn = { 全部: signal(true), 进行中: signal(false), 已完成: sign
// —— 滑块:音量与禁用态演示 ——
const volume = signal(40);
const priceLo = signal(200); // 区间滑块下限
const priceHi = signal(800); // 区间滑块上限
// —— 数字输入:购买数量演示,与禁用态实例共用同一信号 ——
const qty = signal(1);
@@ -417,13 +419,14 @@ const content = h(
section(
'sec-slider',
'滑块',
'slider:原生 range 承载交互(含键盘 ←→),value 信号双向绑定,右侧可显示当前值。',
'slider:原生 range 承载交互(含键盘 ←→),value 信号双向绑定,右侧可显示当前值;range 模式叠两条原生轨做双滑块区间。',
h(
'div',
{ class: 'row' },
slider({ value: volume, min: 0, max: 100, step: 5, onChange: (v) => toast.info(`音量 ${v}`) }),
slider({ value: volume, disabled: locked }),
slider({ value: volume, marks: [{ value: 0, label: '静音' }, { value: 50, label: '中' }, { value: 100, label: '最大' }], onChange: (v) => toast.info(`音量 ${v}`) }),
slider({ value: priceLo, end: priceHi, range: true, min: 0, max: 1000, step: 50, onRangeChange: (a, b) => toast.info(`价格 ${a} ~ ${b}`) }),
),
),
section(
+5 -1
View File
@@ -1,6 +1,6 @@
# @ui-arch 组件库 Agent 文档(无图形版)
> 自动生成于 2026-10-26T08:02:08.033Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 自动生成于 2026-10-26T08:03:40.841Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。
> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。
## 快速上手
@@ -197,6 +197,9 @@ const bio = signal(''); textarea({ value: bio, rows: 4, placeholder: '一句话
- disabled:boolean | Signal<boolean> —— 禁用态:布尔直接置位,传信号则动态同步
- showValue(默认 true):boolean —— 是否右侧显示当前值;缺省 true
- marks:{ value: number; label?: string }[] —— 刻度标注:按值定位在轨道下方,label 缺省显示数值;纯装饰不进读屏(数值由原生 input 播报)
- range(默认 false):boolean —— 双滑块区间模式:value 当下限、end 当上限,两拇指之间用强调色连成已选段
- end:Signal<number> —— 区间上限信号;仅 range 模式使用,不传则组件内部维护
- onRangeChange(回调):(low: number, high: number) => void —— 区间两端变更回调,参数已保证 low ≤ high(越界那一端会被压平)
- onChange(回调):(value: number) => void —— 拖动回调
示例:
@@ -213,6 +216,7 @@ const vol = signal(40); slider({ value: vol, min: 0, max: 100, step: 5, onChange
4. 只想看拖动条不显示数字时传 showValue: false;需要自定义展示可自行读 signal
5. 禁用场景传 disabled(布尔或信号),置灰且不可拖
6. 需要在轨道下方标刻度(如 0 / 50 / 100 的档位含义)传 marks:[{ value, label }],按 min-max 换算百分比定位,容器宽度变化时自动跟随
7. 要选数值区间就传 range: true 并给 end 信号:两条原生 range 叠在同一轨道,只有拇指可拖;拖下限越过上限会自动压平,回调走 onRangeChange(low, high)
现场演示:apps/docs/demo.html#sec-slider
+41 -27
View File
@@ -1,43 +1,57 @@
import { effect, h } from '@ui-arch/frame';
import { effect, h, signal } from '@ui-arch/frame';
import type { SliderProps } from './types.js';
/*** 滑块:原生 range 承载交互与键盘,value 信号双向绑定;marks 在轨道下方标出刻度文案 ***/
/*** 滑块:原生 range 承载交互与键盘,value 信号双向绑定;range 模式叠两条原生轨取双滑块(值域夹取保证 low ≤ high),marks 在轨道下方标出刻度文案 ***/
export function slider(props: SliderProps): HTMLElement {
const value = props.value; // 必需信号:组件不持有数值,一律委托调用方
const min = props.min ?? 0; // 与 input 的缺省保持一致,供刻度换算百分比
// —— 状态集中声明 ——
const value = props.value; // 必需信号:组件不持有数值,一律委托调用方
const min = props.min ?? 0; // 与 input 的缺省保持一致,供刻度换算百分比
const max = props.max ?? 100;
const input = h('input', {
type: 'range',
class: 'ua-slider-input',
min,
max,
step: props.step ?? 1,
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传
}) as HTMLInputElement;
const step = props.step ?? 1;
const high = props.end ?? signal(max); // 区间上限:不传则由组件内部维护
const disabled = props.disabled; // 布尔或信号禁用,h() 直接透传
const pct = (v: number): number => ((v - min) / (max - min || 1)) * 100; // 分母兜底,避免 min=max 时除零
input.addEventListener('input', () => {
const next = Number(input.value);
value(next); // 用户拖动写回信号
props.onChange?.(next);
});
effect(() => {
const next = String(value());
if (input.value !== next) input.value = next; // 外部赋值同步滑块位置(等值不重写,避免打断拖动)
});
/*** 建一条原生 range:区间模式靠 CSS 叠在同一格,只有拇指可命中 ***/
const track = (sig: typeof value, onInput: (n: number) => void): HTMLInputElement => {
const input = h('input', { type: 'range', class: 'ua-slider-input', min, max, step, disabled }) as HTMLInputElement;
input.addEventListener('input', () => onInput(Number(input.value)));
effect(() => {
const next = String(sig());
if (input.value !== next) input.value = next; // 外部赋值同步位置(等值不重写,避免打断拖动)
});
return input;
};
// 刻度:按值算百分比定位,纯装饰不进读屏(数值本身由原生 input 播报)
const marks = props.marks?.length
? h('div', { class: 'ua-slider-marks', 'aria-hidden': 'true' },
...props.marks.map((mark) => h('span', {
class: 'ua-slider-mark',
style: `left:${((mark.value - min) / (max - min || 1)) * 100}%`, // 分母兜底,避免 min=max 时除零
}, mark.label ?? String(mark.value))))
...props.marks.map((mark) => h('span', { class: 'ua-slider-mark', style: `left:${pct(mark.value)}%` }, mark.label ?? String(mark.value))))
: null;
if (!props.range) { // —— 单值:一条原生轨,行为与旧版完全一致 ——
const input = track(value, (next) => { value(next); props.onChange?.(next); });
return h(
'div',
{ class: 'ua-slider' },
h('div', { class: 'ua-slider-track' }, input, marks),
props.showValue === false ? null : h('span', { class: 'ua-slider-value' }, () => String(value())),
);
}
// —— 区间:两条原生轨叠放,rail 打底、seg 画出两拇指之间的已选段 ——
const rail = h('div', { class: 'ua-slider-rail', 'aria-hidden': 'true' });
const seg = h('div', { class: 'ua-slider-seg', 'aria-hidden': 'true' }) as HTMLElement;
const lowIn = track(value, (next) => { value(Math.min(next, high())); props.onRangeChange?.(value(), high()); }); // 下限越过上限就压平
const highIn = track(high, (next) => { high(Math.max(next, value())); props.onRangeChange?.(value(), high()); }); // 上限低于下限就压平
lowIn.classList.add('ua-slider-input-low'); // 供 CSS 区分上下层与拇指命中区
highIn.classList.add('ua-slider-input-high');
effect(() => { seg.style.left = `${pct(value())}%`; seg.style.width = `${pct(high()) - pct(value())}%`; }); // 已选段随两端重算
return h(
'div',
{ class: 'ua-slider' },
h('div', { class: 'ua-slider-track' }, input, marks), // 轨道包一层:刻度只跟滑块对齐,不影响右侧数值
props.showValue === false ? null : h('span', { class: 'ua-slider-value' }, () => String(value())),
h('div', { class: 'ua-slider-track' }, h('div', { class: 'ua-slider-range' }, rail, seg, lowIn, highIn), marks),
props.showValue === false ? null : h('span', { class: 'ua-slider-value' }, () => `${value()} ~ ${high()}`),
);
}
+3
View File
@@ -182,6 +182,9 @@ export interface SliderProps {
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
showValue?: boolean; // 是否右侧显示当前值;缺省 true
marks?: { value: number; label?: string }[]; // 刻度标注:按值定位在轨道下方,label 缺省显示数值;纯装饰不进读屏
range?: boolean; // 双滑块区间模式:value 是下限、end 是上限;缺省 false
end?: Signal<number>; // 区间上限信号;仅 range 模式使用,不传则内部维护
onRangeChange?: (low: number, high: number) => void; // 区间两端变更回调(已保证 low ≤ high)
onChange?: (value: number) => void; // 拖动回调
}
+19 -1
View File
@@ -444,6 +444,23 @@
"t": "{ value: number; label?: string }[]",
"d": "刻度标注:按值定位在轨道下方,label 缺省显示数值;纯装饰不进读屏(数值由原生 input 播报)"
},
{
"n": "range",
"t": "boolean",
"df": "false",
"d": "双滑块区间模式:value 当下限、end 当上限,两拇指之间用强调色连成已选段"
},
{
"n": "end",
"t": "Signal<number>",
"d": "区间上限信号;仅 range 模式使用,不传则组件内部维护"
},
{
"n": "onRangeChange",
"t": "(low: number, high: number) => void",
"k": 1,
"d": "区间两端变更回调,参数已保证 low ≤ high(越界那一端会被压平)"
},
{
"n": "onChange",
"t": "(value: number) => void",
@@ -458,7 +475,8 @@
"拖动或按 ←→ 键改变值:原生 range 自带键盘与触屏交互,无需额外处理",
"只想看拖动条不显示数字时传 showValue: false;需要自定义展示可自行读 signal",
"禁用场景传 disabled(布尔或信号),置灰且不可拖",
"需要在轨道下方标刻度(如 0 / 50 / 100 的档位含义)传 marks:[{ value, label }],按 min-max 换算百分比定位,容器宽度变化时自动跟随"
"需要在轨道下方标刻度(如 0 / 50 / 100 的档位含义)传 marks:[{ value, label }],按 min-max 换算百分比定位,容器宽度变化时自动跟随",
"要选数值区间就传 range: true 并给 end 信号:两条原生 range 叠在同一轨道,只有拇指可拖;拖下限越过上限会自动压平,回调走 onRangeChange(low, high)"
]
},
{
+40
View File
@@ -826,6 +826,46 @@ body.ua-col-resizing {
font-variant-numeric: tabular-nums;
text-align: right;
}
.ua-slider-range {
position: relative;
flex: 1;
display: flex;
align-items: center;
height: 20px;
}
.ua-slider-rail {
position: absolute;
left: 0;
right: 0;
height: 4px;
border-radius: var(--ua-radius-sm);
background: color-mix(in srgb, var(--ua-color-border) 70%, transparent);
}
.ua-slider-seg {
position: absolute;
height: 4px;
border-radius: var(--ua-radius-sm);
background: var(--ua-color-accent);
transition: left 0.15s, width 0.15s;
}
.ua-slider-range .ua-slider-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
background: none;
pointer-events: none; /* 轨道不吃事件,只有拇指可拖,避免上下两条互相遮挡 */
}
.ua-slider-range .ua-slider-input::-webkit-slider-thumb {
pointer-events: auto;
}
.ua-slider-range .ua-slider-input::-moz-range-thumb {
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.ua-slider-seg { transition: none; }
}
/* —— 多行输入 —— */
.ua-textarea {