@marianmeres/stuic 3.172.0 → 3.173.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.
Files changed (53) hide show
  1. package/AGENTS.md +2 -2
  2. package/API.md +56 -2
  3. package/README.md +14 -2
  4. package/dist/actions/resizable-width.svelte.d.ts +14 -28
  5. package/dist/actions/resizable-width.svelte.js +16 -171
  6. package/dist/attachments/index.d.ts +1 -0
  7. package/dist/attachments/index.js +1 -0
  8. package/dist/attachments/resizable.d.ts +113 -0
  9. package/dist/attachments/resizable.fixture.svelte +53 -0
  10. package/dist/attachments/resizable.fixture.svelte.d.ts +10 -0
  11. package/dist/attachments/resizable.js +295 -0
  12. package/dist/components/RangeSlider/README.md +291 -0
  13. package/dist/components/RangeSlider/RangeSlider.svelte +763 -0
  14. package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +130 -0
  15. package/dist/components/RangeSlider/i18n-sk.d.ts +17 -0
  16. package/dist/components/RangeSlider/i18n-sk.js +19 -0
  17. package/dist/components/RangeSlider/i18n.d.ts +33 -0
  18. package/dist/components/RangeSlider/i18n.js +41 -0
  19. package/dist/components/RangeSlider/index.css +430 -0
  20. package/dist/components/RangeSlider/index.d.ts +3 -0
  21. package/dist/components/RangeSlider/index.js +3 -0
  22. package/dist/components/Rating/README.md +206 -0
  23. package/dist/components/Rating/Rating.svelte +355 -0
  24. package/dist/components/Rating/Rating.svelte.d.ts +82 -0
  25. package/dist/components/Rating/i18n-sk.d.ts +17 -0
  26. package/dist/components/Rating/i18n-sk.js +21 -0
  27. package/dist/components/Rating/i18n.d.ts +34 -0
  28. package/dist/components/Rating/i18n.js +42 -0
  29. package/dist/components/Rating/index.css +170 -0
  30. package/dist/components/Rating/index.d.ts +3 -0
  31. package/dist/components/Rating/index.js +3 -0
  32. package/dist/components/SplitPane/README.md +169 -0
  33. package/dist/components/SplitPane/SplitPane.svelte +202 -0
  34. package/dist/components/SplitPane/SplitPane.svelte.d.ts +67 -0
  35. package/dist/components/SplitPane/i18n-sk.d.ts +17 -0
  36. package/dist/components/SplitPane/i18n-sk.js +18 -0
  37. package/dist/components/SplitPane/i18n.d.ts +31 -0
  38. package/dist/components/SplitPane/i18n.js +39 -0
  39. package/dist/components/SplitPane/index.css +153 -0
  40. package/dist/components/SplitPane/index.d.ts +3 -0
  41. package/dist/components/SplitPane/index.js +3 -0
  42. package/dist/components/WithSidePanel/README.md +19 -16
  43. package/dist/icons/index.d.ts +2 -0
  44. package/dist/icons/index.js +3 -0
  45. package/dist/index.css +3 -0
  46. package/dist/index.d.ts +3 -0
  47. package/dist/index.js +3 -0
  48. package/docs/{maybe-todo.md → _archive/maybe-todo.md} +18 -7
  49. package/docs/architecture.md +1 -1
  50. package/docs/domains/actions.md +18 -18
  51. package/docs/domains/attachments.md +72 -9
  52. package/docs/domains/components.md +175 -32
  53. package/package.json +5 -5
