@marianmeres/stuic 3.172.0 → 3.174.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +19 -4
- package/API.md +56 -2
- package/README.md +21 -5
- package/dist/actions/resizable-width.svelte.d.ts +14 -28
- package/dist/actions/resizable-width.svelte.js +16 -171
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/resizable.d.ts +113 -0
- package/dist/attachments/resizable.fixture.svelte +53 -0
- package/dist/attachments/resizable.fixture.svelte.d.ts +10 -0
- package/dist/attachments/resizable.js +295 -0
- package/dist/components/Button/README.md +11 -10
- package/dist/components/ButtonGroupRadio/README.md +52 -23
- package/dist/components/ButtonGroupRadio/index.css +6 -2
- package/dist/components/PricingTable/PricingTable.svelte +8 -14
- package/dist/components/PricingTable/README.md +29 -6
- package/dist/components/PricingTable/index.css +54 -0
- package/dist/components/RangeSlider/README.md +291 -0
- package/dist/components/RangeSlider/RangeSlider.svelte +763 -0
- package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +130 -0
- package/dist/components/RangeSlider/i18n-sk.d.ts +17 -0
- package/dist/components/RangeSlider/i18n-sk.js +19 -0
- package/dist/components/RangeSlider/i18n.d.ts +33 -0
- package/dist/components/RangeSlider/i18n.js +41 -0
- package/dist/components/RangeSlider/index.css +430 -0
- package/dist/components/RangeSlider/index.d.ts +3 -0
- package/dist/components/RangeSlider/index.js +3 -0
- package/dist/components/Rating/README.md +206 -0
- package/dist/components/Rating/Rating.svelte +355 -0
- package/dist/components/Rating/Rating.svelte.d.ts +82 -0
- package/dist/components/Rating/i18n-sk.d.ts +17 -0
- package/dist/components/Rating/i18n-sk.js +21 -0
- package/dist/components/Rating/i18n.d.ts +34 -0
- package/dist/components/Rating/i18n.js +42 -0
- package/dist/components/Rating/index.css +170 -0
- package/dist/components/Rating/index.d.ts +3 -0
- package/dist/components/Rating/index.js +3 -0
- package/dist/components/SplitPane/README.md +169 -0
- package/dist/components/SplitPane/SplitPane.svelte +202 -0
- package/dist/components/SplitPane/SplitPane.svelte.d.ts +67 -0
- package/dist/components/SplitPane/i18n-sk.d.ts +17 -0
- package/dist/components/SplitPane/i18n-sk.js +18 -0
- package/dist/components/SplitPane/i18n.d.ts +31 -0
- package/dist/components/SplitPane/i18n.js +39 -0
- package/dist/components/SplitPane/index.css +153 -0
- package/dist/components/SplitPane/index.d.ts +3 -0
- package/dist/components/SplitPane/index.js +3 -0
- package/dist/components/WithSidePanel/README.md +19 -16
- package/dist/icons/index.d.ts +2 -0
- package/dist/icons/index.js +3 -0
- package/dist/index.css +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/docs/{maybe-todo.md → _archive/maybe-todo.md} +18 -7
- package/docs/architecture.md +1 -1
- package/docs/conventions.md +27 -7
- package/docs/domains/actions.md +18 -18
- package/docs/domains/attachments.md +72 -9
- package/docs/domains/components.md +175 -32
- package/docs/domains/theming.md +57 -13
- package/package.json +5 -5
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
RangeSlider — the dual-thumb sibling of `Slider`: two values (`start` ≤ `end`) on
|
|
3
|
+
one track, the fill spanning the selected range. Price filters, "between" queries,
|
|
4
|
+
min/max limits.
|
|
5
|
+
|
|
6
|
+
Same construction as Slider: the root owns the pointer machinery (nearest thumb
|
|
7
|
+
jumps to a track press, grabbing a thumb drags it without a jump, thumbs never
|
|
8
|
+
cross), while two visually hidden native `<input type="range">` — one per thumb,
|
|
9
|
+
each covering its half of the track — provide keyboard interaction, the slider
|
|
10
|
+
roles for assistive tech, and form participation (`nameStart` / `nameEnd`).
|
|
11
|
+
-->
|
|
12
|
+
<script lang="ts" module>
|
|
13
|
+
import type { Snippet } from "svelte";
|
|
14
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
15
|
+
import type {
|
|
16
|
+
ValidateOptions,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
} from "../../actions/validate.svelte.js";
|
|
19
|
+
import type { TranslateFn } from "../../types.js";
|
|
20
|
+
|
|
21
|
+
export type RangeSliderIntent =
|
|
22
|
+
"primary" | "accent" | "success" | "warning" | "destructive";
|
|
23
|
+
|
|
24
|
+
export type RangeSliderOrientation = "horizontal" | "vertical";
|
|
25
|
+
|
|
26
|
+
/** One of the two thumbs: `start` carries the lower value, `end` the upper one. */
|
|
27
|
+
export type RangeSliderThumb = "start" | "end";
|
|
28
|
+
|
|
29
|
+
/** The value pair — payload of `oninput` / `onchange` and of `validate.customValidator`. */
|
|
30
|
+
export interface RangeSliderValue {
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Context passed to the `thumb` and `valueLabel` snippets (rendered once per thumb). */
|
|
36
|
+
export interface RangeSliderRenderCtx {
|
|
37
|
+
/** The thumb this render is for */
|
|
38
|
+
thumb: RangeSliderThumb;
|
|
39
|
+
/** This thumb's value */
|
|
40
|
+
value: number;
|
|
41
|
+
/** Normalized position of this thumb, 0..1 */
|
|
42
|
+
ratio: number;
|
|
43
|
+
/** Normalized position of this thumb, 0..100 */
|
|
44
|
+
percent: number;
|
|
45
|
+
/** Whether this thumb is being dragged */
|
|
46
|
+
dragging: boolean;
|
|
47
|
+
/** Current lower value */
|
|
48
|
+
start: number;
|
|
49
|
+
/** Current upper value */
|
|
50
|
+
end: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Props extends Omit<
|
|
54
|
+
HTMLAttributes<HTMLDivElement>,
|
|
55
|
+
| "children"
|
|
56
|
+
| "oninput"
|
|
57
|
+
| "onchange"
|
|
58
|
+
// Reserved by the slider's own drag machinery (compile-time guard against
|
|
59
|
+
// silently clobbering them via the rest spread).
|
|
60
|
+
| "onpointerdown"
|
|
61
|
+
| "onpointermove"
|
|
62
|
+
| "onpointerup"
|
|
63
|
+
| "onpointercancel"
|
|
64
|
+
> {
|
|
65
|
+
/**
|
|
66
|
+
* Lower value (bindable). Defaults to `min`. Non-finite, out-of-range and
|
|
67
|
+
* off-step-grid writes are normalized back into the binding, a reversed pair
|
|
68
|
+
* is reordered and `minRange` is enforced — so the bound values always equal
|
|
69
|
+
* what is rendered and submitted.
|
|
70
|
+
*/
|
|
71
|
+
start?: number;
|
|
72
|
+
/** Upper value (bindable). Defaults to `max` (or the last step-grid point below it). */
|
|
73
|
+
end?: number;
|
|
74
|
+
min?: number;
|
|
75
|
+
max?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Snap increment. Use "any" for continuous (no snapping). Non-positive
|
|
78
|
+
* numbers are treated as "any".
|
|
79
|
+
*/
|
|
80
|
+
step?: number | "any";
|
|
81
|
+
/**
|
|
82
|
+
* Minimum distance between the two values — the thumbs can never get closer
|
|
83
|
+
* than this. Rounded up onto the step grid and capped at the reachable span;
|
|
84
|
+
* `0` (default) lets the thumbs coincide.
|
|
85
|
+
*/
|
|
86
|
+
minRange?: number;
|
|
87
|
+
orientation?: RangeSliderOrientation;
|
|
88
|
+
/** Cross-axis thickness preset (main-axis length via `--stuic-range-slider-length` or class) */
|
|
89
|
+
size?: "sm" | "md" | "lg" | string;
|
|
90
|
+
/** Semantic color intent (colors the fill) */
|
|
91
|
+
intent?: RangeSliderIntent;
|
|
92
|
+
disabled?: boolean;
|
|
93
|
+
/** Accessible name of the whole control (the root is a labelled `group`) */
|
|
94
|
+
label?: string;
|
|
95
|
+
/** Accessible name of the start (lower) thumb. Defaults to `t("minimum")` — "Minimum". */
|
|
96
|
+
labelStart?: string;
|
|
97
|
+
/** Accessible name of the end (upper) thumb. Defaults to `t("maximum")` — "Maximum". */
|
|
98
|
+
labelEnd?: string;
|
|
99
|
+
/** Form field name of the hidden range input carrying `start` */
|
|
100
|
+
nameStart?: string;
|
|
101
|
+
/** Form field name of the hidden range input carrying `end` */
|
|
102
|
+
nameEnd?: string;
|
|
103
|
+
/** Skip all default styling, use only custom classes */
|
|
104
|
+
unstyled?: boolean;
|
|
105
|
+
class?: string;
|
|
106
|
+
/** Classes for the track (pill background) element */
|
|
107
|
+
trackClass?: string;
|
|
108
|
+
/** Classes for the fill (selected range) element */
|
|
109
|
+
fillClass?: string;
|
|
110
|
+
/** Classes for both thumb elements */
|
|
111
|
+
thumbClass?: string;
|
|
112
|
+
/** Classes for each tick mark element */
|
|
113
|
+
tickClass?: string;
|
|
114
|
+
/** Classes for both floating value label wrappers */
|
|
115
|
+
valueClass?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Thumb rendering: `true` (default) renders two empty thumbs, `false` hides
|
|
118
|
+
* them (fill-only look), a snippet renders custom content inside each thumb
|
|
119
|
+
* (its context says which one).
|
|
120
|
+
*/
|
|
121
|
+
thumb?: boolean | Snippet<[RangeSliderRenderCtx]>;
|
|
122
|
+
/**
|
|
123
|
+
* Round the fill's edges (instead of flat cuts), giving the "pill inside a
|
|
124
|
+
* pill" look.
|
|
125
|
+
*/
|
|
126
|
+
fillRounded?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Tick marks along the track: `true` renders one at every `step` (requires a
|
|
129
|
+
* positive numeric step; auto ticks are skipped above 101 — pass an explicit
|
|
130
|
+
* array instead), an array renders them at the given (in-range) values.
|
|
131
|
+
*/
|
|
132
|
+
ticks?: boolean | number[];
|
|
133
|
+
/** Floating value label per thumb (tracks the thumb along the track, outside it) */
|
|
134
|
+
valueLabel?: Snippet<[RangeSliderRenderCtx]>;
|
|
135
|
+
/** Bindable root element reference */
|
|
136
|
+
el?: HTMLDivElement;
|
|
137
|
+
/** Bindable reference of the hidden range input carrying `start` */
|
|
138
|
+
inputStartEl?: HTMLInputElement;
|
|
139
|
+
/** Bindable reference of the hidden range input carrying `end` */
|
|
140
|
+
inputEndEl?: HTMLInputElement;
|
|
141
|
+
/** Fires on every actual value change (dragging, keyboard), with the thumb that moved */
|
|
142
|
+
oninput?: (value: RangeSliderValue, thumb: RangeSliderThumb) => void;
|
|
143
|
+
/** Fires when a changed value is committed (drag released, keyboard), with the thumb that moved */
|
|
144
|
+
onchange?: (value: RangeSliderValue, thumb: RangeSliderThumb) => void;
|
|
145
|
+
/**
|
|
146
|
+
* Validation (the stuic `validate` action, attached to the start input and
|
|
147
|
+
* re-run on every commit of either thumb). NOTE: `customValidator` receives
|
|
148
|
+
* the `{ start, end }` pair as its value, not a DOM string.
|
|
149
|
+
*/
|
|
150
|
+
validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
|
|
151
|
+
setValidationResult?: (res: ValidationResult) => void;
|
|
152
|
+
/** i18n of the default thumb names (see `createRangeSliderT`) */
|
|
153
|
+
t?: TranslateFn;
|
|
154
|
+
}
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<script lang="ts">
|
|
158
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
159
|
+
import { validate as validateAction } from "../../actions/validate.svelte.js";
|
|
160
|
+
import { t_default } from "./i18n.js";
|
|
161
|
+
|
|
162
|
+
let {
|
|
163
|
+
start = $bindable(),
|
|
164
|
+
end = $bindable(),
|
|
165
|
+
min = 0,
|
|
166
|
+
max = 100,
|
|
167
|
+
step = 1,
|
|
168
|
+
minRange = 0,
|
|
169
|
+
orientation = "horizontal",
|
|
170
|
+
size = "md",
|
|
171
|
+
intent,
|
|
172
|
+
disabled,
|
|
173
|
+
label,
|
|
174
|
+
labelStart,
|
|
175
|
+
labelEnd,
|
|
176
|
+
nameStart,
|
|
177
|
+
nameEnd,
|
|
178
|
+
unstyled = false,
|
|
179
|
+
class: classProp,
|
|
180
|
+
trackClass,
|
|
181
|
+
fillClass,
|
|
182
|
+
thumbClass,
|
|
183
|
+
tickClass,
|
|
184
|
+
valueClass,
|
|
185
|
+
thumb = true,
|
|
186
|
+
fillRounded = false,
|
|
187
|
+
ticks,
|
|
188
|
+
valueLabel,
|
|
189
|
+
el = $bindable(),
|
|
190
|
+
inputStartEl = $bindable(),
|
|
191
|
+
inputEndEl = $bindable(),
|
|
192
|
+
oninput,
|
|
193
|
+
onchange,
|
|
194
|
+
// Renamed local binding to avoid collision with `export function validate()` below.
|
|
195
|
+
validate: validateProp,
|
|
196
|
+
setValidationResult,
|
|
197
|
+
t = t_default,
|
|
198
|
+
...rest
|
|
199
|
+
}: Props = $props();
|
|
200
|
+
|
|
201
|
+
const MAX_AUTO_TICKS = 100;
|
|
202
|
+
const EPS = 1e-9;
|
|
203
|
+
|
|
204
|
+
// The thumb being dragged (null between drags — and during the brief "which
|
|
205
|
+
// thumb?" limbo of a press on two stacked thumbs, which the first move's
|
|
206
|
+
// direction resolves).
|
|
207
|
+
let _dragThumb = $state<RangeSliderThumb | null>(null);
|
|
208
|
+
let _pending = false;
|
|
209
|
+
// Active press pointer (ignore other concurrent pointers), where it landed
|
|
210
|
+
// (main-axis px from the value=min end) and whether that was on a thumb.
|
|
211
|
+
let _pointerId: number | null = null;
|
|
212
|
+
let _pressPos = 0;
|
|
213
|
+
let _pressOnStart = false;
|
|
214
|
+
let _pressOnEnd = false;
|
|
215
|
+
// Main-axis px offset between the grab point and the dragged thumb's center,
|
|
216
|
+
// so grabbing a thumb off-center does not jump the value.
|
|
217
|
+
let _grabOffset = 0;
|
|
218
|
+
// Value at drag start — commit (change) fires only if the drag changed it.
|
|
219
|
+
let _dragStartValue = 0;
|
|
220
|
+
// The thumb last dragged or focused: rendered above the other one.
|
|
221
|
+
let _activeThumb = $state<RangeSliderThumb | null>(null);
|
|
222
|
+
|
|
223
|
+
// Rendered thumbs reserve their footprint at both ends of the track: the values
|
|
224
|
+
// map onto the thumb-center travel, so the fill can never shrink below one
|
|
225
|
+
// thumb (coinciding thumbs leave exactly a thumb-sized nub). No thumbs maps
|
|
226
|
+
// linearly across the whole track and lets the fill collapse to zero.
|
|
227
|
+
let _thumbReserved = $derived(thumb !== false);
|
|
228
|
+
|
|
229
|
+
// Bounds must be finite before anything else: a NaN bound would make every
|
|
230
|
+
// normalization write NaN, and NaN !== NaN would re-trigger the effect below
|
|
231
|
+
// forever (Svelte sources compare with ===) — an app-killing update-depth error.
|
|
232
|
+
let _rawMin = $derived(Number.isFinite(min) ? min : 0);
|
|
233
|
+
let _rawMax = $derived(Number.isFinite(max) ? max : 100);
|
|
234
|
+
let _min = $derived(Math.min(_rawMin, _rawMax));
|
|
235
|
+
let _max = $derived(Math.max(_rawMin, _rawMax));
|
|
236
|
+
let _span = $derived(_max - _min);
|
|
237
|
+
// The hidden inputs must never receive an invalid step (the browser would fall
|
|
238
|
+
// back to step=1 and re-sanitize values the JS side left continuous).
|
|
239
|
+
let _stepAttr = $derived(typeof step === "number" && step > 0 ? step : "any");
|
|
240
|
+
/** Largest reachable value: `max`, or the last step-grid point below it. */
|
|
241
|
+
let _ceil = $derived(_snap(_max));
|
|
242
|
+
/** `minRange` rounded up onto the step grid and capped at the reachable span. */
|
|
243
|
+
let _gap = $derived.by(() => {
|
|
244
|
+
if (!(typeof minRange === "number" && Number.isFinite(minRange) && minRange > 0)) {
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
let g = minRange;
|
|
248
|
+
if (typeof step === "number" && step > 0) g = Math.ceil(g / step - EPS) * step;
|
|
249
|
+
return _trim(Math.min(g, _ceil - _min));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Normalize the bindings: undefined/non-finite become `min` / `max`, out-of-range
|
|
253
|
+
// is clamped, off-grid is snapped, a reversed pair is reordered and `minRange`
|
|
254
|
+
// enforced — so the bound values always match what is rendered AND what the
|
|
255
|
+
// hidden inputs hold (the browser sanitizes off-grid values onto the step grid,
|
|
256
|
+
// which would otherwise diverge silently).
|
|
257
|
+
$effect(() => {
|
|
258
|
+
const [s, e] = _normalize(start, end);
|
|
259
|
+
if (!Object.is(start, s)) start = s;
|
|
260
|
+
if (!Object.is(end, e)) end = e;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
let _pair = $derived(_normalize(start, end));
|
|
264
|
+
let _start = $derived(_pair[0]);
|
|
265
|
+
let _end = $derived(_pair[1]);
|
|
266
|
+
let _ratioStart = $derived(_span ? (_start - _min) / _span : 0);
|
|
267
|
+
let _ratioEnd = $derived(_span ? (_end - _min) / _span : 0);
|
|
268
|
+
/** Midpoint between the thumbs (0..1) — where the two hidden inputs split the track. */
|
|
269
|
+
let _mid = $derived((_ratioStart + _ratioEnd) / 2);
|
|
270
|
+
|
|
271
|
+
let _ctxStart: RangeSliderRenderCtx = $derived({
|
|
272
|
+
thumb: "start",
|
|
273
|
+
value: _start,
|
|
274
|
+
ratio: _ratioStart,
|
|
275
|
+
percent: _ratioStart * 100,
|
|
276
|
+
dragging: _dragThumb === "start",
|
|
277
|
+
start: _start,
|
|
278
|
+
end: _end,
|
|
279
|
+
});
|
|
280
|
+
let _ctxEnd: RangeSliderRenderCtx = $derived({
|
|
281
|
+
thumb: "end",
|
|
282
|
+
value: _end,
|
|
283
|
+
ratio: _ratioEnd,
|
|
284
|
+
percent: _ratioEnd * 100,
|
|
285
|
+
dragging: _dragThumb === "end",
|
|
286
|
+
start: _start,
|
|
287
|
+
end: _end,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
function _clamp(v: number): number {
|
|
291
|
+
if (!Number.isFinite(v)) return _min;
|
|
292
|
+
return Math.min(Math.max(v, _min), _max);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _decimals(n: number): number {
|
|
296
|
+
const s = String(Math.abs(n));
|
|
297
|
+
const e = s.indexOf("e");
|
|
298
|
+
if (e === -1) {
|
|
299
|
+
const i = s.indexOf(".");
|
|
300
|
+
return i === -1 ? 0 : s.length - i - 1;
|
|
301
|
+
}
|
|
302
|
+
// Exponential notation (e.g. "1e-7", "1.5e-7")
|
|
303
|
+
const exp = Number(s.slice(e + 1));
|
|
304
|
+
const mant = s.slice(0, e);
|
|
305
|
+
const mi = mant.indexOf(".");
|
|
306
|
+
const mantDec = mi === -1 ? 0 : mant.length - mi - 1;
|
|
307
|
+
return Math.max(0, mantDec - exp);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Trim floating point noise (e.g. 0.1 + 0.2) to the precision of `step` / `min`. */
|
|
311
|
+
function _trim(v: number): number {
|
|
312
|
+
if (!(typeof step === "number" && step > 0)) return v;
|
|
313
|
+
const decimals = Math.min(Math.max(_decimals(step), _decimals(_min)), 20);
|
|
314
|
+
return Number(v.toFixed(decimals));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Snap to the step grid (anchored at `min`). Values beyond the last reachable
|
|
319
|
+
* grid point resolve to that grid point — NOT to an off-grid `max` — matching
|
|
320
|
+
* the browser's own range value sanitization (so the hidden inputs never
|
|
321
|
+
* re-sanitize to a different number than the bound value).
|
|
322
|
+
*/
|
|
323
|
+
function _snap(v: number): number {
|
|
324
|
+
if (!(typeof step === "number" && step > 0)) return _clamp(v);
|
|
325
|
+
let snapped = _min + Math.round((v - _min) / step) * step;
|
|
326
|
+
if (snapped > _max) snapped = _min + Math.floor(_span / step + EPS) * step;
|
|
327
|
+
if (snapped < _min) snapped = _min;
|
|
328
|
+
return _trim(snapped);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The canonical pair for any input: both finite, in range and on-grid, ordered
|
|
333
|
+
* (a reversed pair is swapped) and at least `_gap` apart — the end is pushed up,
|
|
334
|
+
* or, at the ceiling, the start pushed down.
|
|
335
|
+
*/
|
|
336
|
+
function _normalize(s: number | undefined, e: number | undefined): [number, number] {
|
|
337
|
+
let a = s === undefined || !Number.isFinite(s) ? _min : _snap(s);
|
|
338
|
+
let b = e === undefined || !Number.isFinite(e) ? _ceil : _snap(e);
|
|
339
|
+
if (a > b) [a, b] = [b, a];
|
|
340
|
+
if (b - a < _gap - EPS) {
|
|
341
|
+
b = _snap(a + _gap);
|
|
342
|
+
if (b - a < _gap - EPS) a = _snap(b - _gap);
|
|
343
|
+
}
|
|
344
|
+
return [a, b];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _valueOf(which: RangeSliderThumb): number {
|
|
348
|
+
return which === "start" ? _start : _end;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function _ratioOf(which: RangeSliderThumb): number {
|
|
352
|
+
return which === "start" ? _ratioStart : _ratioEnd;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function _inputOf(which: RangeSliderThumb): HTMLInputElement | undefined {
|
|
356
|
+
return which === "start" ? inputStartEl : inputEndEl;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Thumbs never cross and stay `_gap` apart: a thumb stops at the other one. */
|
|
360
|
+
function _limit(which: RangeSliderThumb, v: number): number {
|
|
361
|
+
return which === "start"
|
|
362
|
+
? Math.min(v, _snap(_end - _gap))
|
|
363
|
+
: Math.max(v, _snap(_start + _gap));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Sync the hidden inputs synchronously (Svelte's template update is async and
|
|
368
|
+
* skips entirely when a bound parent nets the value back to what it was — and
|
|
369
|
+
* the browser lets each input roam the whole min..max on its own).
|
|
370
|
+
*/
|
|
371
|
+
function _syncInputs() {
|
|
372
|
+
for (const which of ["start", "end"] as const) {
|
|
373
|
+
const input = _inputOf(which);
|
|
374
|
+
const s = String(_valueOf(which));
|
|
375
|
+
if (input && input.value !== s) input.value = s;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function _apply(which: RangeSliderThumb, v: number) {
|
|
380
|
+
v = _limit(which, v);
|
|
381
|
+
const prev = _valueOf(which);
|
|
382
|
+
if (v === prev) return;
|
|
383
|
+
if (which === "start") start = v;
|
|
384
|
+
else end = v;
|
|
385
|
+
// Re-read through the derived: a bound parent may have transformed or
|
|
386
|
+
// rejected the write synchronously.
|
|
387
|
+
const current = _valueOf(which);
|
|
388
|
+
_syncInputs();
|
|
389
|
+
if (current !== prev) oninput?.({ start: _start, end: _end }, which);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
interface Geometry {
|
|
393
|
+
horizontal: boolean;
|
|
394
|
+
rtl: boolean;
|
|
395
|
+
rect: DOMRect;
|
|
396
|
+
trackLen: number;
|
|
397
|
+
/** Dead zone at each end of the track (half the thumb size incl. inset) */
|
|
398
|
+
pad: number;
|
|
399
|
+
/** Usable px distance the values map onto */
|
|
400
|
+
travel: number;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function _geometry(): Geometry {
|
|
404
|
+
const rect = el!.getBoundingClientRect();
|
|
405
|
+
const horizontal = orientation === "horizontal";
|
|
406
|
+
const rtl = horizontal && getComputedStyle(el!).direction === "rtl";
|
|
407
|
+
const trackLen = horizontal ? rect.width : rect.height;
|
|
408
|
+
const thickness = horizontal ? rect.height : rect.width;
|
|
409
|
+
// With thumbs the values map onto [thickness/2, len - thickness/2] (mirrors
|
|
410
|
+
// the CSS `(100% - thickness) * ratio` sizing/positioning); without them
|
|
411
|
+
// linearly across the whole track. NOTE: this assumes the rendered cross-axis
|
|
412
|
+
// size equals --_thickness — size the cross-axis via the size presets or
|
|
413
|
+
// --stuic-range-slider-thickness, not via utility classes (see README).
|
|
414
|
+
const pad = _thumbReserved ? thickness / 2 : 0;
|
|
415
|
+
const travel = Math.max(1, trackLen - 2 * pad);
|
|
416
|
+
return { horizontal, rtl, rect, trackLen, pad, travel };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Pointer position in track coords, measured from the value=min end. */
|
|
420
|
+
function _pointerPos(e: PointerEvent, g: Geometry): number {
|
|
421
|
+
if (!g.horizontal) return g.rect.bottom - e.clientY;
|
|
422
|
+
return g.rtl ? g.rect.right - e.clientX : e.clientX - g.rect.left;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function _posToValue(e: PointerEvent, g: Geometry): number {
|
|
426
|
+
const r = (_pointerPos(e, g) - _grabOffset - g.pad) / g.travel;
|
|
427
|
+
return _snap(_min + r * _span);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Main-axis px position of a thumb's center. */
|
|
431
|
+
function _center(which: RangeSliderThumb, g: Geometry): number {
|
|
432
|
+
return g.pad + g.travel * _ratioOf(which);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function _beginDrag(which: RangeSliderThumb, e: PointerEvent, g: Geometry) {
|
|
436
|
+
_pending = false;
|
|
437
|
+
_dragThumb = which;
|
|
438
|
+
_activeThumb = which;
|
|
439
|
+
_dragStartValue = _valueOf(which);
|
|
440
|
+
// Grabbing (near) a thumb must not jump the value — the drag continues from
|
|
441
|
+
// the grab point.
|
|
442
|
+
const grabbed = which === "start" ? _pressOnStart : _pressOnEnd;
|
|
443
|
+
_grabOffset = grabbed ? _pressPos - _center(which, g) : 0;
|
|
444
|
+
_apply(which, _posToValue(e, g));
|
|
445
|
+
_focusThumb(which);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function _endPress(e: PointerEvent) {
|
|
449
|
+
_dragThumb = null;
|
|
450
|
+
_pending = false;
|
|
451
|
+
_pointerId = null;
|
|
452
|
+
if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function _onpointerdown(e: PointerEvent) {
|
|
456
|
+
// Disabled inputs cannot take focus, so there is no pointer-initiated focus
|
|
457
|
+
// to suppress a ring for — and a flag left behind would hide the ring from
|
|
458
|
+
// the first keyboard focus after re-enabling.
|
|
459
|
+
if (disabled) return;
|
|
460
|
+
// Set before the remaining early-returns: any pointer press over the
|
|
461
|
+
// component means the focus that follows is pointer-initiated and must not
|
|
462
|
+
// show the ring (a right-click still focuses one of the hidden inputs).
|
|
463
|
+
_pointerFocus = true;
|
|
464
|
+
if (_pointerId !== null || e.button !== 0) return;
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
const g = _geometry();
|
|
467
|
+
const pos = _pointerPos(e, g);
|
|
468
|
+
const dStart = Math.abs(pos - _center("start", g));
|
|
469
|
+
const dEnd = Math.abs(pos - _center("end", g));
|
|
470
|
+
// Grab detection is geometric (the hidden inputs are the topmost hit targets,
|
|
471
|
+
// so DOM-target checks would never match a thumb element).
|
|
472
|
+
_pressPos = pos;
|
|
473
|
+
_pressOnStart = _thumbReserved && dStart <= g.pad;
|
|
474
|
+
_pressOnEnd = _thumbReserved && dEnd <= g.pad;
|
|
475
|
+
try {
|
|
476
|
+
el?.setPointerCapture(e.pointerId);
|
|
477
|
+
} catch {
|
|
478
|
+
// NotFoundError for a pointerId that is not actively down (synthetic
|
|
479
|
+
// events) — capture is an optimization here, the drag works without it.
|
|
480
|
+
}
|
|
481
|
+
_pointerId = e.pointerId;
|
|
482
|
+
if (dStart < dEnd) _beginDrag("start", e, g);
|
|
483
|
+
else if (dEnd < dStart) _beginDrag("end", e, g);
|
|
484
|
+
else if (!_pressOnStart && !_pressOnEnd) {
|
|
485
|
+
// Equidistant, off both thumbs — they sit on top of each other and the
|
|
486
|
+
// press is beside them: the thumb on that side jumps (towards max → end).
|
|
487
|
+
_beginDrag(pos >= _center("end", g) ? "end" : "start", e, g);
|
|
488
|
+
} else {
|
|
489
|
+
// Equidistant ON the thumbs (stacked on each other): nothing can move
|
|
490
|
+
// without guessing, so the first move's direction picks the thumb
|
|
491
|
+
// (towards max → end, towards min → start). The end input gets focus
|
|
492
|
+
// meanwhile so the keyboard still has a slider.
|
|
493
|
+
_pending = true;
|
|
494
|
+
_focusThumb("end");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function _onpointermove(e: PointerEvent) {
|
|
499
|
+
if (e.pointerId !== _pointerId) return;
|
|
500
|
+
if (disabled) return _endPress(e);
|
|
501
|
+
const g = _geometry();
|
|
502
|
+
if (_pending) {
|
|
503
|
+
const pos = _pointerPos(e, g);
|
|
504
|
+
if (pos === _pressPos) return;
|
|
505
|
+
_beginDrag(pos > _pressPos ? "end" : "start", e, g);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (_dragThumb) _apply(_dragThumb, _posToValue(e, g));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function _onpointerup(e: PointerEvent) {
|
|
512
|
+
if (e.pointerId !== _pointerId) return;
|
|
513
|
+
const wasDisabled = disabled;
|
|
514
|
+
const which = _dragThumb;
|
|
515
|
+
_endPress(e);
|
|
516
|
+
if (wasDisabled || !which) return;
|
|
517
|
+
// Native "change" (commit) semantics — only when the drag actually changed
|
|
518
|
+
// the value. Also triggers the validate action and the `onchange` handler
|
|
519
|
+
// on the hidden input below.
|
|
520
|
+
if (_valueOf(which) !== _dragStartValue) {
|
|
521
|
+
_inputOf(which)?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let _tickRatios = $derived.by(() => {
|
|
526
|
+
if (!ticks || !_span) return [];
|
|
527
|
+
let vals: number[];
|
|
528
|
+
if (Array.isArray(ticks)) {
|
|
529
|
+
vals = ticks;
|
|
530
|
+
} else if (typeof step === "number" && step > 0) {
|
|
531
|
+
const count = Math.floor(_span / step + EPS);
|
|
532
|
+
if (count > MAX_AUTO_TICKS) {
|
|
533
|
+
console.warn(
|
|
534
|
+
`[stuic] RangeSlider: ${count + 1} auto ticks exceed the limit of ${MAX_AUTO_TICKS + 1}, ` +
|
|
535
|
+
`skipping (pass an explicit \`ticks\` array instead)`
|
|
536
|
+
);
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
// Through _snap so float accumulation (0 + 3 * 0.1 = 0.30000000000000004)
|
|
540
|
+
// cannot push the last tick past _max and drop it.
|
|
541
|
+
vals = Array.from({ length: count + 1 }, (_, i) => _snap(_min + i * step));
|
|
542
|
+
} else {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
return [...new Set(vals)]
|
|
546
|
+
.filter((v) => v >= _min && v <= _max)
|
|
547
|
+
.map((v) => (v - _min) / _span);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Styled mode renders three complementary tick layers — clipped to the part of
|
|
551
|
+
// the track before the fill, to the fill, and after it — so ticks stay readable
|
|
552
|
+
// over both the track and the fill. `unstyled` has no CSS to clip them, so it
|
|
553
|
+
// gets the single plain layer.
|
|
554
|
+
let _tickLayers = $derived<(string | undefined)[]>(
|
|
555
|
+
unstyled ? [undefined] : ["before", "on-fill", "after"]
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// Deterministic, cross-browser focus ring: shown for keyboard-initiated focus
|
|
559
|
+
// only (:focus-visible leaks on pointer-initiated programmatic focus in
|
|
560
|
+
// Chromium/WebKit). Holds the focused thumb, so the ring can sit on it.
|
|
561
|
+
let _ring = $state<RangeSliderThumb | null>(null);
|
|
562
|
+
let _pointerFocus = false;
|
|
563
|
+
// A programmatic focus hop between the two inputs is in progress: the blur it
|
|
564
|
+
// triggers must not reset `_pointerFocus` before the focus reads it.
|
|
565
|
+
let _hopping = false;
|
|
566
|
+
|
|
567
|
+
function _focusThumb(which: RangeSliderThumb) {
|
|
568
|
+
const input = _inputOf(which);
|
|
569
|
+
if (!input || document.activeElement === input) return;
|
|
570
|
+
_pointerFocus = true;
|
|
571
|
+
_hopping = true;
|
|
572
|
+
try {
|
|
573
|
+
input.focus();
|
|
574
|
+
} finally {
|
|
575
|
+
_hopping = false;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function _onfocus(which: RangeSliderThumb) {
|
|
580
|
+
_ring = _pointerFocus ? null : which;
|
|
581
|
+
_pointerFocus = false;
|
|
582
|
+
_activeThumb = which;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function _onblur() {
|
|
586
|
+
_ring = null;
|
|
587
|
+
if (!_hopping) _pointerFocus = false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function _oninput(
|
|
591
|
+
which: RangeSliderThumb,
|
|
592
|
+
e: Event & { currentTarget: HTMLInputElement }
|
|
593
|
+
) {
|
|
594
|
+
const v = e.currentTarget.valueAsNumber;
|
|
595
|
+
if (!Number.isNaN(v)) _apply(which, v);
|
|
596
|
+
// The native step landed past the other thumb (or a bound parent rejected
|
|
597
|
+
// the write): pull the input back to the value actually held.
|
|
598
|
+
_syncInputs();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function _onchange(which: RangeSliderThumb) {
|
|
602
|
+
onchange?.({ start: _start, end: _end }, which);
|
|
603
|
+
// The validate action listens on the start input only.
|
|
604
|
+
if (which === "end") _doValidate?.();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
//
|
|
608
|
+
let _doValidate: (() => void) | undefined = $state();
|
|
609
|
+
// Local copy of the last validation result so getValidation() works even
|
|
610
|
+
// when no external setValidationResult was provided.
|
|
611
|
+
let _validation: ValidationResult | undefined = $state();
|
|
612
|
+
|
|
613
|
+
/** Trigger validation now. Reaches the parent via `setValidationResult`. */
|
|
614
|
+
export function validate(): ValidationResult | undefined {
|
|
615
|
+
_doValidate?.();
|
|
616
|
+
return _validation;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Clear the inline validation message and reset `setCustomValidity`. */
|
|
620
|
+
export function clearValidation(): void {
|
|
621
|
+
_validation = undefined;
|
|
622
|
+
inputStartEl?.setCustomValidity?.("");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Current validation state. */
|
|
626
|
+
export function getValidation(): ValidationResult | undefined {
|
|
627
|
+
return _validation;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Focus the start thumb's range input. */
|
|
631
|
+
export function focus(): void {
|
|
632
|
+
inputStartEl?.focus?.();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Scroll the slider into view. */
|
|
636
|
+
export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
|
|
637
|
+
el?.scrollIntoView?.({ behavior: "smooth", block: "center", ...opts });
|
|
638
|
+
}
|
|
639
|
+
</script>
|
|
640
|
+
|
|
641
|
+
{#snippet thumbEl(ctx: RangeSliderRenderCtx)}
|
|
642
|
+
<div class={twMerge("thumb", thumbClass)} data-thumb={ctx.thumb} aria-hidden="true">
|
|
643
|
+
{#if typeof thumb === "function"}
|
|
644
|
+
{@render thumb(ctx)}
|
|
645
|
+
{/if}
|
|
646
|
+
</div>
|
|
647
|
+
{/snippet}
|
|
648
|
+
|
|
649
|
+
{#snippet valueEl(ctx: RangeSliderRenderCtx)}
|
|
650
|
+
<div class={twMerge("value", valueClass)} data-thumb={ctx.thumb} aria-hidden="true">
|
|
651
|
+
{@render valueLabel?.(ctx)}
|
|
652
|
+
</div>
|
|
653
|
+
{/snippet}
|
|
654
|
+
|
|
655
|
+
<!-- `rest` is spread first intentionally: the pointer handlers and computed
|
|
656
|
+
attributes below are functionally required and must not be clobbered.
|
|
657
|
+
`data-stuic-range-slider` is always emitted (even when `unstyled`) — index.css
|
|
658
|
+
hangs the FUNCTIONAL declarations off it (touch-action, position, the hidden
|
|
659
|
+
inputs' hit areas, …) so dragging survives `unstyled`, while staying
|
|
660
|
+
attribute-specificity so a consumer utility class can still override it. -->
|
|
661
|
+
<div
|
|
662
|
+
{...rest}
|
|
663
|
+
bind:this={el}
|
|
664
|
+
class={unstyled ? classProp : twMerge("stuic-range-slider", classProp)}
|
|
665
|
+
style:--_ratio-start={_ratioStart}
|
|
666
|
+
style:--_ratio-end={_ratioEnd}
|
|
667
|
+
style:--_mid={_mid}
|
|
668
|
+
role="group"
|
|
669
|
+
aria-label={label}
|
|
670
|
+
data-stuic-range-slider=""
|
|
671
|
+
data-orientation={orientation}
|
|
672
|
+
data-thumbs={_thumbReserved ? "true" : "false"}
|
|
673
|
+
data-fill-rounded={fillRounded ? "true" : undefined}
|
|
674
|
+
data-size={!unstyled ? size : undefined}
|
|
675
|
+
data-intent={!unstyled ? intent : undefined}
|
|
676
|
+
data-disabled={disabled ? "true" : undefined}
|
|
677
|
+
data-dragging={_dragThumb ?? undefined}
|
|
678
|
+
data-active-thumb={_activeThumb ?? undefined}
|
|
679
|
+
data-ring={_ring ?? undefined}
|
|
680
|
+
onpointerdown={_onpointerdown}
|
|
681
|
+
onpointermove={_onpointermove}
|
|
682
|
+
onpointerup={_onpointerup}
|
|
683
|
+
onpointercancel={_onpointerup}
|
|
684
|
+
>
|
|
685
|
+
<div class={twMerge("track", trackClass)}>
|
|
686
|
+
<div class={twMerge("fill", fillClass)}></div>
|
|
687
|
+
{#if _tickRatios.length}
|
|
688
|
+
{#each _tickLayers as layer (layer)}
|
|
689
|
+
<div class="ticks" data-layer={layer} aria-hidden="true">
|
|
690
|
+
{#each _tickRatios as r (r)}
|
|
691
|
+
<div class={twMerge("tick", tickClass)} style:--_tick-ratio={r}></div>
|
|
692
|
+
{/each}
|
|
693
|
+
</div>
|
|
694
|
+
{/each}
|
|
695
|
+
{/if}
|
|
696
|
+
</div>
|
|
697
|
+
{#if thumb !== false}
|
|
698
|
+
{@render thumbEl(_ctxStart)}
|
|
699
|
+
{@render thumbEl(_ctxEnd)}
|
|
700
|
+
{/if}
|
|
701
|
+
{#if valueLabel}
|
|
702
|
+
{@render valueEl(_ctxStart)}
|
|
703
|
+
{@render valueEl(_ctxEnd)}
|
|
704
|
+
{/if}
|
|
705
|
+
<!-- Visually hidden but sized (index.css): each input covers the track from
|
|
706
|
+
its end up to the midpoint between the thumbs, so touch-based assistive
|
|
707
|
+
tech (VoiceOver / TalkBack explore-by-touch) hit-tests a real slider role
|
|
708
|
+
on the side of the thumb it belongs to. Pointer events bubble to the root
|
|
709
|
+
handler, which preventDefault()s the native drag behavior. -->
|
|
710
|
+
<input
|
|
711
|
+
bind:this={inputStartEl}
|
|
712
|
+
type="range"
|
|
713
|
+
data-thumb="start"
|
|
714
|
+
value={_start}
|
|
715
|
+
min={_min}
|
|
716
|
+
max={_max}
|
|
717
|
+
step={_stepAttr}
|
|
718
|
+
name={nameStart}
|
|
719
|
+
{disabled}
|
|
720
|
+
aria-label={labelStart ?? t("minimum", null, "Minimum")}
|
|
721
|
+
aria-orientation={orientation === "vertical" ? "vertical" : undefined}
|
|
722
|
+
onfocus={() => _onfocus("start")}
|
|
723
|
+
onblur={_onblur}
|
|
724
|
+
onkeydown={() => (_ring = "start")}
|
|
725
|
+
oninput={(e) => _oninput("start", e)}
|
|
726
|
+
onchange={() => _onchange("start")}
|
|
727
|
+
use:validateAction={() => {
|
|
728
|
+
const opts = typeof validateProp === "object" ? validateProp : {};
|
|
729
|
+
const custom = opts.customValidator;
|
|
730
|
+
return {
|
|
731
|
+
enabled: validateProp !== false,
|
|
732
|
+
...opts,
|
|
733
|
+
// The pair is the value — the DOM string of one input is useless alone.
|
|
734
|
+
customValidator: custom
|
|
735
|
+
? (_v, ctx, input) => custom({ start: _start, end: _end }, ctx, input)
|
|
736
|
+
: undefined,
|
|
737
|
+
setValidationResult: (res) => {
|
|
738
|
+
_validation = res;
|
|
739
|
+
setValidationResult?.(res);
|
|
740
|
+
},
|
|
741
|
+
setDoValidate: (fn) => (_doValidate = fn),
|
|
742
|
+
};
|
|
743
|
+
}}
|
|
744
|
+
/>
|
|
745
|
+
<input
|
|
746
|
+
bind:this={inputEndEl}
|
|
747
|
+
type="range"
|
|
748
|
+
data-thumb="end"
|
|
749
|
+
value={_end}
|
|
750
|
+
min={_min}
|
|
751
|
+
max={_max}
|
|
752
|
+
step={_stepAttr}
|
|
753
|
+
name={nameEnd}
|
|
754
|
+
{disabled}
|
|
755
|
+
aria-label={labelEnd ?? t("maximum", null, "Maximum")}
|
|
756
|
+
aria-orientation={orientation === "vertical" ? "vertical" : undefined}
|
|
757
|
+
onfocus={() => _onfocus("end")}
|
|
758
|
+
onblur={_onblur}
|
|
759
|
+
onkeydown={() => (_ring = "end")}
|
|
760
|
+
oninput={(e) => _oninput("end", e)}
|
|
761
|
+
onchange={() => _onchange("end")}
|
|
762
|
+
/>
|
|
763
|
+
</div>
|