diff --git a/.ua/index.json b/.ua/index.json index 1e2b6de..bafa3f6 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -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", + "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)" ] }, { diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 4079898..cd03e62 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -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( diff --git a/llms-full.txt b/llms-full.txt index 8042ce0..e4230ae 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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 —— 禁用态:布尔直接置位,传信号则动态同步 - showValue(默认 true):boolean —— 是否右侧显示当前值;缺省 true - marks:{ value: number; label?: string }[] —— 刻度标注:按值定位在轨道下方,label 缺省显示数值;纯装饰不进读屏(数值由原生 input 播报) +- range(默认 false):boolean —— 双滑块区间模式:value 当下限、end 当上限,两拇指之间用强调色连成已选段 +- end:Signal —— 区间上限信号;仅 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 diff --git a/packages/skin/ui/src/slider.ts b/packages/skin/ui/src/slider.ts index 2059fa7..e05b488 100644 --- a/packages/skin/ui/src/slider.ts +++ b/packages/skin/ui/src/slider.ts @@ -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()}`), ); } diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 8ef8a09..998bf28 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -182,6 +182,9 @@ export interface SliderProps { disabled?: boolean | Signal; // 禁用态:布尔直接置位,传信号则动态同步 showValue?: boolean; // 是否右侧显示当前值;缺省 true marks?: { value: number; label?: string }[]; // 刻度标注:按值定位在轨道下方,label 缺省显示数值;纯装饰不进读屏 + range?: boolean; // 双滑块区间模式:value 是下限、end 是上限;缺省 false + end?: Signal; // 区间上限信号;仅 range 模式使用,不传则内部维护 + onRangeChange?: (low: number, high: number) => void; // 区间两端变更回调(已保证 low ≤ high) onChange?: (value: number) => void; // 拖动回调 } diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index cb8db13..da1fee5 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -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", + "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)" ] }, { @@ -3425,4 +3443,4 @@ } ] } -} +} \ No newline at end of file diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 082266d..97e168a 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -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 {