@@ -0,0 +1,295 @@
1
+ import { getId } from "../utils/get-id.js";
2
+ import { localStorageState, sessionStorageState, } from "../utils/persistent-state.svelte.js";
3
+ import { twMerge } from "../utils/tw-merge.js";
4
+ const DEFAULT_LABEL = "Resize";
5
+ /**
6
+ * Makes an element's width (`axis: "x"`) or height (`axis: "y"`) resizable by dragging
7
+ * its edge. The handle is a keyboard-operable ARIA window splitter (`role="separator"`,
8
+ * focusable, `aria-valuenow` in `units`): arrow keys along the axis move it by `step`
9
+ * (×10 with Shift), Home / End go to `min` / `max`, Enter (like double-click) resets.
10
+ * Pointer Events with capture cover mouse, touch and pen in one code path.
11
+ *
12
+ * By default a thin handle strip with a grip is appended inside the element on the
13
+ * resized edge (the element becomes `position: relative`); pass `handle` to drive an
14
+ * element of your own (a sibling separator, say) instead. The size can be persisted
15
+ * under `key`. `min` / `max` clamp every path (drag, keys, `api.set()`).
16
+ *
17
+ * A factory: call it with the options (or a function returning them — reactive reads
18
+ * inside re-run the attachment) and attach the result.
19
+ *
20
+ * @example
21
+ * ```svelte
22
+ * <script>
23
+ * import { resizable } from "@marianmeres/stuic";
24
+ * </script>
25
+ *
26
+ * <!-- side by side: the first flex child gets a draggable width -->
27
+ * <div class="flex">
28
+ * <aside {@attach resizable({ initial: 300, min: 200, max: 600, key: "sidebar" })}>…</aside>
29
+ * <main class="flex-1">…</main>
30
+ * </div>
31
+ *
32
+ * <!-- stacked: a draggable height, in % of the (definite-height) parent -->
33
+ * <div class="flex flex-col h-96">
34
+ * <div {@attach resizable({ axis: "y", units: "%", initial: 40, max: 80 })}>…</div>
35
+ * <div class="flex-1">…</div>
36
+ * </div>
37
+ * ```
38
+ *
39
+ * @param options - {@link ResizableOptions}, or a function returning them
40
+ */
41
+ export function resizable(options = {}) {
42
+ return (el) => setup(el, typeof options === "function" ? options() : options);
43
+ }
44
+ // internals //////////////////////////////////////////////////////////////////////////
45
+ const HANDLE_CLS_COMMON = [
46
+ "group",
47
+ "absolute",
48
+ "bg-black/20 hover:bg-black/30",
49
+ "dark:bg-white/10 dark:hover:bg-white/20",
50
+ "transition-colors duration-200",
51
+ "touch-none",
52
+ "focus-visible:outline-2 focus-visible:outline-(--stuic-color-ring)",
53
+ ];
54
+ const HANDLE_CLS_X = [...HANDLE_CLS_COMMON, "top-0 bottom-0 w-[1px] cursor-col-resize"];
55
+ const HANDLE_CLS_Y = [...HANDLE_CLS_COMMON, "left-0 right-0 h-[1px] cursor-row-resize"];
56
+ const GRIP_CLS_COMMON = [
57
+ "absolute",
58
+ "rounded border border-black/20 dark:border-white/20",
59
+ "bg-gray-300 group-hover:bg-gray-400",
60
+ "dark:bg-gray-600 dark:group-hover:bg-gray-500",
61
+ "transition-colors duration-200",
62
+ "touch-none",
63
+ ];
64
+ const GRIP_CLS_X = [
65
+ ...GRIP_CLS_COMMON,
66
+ "h-[20px] w-[9px] -translate-x-[4px] top-1/2 -translate-y-1/2 cursor-col-resize",
67
+ ];
68
+ const GRIP_CLS_Y = [
69
+ ...GRIP_CLS_COMMON,
70
+ "w-[20px] h-[9px] -translate-y-[4px] left-1/2 -translate-x-1/2 cursor-row-resize",
71
+ ];
72
+ function create_handle(el, isX, reverse, handleClass, handleDragClass) {
73
+ const handle = document.createElement("div");
74
+ handle.setAttribute("data-handle", "true");
75
+ const grip = document.createElement("div");
76
+ grip.classList.add(...twMerge((isX ? GRIP_CLS_X : GRIP_CLS_Y).join(" "), handleDragClass).split(" "));
77
+ handle.appendChild(grip);
78
+ const edge = isX ? (reverse ? "left-0" : "right-0") : reverse ? "top-0" : "bottom-0";
79
+ handle.classList.add(...twMerge((isX ? HANDLE_CLS_X : HANDLE_CLS_Y).join(" "), edge, handleClass).split(" "));
80
+ el.appendChild(handle);
81
+ return handle;
82
+ }
83
+ function setup(el, options) {
84
+ const { enabled = true, axis = "x", initial = 0, min = 0, max = 0, units = "px", reverse = false, key, storage = "session", step = units === "%" ? 1 : 10, label = DEFAULT_LABEL, resetTo = initial, handle: providedHandle, handleClass = "", handleDragClass = "", onResize, onInit, debug, } = options;
85
+ const _debug = (...args) => debug?.("[resizable]", ...args);
86
+ _debug("setup", options);
87
+ if (!enabled)
88
+ return;
89
+ const container = el.parentElement;
90
+ if (!container)
91
+ return;
92
+ const isX = axis === "x";
93
+ const dim = isX ? "width" : "height";
94
+ // storage: a stored size wins over `initial`
95
+ const stored = key
96
+ ? (storage === "local" ? localStorageState : sessionStorageState)(`resizable-${dim}-${key}`, initial)
97
+ : null;
98
+ // handle /////////////////////////////////////////////////////////////////////////
99
+ const handle = providedHandle ?? create_handle(el, isX, reverse, handleClass, handleDragClass);
100
+ // what a provided handle looked like before, so the cleanup can restore it
101
+ const prevAttrs = new Map();
102
+ const set_attr = (name, value) => {
103
+ if (!prevAttrs.has(name))
104
+ prevAttrs.set(name, handle.getAttribute(name));
105
+ handle.setAttribute(name, value);
106
+ };
107
+ const prevPosition = el.style.position;
108
+ const prevTouchAction = handle.style.touchAction;
109
+ if (!providedHandle)
110
+ el.style.position = "relative"; // the created handle is absolute
111
+ handle.style.touchAction = "none"; // no scrolling / zooming mid-drag
112
+ const assignedId = el.id ? null : (el.id = getId("stuic-resizable-"));
113
+ set_attr("role", "separator");
114
+ set_attr("tabindex", "0");
115
+ set_attr("aria-orientation", isX ? "vertical" : "horizontal");
116
+ set_attr("aria-label", label);
117
+ set_attr("aria-controls", el.id);
118
+ // sizing /////////////////////////////////////////////////////////////////////////
119
+ const measure = () => (isX ? container.offsetWidth : container.offsetHeight);
120
+ const current_px = () => parseFloat(getComputedStyle(el)[dim]) || 0;
121
+ // px -> units; with no measurable container (hidden) a % size is left as it is
122
+ const to_units = (px, cs) => units === "%" ? (cs > 0 ? (px / cs) * 100 : current) : px;
123
+ const clamp = (value) => {
124
+ const before = value;
125
+ if (min)
126
+ value = Math.max(min, value);
127
+ if (max)
128
+ value = Math.min(max, value);
129
+ if (before !== value)
130
+ _debug("clamped", before, "->", value, units);
131
+ return value;
132
+ };
133
+ let current = 0; // last applied size, in units
134
+ function update_aria(cs) {
135
+ set_attr("aria-valuemin", `${min || 0}`);
136
+ set_attr("aria-valuemax", `${max || (units === "%" ? 100 : Math.round(cs))}`);
137
+ set_attr("aria-valuenow", `${Math.round(current)}`);
138
+ set_attr("aria-valuetext", `${Math.round(current)}${units}`);
139
+ }
140
+ function commit(size, cs) {
141
+ size = clamp(units === "%" ? Math.min(100, size) : size);
142
+ el.style[dim] = `${size}${units}`;
143
+ current = size;
144
+ _debug("applied", size, units);
145
+ update_aria(cs);
146
+ const info = { size, units, axis, container: cs };
147
+ onResize?.(info);
148
+ if (stored)
149
+ stored.current = size;
150
+ return info;
151
+ }
152
+ /** Applies a size given in `units` — needs no container measurement, so it also works while hidden. */
153
+ function apply(size) {
154
+ commit(size, measure());
155
+ }
156
+ /** Applies a size given in px (a drag), against the container as measured when it started. */
157
+ function apply_px(px, cs) {
158
+ commit(to_units(px, cs), cs);
159
+ }
160
+ function reset() {
161
+ if (resetTo)
162
+ apply(resetTo);
163
+ }
164
+ // pointer ////////////////////////////////////////////////////////////////////////
165
+ let dragging = false;
166
+ let pointerId = -1;
167
+ let startCoord = 0;
168
+ let startPx = 0;
169
+ let containerSize = 0;
170
+ function on_pointerdown(e) {
171
+ if (e.button !== 0 && e.pointerType === "mouse")
172
+ return;
173
+ dragging = true;
174
+ pointerId = e.pointerId;
175
+ startCoord = isX ? e.clientX : e.clientY;
176
+ startPx = current_px();
177
+ containerSize = measure();
178
+ try {
179
+ handle.setPointerCapture(e.pointerId);
180
+ }
181
+ catch {
182
+ /* capture is best-effort (synthetic pointers have no id to capture) */
183
+ }
184
+ document.body.style.cursor = isX ? "col-resize" : "row-resize";
185
+ document.body.style.userSelect = "none";
186
+ handle.setAttribute("data-resizing", "");
187
+ }
188
+ function on_pointermove(e) {
189
+ if (!dragging || e.pointerId !== pointerId)
190
+ return;
191
+ e.preventDefault();
192
+ const delta = (isX ? e.clientX : e.clientY) - startCoord;
193
+ apply_px(reverse ? startPx - delta : startPx + delta, containerSize);
194
+ }
195
+ function on_pointerup(e) {
196
+ if (!dragging || e.pointerId !== pointerId)
197
+ return;
198
+ stop();
199
+ }
200
+ function stop() {
201
+ if (!dragging)
202
+ return;
203
+ dragging = false;
204
+ try {
205
+ handle.releasePointerCapture(pointerId);
206
+ }
207
+ catch {
208
+ /* noop */
209
+ }
210
+ document.body.style.cursor = "";
211
+ document.body.style.userSelect = "";
212
+ handle.removeAttribute("data-resizing");
213
+ }
214
+ // keyboard ///////////////////////////////////////////////////////////////////////
215
+ function on_keydown(e) {
216
+ const amount = e.shiftKey ? step * 10 : step;
217
+ // delta along the screen direction (+ = right / down), like a pointer drag
218
+ let delta;
219
+ if (e.key === (isX ? "ArrowRight" : "ArrowDown"))
220
+ delta = amount;
221
+ else if (e.key === (isX ? "ArrowLeft" : "ArrowUp"))
222
+ delta = -amount;
223
+ else if (e.key === "Home")
224
+ delta = NaN;
225
+ else if (e.key === "End")
226
+ delta = NaN;
227
+ else if (e.key === "Enter")
228
+ delta = NaN;
229
+ else
230
+ return;
231
+ e.preventDefault();
232
+ if (e.key === "Home")
233
+ return apply(min || 0);
234
+ if (e.key === "End")
235
+ return apply(max || (units === "%" ? 100 : measure()));
236
+ if (e.key === "Enter")
237
+ return reset();
238
+ apply(current + (reverse ? -delta : delta));
239
+ }
240
+ function on_dblclick() {
241
+ reset();
242
+ }
243
+ function on_selectstart(e) {
244
+ e.preventDefault();
245
+ }
246
+ // go /////////////////////////////////////////////////////////////////////////////
247
+ const startSize = stored ? (stored.current ?? initial) : initial;
248
+ if (startSize) {
249
+ apply(startSize);
250
+ }
251
+ else {
252
+ const cs = measure();
253
+ current = to_units(current_px(), cs);
254
+ update_aria(cs);
255
+ }
256
+ handle.addEventListener("pointerdown", on_pointerdown);
257
+ handle.addEventListener("pointermove", on_pointermove);
258
+ handle.addEventListener("pointerup", on_pointerup);
259
+ handle.addEventListener("pointercancel", on_pointerup);
260
+ handle.addEventListener("keydown", on_keydown);
261
+ handle.addEventListener("dblclick", on_dblclick);
262
+ handle.addEventListener("selectstart", on_selectstart);
263
+ onInit?.({
264
+ get current() {
265
+ return current;
266
+ },
267
+ set: apply,
268
+ reset,
269
+ });
270
+ return () => {
271
+ stop();
272
+ handle.removeEventListener("pointerdown", on_pointerdown);
273
+ handle.removeEventListener("pointermove", on_pointermove);
274
+ handle.removeEventListener("pointerup", on_pointerup);
275
+ handle.removeEventListener("pointercancel", on_pointerup);
276
+ handle.removeEventListener("keydown", on_keydown);
277
+ handle.removeEventListener("dblclick", on_dblclick);
278
+ handle.removeEventListener("selectstart", on_selectstart);
279
+ if (providedHandle) {
280
+ for (const [name, value] of prevAttrs) {
281
+ if (value === null)
282
+ handle.removeAttribute(name);
283
+ else
284
+ handle.setAttribute(name, value);
285
+ }
286
+ handle.style.touchAction = prevTouchAction;
287
+ }
288
+ else {
289
+ handle.remove();
290
+ el.style.position = prevPosition;
291
+ }
292
+ if (assignedId && el.id === assignedId)
293
+ el.removeAttribute("id");
294
+ };
295
+ }
@@ -0,0 +1,291 @@
1
+ # RangeSlider
2
+
3
+ The dual-thumb sibling of [`Slider`](../Slider/README.md): two values (`start` ≤ `end`) on
4
+ one pill-shaped track, with the fill spanning the selected range — price filters,
5
+ "between" queries, min/max limits. Same construction and look as `Slider`: horizontal
6
+ and vertical orientation, pointer dragging with step snapping (the thumbs never cross),
7
+ native keyboard interaction per thumb, tick marks, floating value labels, form
8
+ participation via two hidden range inputs, and validation.
9
+
10
+ Not a replacement for two `FieldInput type="range"`s — this is the "fancy" custom-UI
11
+ variant. The bound ends are two plain numbers (`bind:start` / `bind:end`), the same
12
+ shape `FieldDateRange` uses.
13
+
14
+ ## Props
15
+
16
+ | Prop | Type | Default | Description |
17
+ | -------------- | ------------------------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
18
+ | `start` | `number` | `min` | Lower value (bindable; non-finite / out-of-range / off-grid writes are normalized back, a reversed pair is reordered, `minRange` enforced) |
19
+ | `end` | `number` | `max` | Upper value (bindable; same normalization). Defaults to `max`, or the last step-grid point below it |
20
+ | `min` | `number` | `0` | Minimum value |
21
+ | `max` | `number` | `100` | Maximum value |
22
+ | `step` | `number \| "any"` | `1` | Snap increment (`"any"` or non-positive = continuous) |
23
+ | `minRange` | `number` | `0` | Minimum distance between the two values; rounded up onto the step grid, capped at the span. `0` lets the thumbs coincide |
24
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Slider direction (vertical fills bottom-up) |
25
+ | `size` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Cross-axis thickness preset |
26
+ | `intent` | `"primary" \| "accent" \| "success" \| "warning" \| "destructive"` | - | Semantic fill color |
27
+ | `thumb` | `boolean \| Snippet<[RangeSliderRenderCtx]>` | `true` | `false` hides both thumbs (fill-only look), a snippet renders inside each thumb (its context says which one) |
28
+ | `fillRounded` | `boolean` | `false` | Round the fill's edges ("pill inside a pill") |
29
+ | `ticks` | `boolean \| number[]` | - | `true` = tick at every `step` (positive numeric step only; skipped above 101 auto ticks — pass an array), array = ticks at given in-range values |
30
+ | `valueLabel` | `Snippet<[RangeSliderRenderCtx]>` | - | Floating label per thumb, at its value along the track |
31
+ | `disabled` | `boolean` | `false` | Disable interaction |
32
+ | `label` | `string` | - | Accessible name of the whole control (`aria-label` on the `role="group"` root) |
33
+ | `labelStart` | `string` | `t("minimum")` | Accessible name of the start thumb's input ("Minimum") |
34
+ | `labelEnd` | `string` | `t("maximum")` | Accessible name of the end thumb's input ("Maximum") |
35
+ | `nameStart` | `string` | - | Form field name of the hidden range input carrying `start` |
36
+ | `nameEnd` | `string` | - | Form field name of the hidden range input carrying `end` |
37
+ | `oninput` | `(value: RangeSliderValue, thumb: RangeSliderThumb) => void` | - | Fires on every value change (drag, keyboard) with the pair and the thumb that moved |
38
+ | `onchange` | `(value: RangeSliderValue, thumb: RangeSliderThumb) => void` | - | Fires when a change is committed (drag release, keyboard) |
39
+ | `validate` | `boolean \| ValidateOptions` | - | Enable validation (stuic validate action). **`customValidator` receives the `{ start, end }` pair** as its value |
40
+ | `t` | `TranslateFn` | English | i18n of the default thumb names (`createRangeSliderT`) |
41
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
42
+ | `class` | `string` | - | Classes for the root element |
43
+ | `trackClass` | `string` | - | Classes for the track (pill background) |
44
+ | `fillClass` | `string` | - | Classes for the fill (selected range) |
45
+ | `thumbClass` | `string` | - | Classes for both thumbs |
46
+ | `tickClass` | `string` | - | Classes for each tick mark |
47
+ | `valueClass` | `string` | - | Classes for both value label wrappers |
48
+ | `el` | `HTMLDivElement` | - | Root element reference (bindable) |
49
+ | `inputStartEl` | `HTMLInputElement` | - | Hidden range input carrying `start` (bindable) |
50
+ | `inputEndEl` | `HTMLInputElement` | - | Hidden range input carrying `end` (bindable) |
51
+
52
+ `RangeSliderValue` (the callback / validator payload): `{ start: number; end: number }`.
53
+
54
+ `RangeSliderThumb`: `"start" | "end"`.
55
+
56
+ `RangeSliderRenderCtx` (passed to the `thumb` and `valueLabel` snippets, once per thumb):
57
+ `{ thumb: RangeSliderThumb; value: number; ratio: number /* 0..1 */; percent: number /* 0..100 */; dragging: boolean; start: number; end: number }`
58
+
59
+ Remaining props are spread onto the root `<div>`.
60
+
61
+ ### Exported methods (via component instance binding)
62
+
63
+ | Method | Description |
64
+ | ----------------------- | ------------------------------------------------------------------- |
65
+ | `validate()` | Trigger validation now |
66
+ | `clearValidation()` | Clear the current validation result |
67
+ | `getValidation()` | Read the current validation result |
68
+ | `focus()` | Focus the start thumb's range input (Tab moves on to the end thumb) |
69
+ | `scrollIntoView(opts?)` | Scroll the slider into view |
70
+
71
+ ### Other exports
72
+
73
+ | Export | Description |
74
+ | -------------------------- | ---------------------------------------------------- |
75
+ | `createRangeSliderT` | Builds the `t` prop from a (partial) message catalog |
76
+ | `RANGE_SLIDER_MESSAGES_EN` | Built-in English catalog (also the fallback) |
77
+ | `RANGE_SLIDER_MESSAGES_SK` | Bundled Slovak catalog (opt-in) |
78
+ | `RangeSliderMessageKey` | `"minimum" \| "maximum"` |
79
+ | `RangeSliderMessages` | One locale's catalog |
80
+
81
+ ## Usage
82
+
83
+ ### Basic
84
+
85
+ ```svelte
86
+ <script lang="ts">
87
+ import { RangeSlider } from "@marianmeres/stuic";
88
+
89
+ let priceMin = $state(150);
90
+ let priceMax = $state(600);
91
+ </script>
92
+
93
+ <RangeSlider bind:start={priceMin} bind:end={priceMax} min={0} max={1000} step={10} />
94
+ ```
95
+
96
+ ### Price filter with value labels
97
+
98
+ ```svelte
99
+ <RangeSlider
100
+ bind:start={priceMin}
101
+ bind:end={priceMax}
102
+ min={0}
103
+ max={1000}
104
+ step={10}
105
+ label="Price"
106
+ labelStart="Minimum price"
107
+ labelEnd="Maximum price"
108
+ >
109
+ {#snippet valueLabel({ value })}
110
+ {eur.format(value)}
111
+ {/snippet}
112
+ </RangeSlider>
113
+ ```
114
+
115
+ Labels are rendered per thumb and will overlap when the thumbs are close; for a single
116
+ combined readout ("€150 – €600") render it yourself next to the slider from the bound
117
+ values.
118
+
119
+ ### Minimum distance
120
+
121
+ ```svelte
122
+ <!-- the thumbs can never get closer than 20 -->
123
+ <RangeSlider bind:start bind:end minRange={20} />
124
+ ```
125
+
126
+ ### Steps and ticks
127
+
128
+ ```svelte
129
+ <RangeSlider min={0} max={24} step={1} ticks bind:start={from} bind:end={to} />
130
+ <RangeSlider min={0} max={100} step={0.5} ticks={[0, 25, 50, 75, 100]} />
131
+ ```
132
+
133
+ ### Fill-only (no thumbs)
134
+
135
+ ```svelte
136
+ <RangeSlider thumb={false} start={30} end={70} />
137
+ ```
138
+
139
+ With no thumbs there is nothing to reserve: the values map linearly across the whole
140
+ track and the fill collapses to zero when they coincide.
141
+
142
+ ### Custom thumb content
143
+
144
+ The snippet renders once per thumb; use `thumb` from its context to tell them apart.
145
+
146
+ ```svelte
147
+ <RangeSlider bind:start bind:end size="lg">
148
+ {#snippet thumb({ thumb })}
149
+ {@html (thumb === "start" ? iconChevronLeft : iconChevronRight)({ size: 18 })}
150
+ {/snippet}
151
+ </RangeSlider>
152
+ ```
153
+
154
+ ### Vertical
155
+
156
+ ```svelte
157
+ <RangeSlider orientation="vertical" class="h-40" bind:start bind:end />
158
+ ```
159
+
160
+ ### In a form
161
+
162
+ Two hidden `input[type=range]` carry the values:
163
+
164
+ ```svelte
165
+ <form onsubmit={...}>
166
+ <RangeSlider nameStart="price_min" nameEnd="price_max" bind:start bind:end />
167
+ </form>
168
+ ```
169
+
170
+ ### Validation
171
+
172
+ The stuic `validate` action is attached to the start input and re-run whenever either
173
+ thumb commits. Because a single input's DOM string is useless for a range rule,
174
+ `customValidator` receives the `{ start, end }` pair as its value (the start input is
175
+ still passed as the third argument):
176
+
177
+ ```svelte
178
+ <RangeSlider
179
+ bind:start
180
+ bind:end
181
+ validate={{
182
+ customValidator: (v) => {
183
+ const { start, end } = v as RangeSliderValue;
184
+ return end - start < 25 ? "Span at least 25" : "";
185
+ },
186
+ }}
187
+ setValidationResult={(res) => (validation = res)}
188
+ />
189
+ ```
190
+
191
+ ### i18n
192
+
193
+ Only the default thumb names ("Minimum" / "Maximum") are translatable — pass explicit
194
+ `labelStart` / `labelEnd` for context-specific names.
195
+
196
+ ```svelte
197
+ <script>
198
+ import {
199
+ RangeSlider,
200
+ createRangeSliderT,
201
+ RANGE_SLIDER_MESSAGES_SK,
202
+ } from "@marianmeres/stuic";
203
+ const t = createRangeSliderT(RANGE_SLIDER_MESSAGES_SK);
204
+ </script>
205
+
206
+ <RangeSlider bind:start bind:end {t} />
207
+ ```
208
+
209
+ ## Interaction
210
+
211
+ - **Pointer**: press anywhere on the track and the _nearest_ thumb jumps there, then
212
+ drags. Grabbing a thumb itself does not jump (the drag continues from the grab point).
213
+ When both thumbs sit on top of each other, a press beside them moves the thumb on that
214
+ side (towards `max` the end thumb, towards `min` the start thumb), and a press _on_
215
+ them is resolved by the first move's direction — drag right/up and the end thumb
216
+ comes along, left/down the start thumb.
217
+ - **No crossing**: a thumb dragged (or stepped) past the other one stops at it —
218
+ `minRange` apart when set. Thumbs never swap roles.
219
+ - **Keyboard**: Tab focuses the start thumb, then the end thumb; Arrow keys / PageUp /
220
+ PageDown / Home / End step the focused one — native `input[type=range]` behavior, with
221
+ the other thumb as the limit (End on the start thumb jumps up to the end thumb).
222
+ - **Thumb reserve**: with thumbs rendered the values map onto the thumb-center travel —
223
+ the outer half-thumb at each end of the track resolves to `min` / `max`, and the fill
224
+ never shrinks below one thumb (coinciding thumbs leave a thumb-sized nub).
225
+ `thumb={false}` maps linearly across the whole track instead.
226
+ - **Vertical**: bottom is `min`, top is `max`; ArrowUp increases.
227
+ - **RTL**: horizontal sliders flip automatically (logical CSS properties + pointer math).
228
+ - **Commit semantics**: `oninput` fires only on actual value changes; `onchange` only
229
+ when a drag / keypress committed a _different_ value (native-faithful — a no-move tap
230
+ fires neither). Both receive the whole pair plus the thumb that moved.
231
+ - **Off-grid max**: when `max` is not on the step grid (e.g. `min=0 max=95 step=10`),
232
+ the largest reachable value is the last grid point (`90`), matching native range
233
+ sanitization — that is also the default `end`.
234
+
235
+ ## Accessibility
236
+
237
+ The root is a `role="group"` named by `label`; inside it, each thumb is a real
238
+ `<input type="range">` (visually hidden, full thickness, covering the track from its end
239
+ up to the midpoint between the thumbs), so screen readers see two sliders named
240
+ `labelStart` / `labelEnd` — "Minimum" / "Maximum" by default (`t`) — with the native
241
+ value, min and max. Explore-by-touch on VoiceOver / TalkBack lands on the slider of the
242
+ side being touched. The focus ring is drawn around the focused thumb (around the track
243
+ when `thumb={false}`).
244
+
245
+ ## Caveats
246
+
247
+ - **Cross-axis sizing**: size the thickness via `size` presets or
248
+ `--stuic-range-slider-thickness` — not via `h-*`/`w-*` utility classes. The pointer math
249
+ and the CSS thumb positioning both derive from the thickness; a utility class
250
+ resizes the box without updating `--_thickness`, misaligning them. (Main-axis length
251
+ via a class — e.g. `class="w-72"` — is fine.)
252
+ - **Root pointer handlers are reserved**: `onpointerdown/move/up/cancel` are excluded
253
+ from `Props` (the drag machinery owns them). Wrap the slider if you need them.
254
+ - **Touch**: the slider claims the whole touch gesture (`touch-action: none`) — a touch
255
+ starting on it adjusts a value and never scrolls the page.
256
+ - **The wrapper is not the control**: props spread onto the root `<div>` — including
257
+ `onfocus` / `onblur` — never reach the hidden inputs. Use `label`, `labelStart`,
258
+ `labelEnd`; for anything else bind `inputStartEl` / `inputEndEl` and wire it
259
+ imperatively.
260
+ - **Independent tokens**: the look is deliberately not derived from `--stuic-slider-*`.
261
+ A theme that restyles `Slider` restyles `RangeSlider` by setting the
262
+ `--stuic-range-slider-*` twins.
263
+
264
+ ## CSS Variables
265
+
266
+ | Variable | Default | Description |
267
+ | --------------------------------------- | ----------------------------- | ---------------------------------- |
268
+ | `--stuic-range-slider-track` | `--stuic-color-muted` | Track (pill background) color |
269
+ | `--stuic-range-slider-fill` | `--stuic-color-primary` | Fill (selected range) color |
270
+ | `--stuic-range-slider-thumb` | `--color-white` | Thumb background |
271
+ | `--stuic-range-slider-thumb-foreground` | `--stuic-color-foreground` | Thumb content color |
272
+ | `--stuic-range-slider-tick` | foreground 25% mix | Tick mark color (over the track) |
273
+ | `--stuic-range-slider-tick-on-fill` | background 55% mix | Tick mark color (over the fill) |
274
+ | `--stuic-range-slider-ring-width` | `4px` | Focus ring width |
275
+ | `--stuic-range-slider-ring-color` | `--stuic-color-ring` | Focus ring color |
276
+ | `--stuic-range-slider-thickness` | `2rem` (`sm` 1.25, `lg` 3) | Cross-axis size |
277
+ | `--stuic-range-slider-length` | `10rem` | Main-axis size |
278
+ | `--stuic-range-slider-thumb-inset` | `3px` | Gap between thumb and track edge |
279
+ | `--stuic-range-slider-radius` | `9999px` | Track corner radius |
280
+ | `--stuic-range-slider-fill-radius` | `--stuic-range-slider-radius` | Fill radius (when `fillRounded`) |
281
+ | `--stuic-range-slider-thumb-radius` | `9999px` | Thumb corner radius |
282
+ | `--stuic-range-slider-thumb-shadow` | `--stuic-shadow` | Thumb shadow |
283
+ | `--stuic-range-slider-tick-size` | `4px` | Tick mark diameter |
284
+ | `--stuic-range-slider-value-gap` | `0.375rem` | Gap between track and value labels |
285
+ | `--stuic-range-slider-transition` | `--stuic-transition` | Fill/thumb movement transition |
286
+
287
+ Data attributes on the root, for custom CSS: `data-orientation`, `data-thumbs`
288
+ (`"true"` / `"false"`), `data-fill-rounded`, `data-size`, `data-intent`,
289
+ `data-disabled`, `data-dragging` / `data-ring` / `data-active-thumb` (each naming a
290
+ thumb: `"start"` / `"end"`). Thumbs, value labels and the hidden inputs carry
291
+ `data-thumb="start|end"`; tick layers `data-layer="before|on-fill|after"`.