@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.
- package/AGENTS.md +2 -2
- package/API.md +56 -2
- package/README.md +14 -2
- 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/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 +3 -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/domains/actions.md +18 -18
- package/docs/domains/attachments.md +72 -9
- package/docs/domains/components.md +175 -32
- 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"`.
|