@monabbous/slidebox 1.1.0 → 1.3.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.
@@ -0,0 +1,121 @@
1
+ .slidebox-group {
2
+ --_size: var(--slidebox-size, 36px);
3
+ --_spacing: var(--slidebox-spacing, .12);
4
+ --_deform: var(--slidebox-deform, .06);
5
+ --_duration: var(--slidebox-duration, 200ms);
6
+ --_easing: var(--slidebox-easing, ease-in-out);
7
+ --_track: var(--slidebox-track, Field);
8
+ --_border: var(--slidebox-border, ButtonBorder);
9
+ --_off: var(--slidebox-off, FieldText);
10
+ --_on: var(--slidebox-on, var(--_accent, light-dark(#0b75ff, SelectedItem)));
11
+ --_text: var(--slidebox-text, var(--slidebox-off, FieldText));
12
+ --_selected-text: var(--slidebox-selected-text, HighlightText);
13
+ --_press-start: 0px;
14
+ --_press-end: 0px;
15
+ display: inline-grid;
16
+ grid-auto-flow: column;
17
+ grid-auto-columns: minmax(0, 1fr);
18
+ box-sizing: border-box;
19
+ padding: calc(var(--_size) * var(--_spacing));
20
+ gap: calc(var(--_size) * var(--_spacing));
21
+ border: 1px solid var(--_border);
22
+ border-radius: var(--slidebox-radius, 100vw);
23
+ background: var(--_track);
24
+ color: var(--_text);
25
+ isolation: isolate;
26
+ position: relative;
27
+ }
28
+ .slidebox-group > label {
29
+ position: relative;
30
+ display: grid;
31
+ min-width: 0;
32
+ cursor: pointer;
33
+ }
34
+ .slidebox-group > label > input[type="radio"] {
35
+ position: absolute;
36
+ width: 1px;
37
+ height: 1px;
38
+ padding: 0;
39
+ margin: -1px;
40
+ overflow: hidden;
41
+ clip-path: inset(50%);
42
+ white-space: nowrap;
43
+ }
44
+ .slidebox-group > label > span {
45
+ position: relative;
46
+ isolation: isolate;
47
+ display: grid;
48
+ place-items: center;
49
+ box-sizing: border-box;
50
+ min-height: var(--_size);
51
+ padding: .35em 1em;
52
+ border: 1px solid transparent;
53
+ border-radius: var(--slidebox-radius, 100vw);
54
+ color: var(--_text);
55
+ background: transparent;
56
+ transition: color var(--_duration) var(--_easing);
57
+ }
58
+ .slidebox-group > label > input:checked + span {
59
+ color: var(--_selected-text);
60
+ }
61
+ .slidebox-group > label > input:checked + span::before {
62
+ content: "";
63
+ position: absolute;
64
+ inset: 0;
65
+ z-index: -1;
66
+ pointer-events: none;
67
+ border-radius: inherit;
68
+ background: var(--_on);
69
+ margin-inline-start: var(--_press-start);
70
+ margin-inline-end: var(--_press-end);
71
+ transition: margin-inline-start var(--_duration) var(--_easing), margin-inline-end var(--_duration) var(--_easing);
72
+ }
73
+ .slidebox-group > label > input:not(:checked) + span { color: var(--_text); }
74
+ .slidebox-group:has(> label > input:checked):has(> label:active > input:not(:checked):not(:disabled)):not(:has(> label:active ~ label > input:checked)) {
75
+ --_press-end: calc(-1 * var(--_size) * var(--_deform));
76
+ }
77
+ .slidebox-group:has(> label:active > input:not(:disabled)):has(> label:active ~ label > input:checked) {
78
+ --_press-start: calc(-1 * var(--_size) * var(--_deform));
79
+ }
80
+ .slidebox-group > label > input:focus-visible + span {
81
+ outline: 2px solid var(--_on);
82
+ outline-offset: 2px;
83
+ }
84
+ .slidebox-group > label:has(> input:disabled) { cursor: not-allowed; }
85
+ .slidebox-group > label > input:disabled + span { opacity: .5; }
86
+ @supports (color: AccentColor) {
87
+ .slidebox-group { --_accent: AccentColor; }
88
+ }
89
+ /* A shared sliding indicator where scoped CSS anchor positioning is available.
90
+ Other browsers retain the per-segment highlight above. Neither needs JS. */
91
+ @supports (anchor-scope: --slidebox-selection) and (left: anchor(left)) {
92
+ .slidebox-group { anchor-scope: --slidebox-selection; }
93
+ .slidebox-group > label:has(> input:checked) { anchor-name: --slidebox-selection; }
94
+ .slidebox-group:has(> label > input:checked)::before {
95
+ content: "";
96
+ position: absolute;
97
+ position-anchor: --slidebox-selection;
98
+ left: anchor(left);
99
+ right: anchor(right);
100
+ top: anchor(top);
101
+ height: anchor-size(height);
102
+ margin-inline-start: var(--_press-start);
103
+ margin-inline-end: var(--_press-end);
104
+ z-index: -1;
105
+ border-radius: var(--slidebox-radius, 100vw);
106
+ background: var(--_on);
107
+ pointer-events: none;
108
+ transition: left var(--_duration) var(--_easing), right var(--_duration) var(--_easing), top var(--_duration) var(--_easing), height var(--_duration) var(--_easing), margin-inline-start var(--_duration) var(--_easing), margin-inline-end var(--_duration) var(--_easing);
109
+ }
110
+ .slidebox-group > label > input:checked + span::before { content: none; }
111
+ }
112
+ @media (forced-colors: active) {
113
+ .slidebox-group { --_track: Field; --_border: ButtonBorder; --_off: FieldText; --_on: Highlight; --_text: FieldText; --_selected-text: HighlightText; }
114
+ .slidebox-group > label > input:checked + span { border-color: Highlight; outline: 2px solid Highlight; outline-offset: -2px; }
115
+ .slidebox-group > label > input:disabled + span { color: GrayText; }
116
+ }
117
+ @media (prefers-reduced-motion: reduce) {
118
+ .slidebox-group > label > span { transition-duration: .01ms; }
119
+ .slidebox-group > label > input:checked + span::before { transition-duration: .01ms; }
120
+ .slidebox-group::before { transition-duration: .01ms; }
121
+ }
@@ -28,6 +28,7 @@
28
28
  }
29
29
  .slidebox::after {
30
30
  --_start: 0; --_end: 1;
31
+ box-sizing: border-box;
31
32
  content: "";
32
33
  display: block;
33
34
  flex: 1 1 auto;
@@ -38,6 +39,7 @@
38
39
  margin-inline-start: calc((1em * var(--_container-width-ratio) - 1em) * var(--_start) + var(--_thumb-spacing-ratio) * .5em);
39
40
  margin-inline-end: calc((1em * var(--_container-width-ratio) - 1em) * var(--_end) + var(--_thumb-spacing-ratio) * .5em);
40
41
  border-radius: inherit;
42
+ border: 1px solid var(--_track);
41
43
  background: var(--_off);
42
44
  transition: background var(--_duration) var(--_easing-function), margin-inline-start var(--_duration) var(--_easing-function), margin-inline-end var(--_duration) var(--_easing-function);
43
45
  transition-delay: 0s, 0s, calc(var(--_duration) * var(--_deform));
@@ -50,6 +52,11 @@
50
52
  .slidebox:not(:checked):active::after { --_end: calc(1 - var(--_deform)); }
51
53
  .slidebox:checked:active::after { --_start: calc(1 - var(--_deform)); }
52
54
  .slidebox:disabled { cursor: not-allowed; opacity: .5; }
55
+ .slidebox[data-slidebox-dragging]::after {
56
+ margin-inline-start: calc((1em * var(--_container-width-ratio) - 1em) * max(0, var(--slidebox-drag-progress) - max(0, var(--slidebox-drag-stretch, 0))) + var(--_thumb-spacing-ratio) * .5em);
57
+ margin-inline-end: calc((1em * var(--_container-width-ratio) - 1em) * max(0, 1 - var(--slidebox-drag-progress) - max(0, -1 * var(--slidebox-drag-stretch, 0))) + var(--_thumb-spacing-ratio) * .5em);
58
+ transition: none;
59
+ }
53
60
  @media (forced-colors: active) {
54
61
  .slidebox { --_track: Field; --_border: ButtonBorder; --_off: FieldText; --_on: Highlight; }
55
62
  }
@@ -0,0 +1,15 @@
1
+ export interface SlideboxDragOptions {
2
+ /** Horizontal movement in CSS pixels before a drag starts. Default: 5. */
3
+ threshold?: number;
4
+ }
5
+
6
+ /**
7
+ * Experimental opt-in pointer dragging for a native checkbox styled by Slidebox.
8
+ * Call after mounting the input. Returns an idempotent cleanup function.
9
+ * Calling again for the same input returns its existing cleanup function;
10
+ * destroy it first to change options. Safe to import during server rendering.
11
+ */
12
+ export declare function enhanceSlidebox(
13
+ input: HTMLInputElement,
14
+ options?: SlideboxDragOptions,
15
+ ): () => void;
@@ -0,0 +1,158 @@
1
+ const enhancements = new WeakMap();
2
+
3
+ /** Opt in a native checkbox to experimental horizontal pointer dragging. */
4
+ export function enhanceSlidebox(input, { threshold = 5 } = {}) {
5
+ if (input?.tagName !== "INPUT" || input.type !== "checkbox") {
6
+ throw new TypeError("enhanceSlidebox expects a checkbox input");
7
+ }
8
+ if (!Number.isFinite(threshold) || threshold < 0) {
9
+ throw new RangeError("threshold must be a non-negative number of pixels");
10
+ }
11
+ if (enhancements.has(input)) return enhancements.get(input);
12
+
13
+ const view = input.ownerDocument.defaultView;
14
+ if (!view?.PointerEvent || typeof input.setPointerCapture !== "function") {
15
+ throw new Error("Slidebox dragging requires a browser with Pointer Events and pointer capture");
16
+ }
17
+ const originalTouchAction = input.style.getPropertyValue("touch-action");
18
+ const originalTouchPriority = input.style.getPropertyPriority("touch-action");
19
+ let gesture;
20
+ let suppressed;
21
+ let suppressionTimer;
22
+ input.style.setProperty("touch-action", "pan-y pinch-zoom");
23
+
24
+ function disabled() { return input.matches(":disabled"); }
25
+ function clearSuppression() {
26
+ suppressed = undefined;
27
+ view.clearTimeout(suppressionTimer);
28
+ }
29
+ function suppressClick(id) {
30
+ clearSuppression();
31
+ suppressed = id;
32
+ suppressionTimer = view.setTimeout(clearSuppression, 500);
33
+ }
34
+ function finish(commit = false) {
35
+ if (!gesture) return;
36
+ const current = gesture;
37
+ gesture = undefined;
38
+ if (current.frame !== undefined) view.cancelAnimationFrame(current.frame);
39
+ if (current.dragging) {
40
+ if (current.attribute === null) input.removeAttribute("data-slidebox-dragging");
41
+ else input.setAttribute("data-slidebox-dragging", current.attribute);
42
+ if (current.progressStyle) input.style.setProperty("--slidebox-drag-progress", current.progressStyle, current.progressPriority);
43
+ else input.style.removeProperty("--slidebox-drag-progress");
44
+ if (current.stretchStyle) input.style.setProperty("--slidebox-drag-stretch", current.stretchStyle, current.stretchPriority);
45
+ else input.style.removeProperty("--slidebox-drag-stretch");
46
+ suppressClick(current.id);
47
+ }
48
+ if (input.hasPointerCapture(current.id)) input.releasePointerCapture(current.id);
49
+ if (commit && current.dragging && !disabled()) {
50
+ const checked = current.progress >= .5;
51
+ if (checked !== input.checked) {
52
+ input.checked = checked;
53
+ input.dispatchEvent(new view.Event("input", { bubbles: true }));
54
+ input.dispatchEvent(new view.Event("change", { bubbles: true }));
55
+ }
56
+ }
57
+ }
58
+ function down(event) {
59
+ if (gesture || disabled() || !event.isPrimary || event.button !== 0) return;
60
+ clearSuppression();
61
+ const box = input.getBoundingClientRect();
62
+ const travel = box.width - box.height;
63
+ if (travel <= 0) return;
64
+ const computed = view.getComputedStyle(input);
65
+ const deformation = Number.parseFloat(computed.getPropertyValue("--_deform")) || 0;
66
+ const durationValue = computed.getPropertyValue("--_duration").trim();
67
+ const duration = (Number.parseFloat(durationValue) || 0) * (durationValue.endsWith("ms") ? 1 : 1000);
68
+ gesture = {
69
+ id: event.pointerId, x: event.clientX, y: event.clientY, travel,
70
+ start: input.checked ? 1 : 0, progress: input.checked ? 1 : 0,
71
+ direction: computed.direction === "rtl" ? -1 : 1,
72
+ deform: view.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? 0 : Math.max(0, Math.min(1, deformation)),
73
+ duration, stretch: 0,
74
+ attribute: input.getAttribute("data-slidebox-dragging"),
75
+ progressStyle: input.style.getPropertyValue("--slidebox-drag-progress"),
76
+ progressPriority: input.style.getPropertyPriority("--slidebox-drag-progress"),
77
+ stretchStyle: input.style.getPropertyValue("--slidebox-drag-stretch"),
78
+ stretchPriority: input.style.getPropertyPriority("--slidebox-drag-stretch"),
79
+ dragging: false,
80
+ };
81
+ input.setPointerCapture(event.pointerId);
82
+ }
83
+ function relax(now) {
84
+ if (!gesture) return;
85
+ const elapsed = gesture.lastFrame === undefined ? 16 : Math.max(0, now - gesture.lastFrame);
86
+ gesture.lastFrame = now;
87
+ gesture.stretch *= Math.exp(-elapsed / Math.max(16, gesture.duration * .35));
88
+ if (Math.abs(gesture.stretch) < .0001) gesture.stretch = 0;
89
+ input.style.setProperty("--slidebox-drag-stretch", String(gesture.stretch));
90
+ gesture.frame = gesture.stretch ? view.requestAnimationFrame(relax) : undefined;
91
+ if (!gesture.stretch) gesture.lastFrame = undefined;
92
+ }
93
+ function move(event) {
94
+ if (!gesture || event.pointerId !== gesture.id) return;
95
+ if (disabled()) { finish(); return; }
96
+ const dx = event.clientX - gesture.x;
97
+ const dy = event.clientY - gesture.y;
98
+ if (!gesture.dragging) {
99
+ // Leave vertical gestures to page scrolling and taps to native clicks.
100
+ if (Math.abs(dy) > threshold && Math.abs(dy) > Math.abs(dx)) { finish(); return; }
101
+ if (Math.abs(dx) <= threshold) return;
102
+ gesture.dragging = true;
103
+ input.setAttribute("data-slidebox-dragging", "");
104
+ input.focus({ preventScroll: true });
105
+ }
106
+ const progress = Math.max(0, Math.min(1, gesture.start + dx * gesture.direction / gesture.travel));
107
+ gesture.stretch = Math.max(-gesture.deform, Math.min(gesture.deform, gesture.stretch + progress - gesture.progress));
108
+ gesture.progress = progress;
109
+ input.style.setProperty("--slidebox-drag-progress", String(gesture.progress));
110
+ input.style.setProperty("--slidebox-drag-stretch", String(gesture.stretch));
111
+ if (gesture.stretch && gesture.frame === undefined) gesture.frame = view.requestAnimationFrame(relax);
112
+ event.preventDefault();
113
+ }
114
+ function up(event) {
115
+ if (!gesture || event.pointerId !== gesture.id) return;
116
+ move(event);
117
+ finish(true);
118
+ }
119
+ function cancel(event) {
120
+ if (gesture && event.pointerId === gesture.id) finish();
121
+ }
122
+ function click(event) {
123
+ // Keyboard activation has detail=0 and must retain native behavior.
124
+ if (suppressed === undefined || event.detail === 0) return;
125
+ if ("pointerId" in event && event.pointerId !== suppressed) return;
126
+ event.preventDefault();
127
+ event.stopImmediatePropagation();
128
+ clearSuppression();
129
+ }
130
+ function reset() { finish(); }
131
+ const form = input.form;
132
+ input.addEventListener("pointerdown", down);
133
+ input.addEventListener("pointermove", move);
134
+ input.addEventListener("pointerup", up);
135
+ input.addEventListener("pointercancel", cancel);
136
+ input.addEventListener("lostpointercapture", cancel);
137
+ input.addEventListener("click", click, true);
138
+ form?.addEventListener("reset", reset);
139
+
140
+ function destroy() {
141
+ finish();
142
+ clearSuppression();
143
+ input.removeEventListener("pointerdown", down);
144
+ input.removeEventListener("pointermove", move);
145
+ input.removeEventListener("pointerup", up);
146
+ input.removeEventListener("pointercancel", cancel);
147
+ input.removeEventListener("lostpointercapture", cancel);
148
+ input.removeEventListener("click", click, true);
149
+ form?.removeEventListener("reset", reset);
150
+ if (input.style.getPropertyValue("touch-action") === "pan-y pinch-zoom") {
151
+ if (originalTouchAction) input.style.setProperty("touch-action", originalTouchAction, originalTouchPriority);
152
+ else input.style.removeProperty("touch-action");
153
+ }
154
+ enhancements.delete(input);
155
+ }
156
+ enhancements.set(input, destroy);
157
+ return destroy;
158
+ }
@@ -0,0 +1,99 @@
1
+ .slidebox-range {
2
+ --_size: var(--slidebox-size, 24px);
3
+ --_spacing: var(--slidebox-spacing, .33);
4
+ --_range-inset: max(0px, calc(var(--_size) * var(--_spacing) * .5));
5
+ --_thumb-size: calc(var(--_size) * (1 - var(--_spacing)));
6
+ --_deform: var(--slidebox-deform, .1);
7
+ --_duration: var(--slidebox-duration, 200ms);
8
+ --_easing: var(--slidebox-easing, ease-in-out);
9
+ --_track: var(--slidebox-track, Field);
10
+ --_border: var(--slidebox-border, ButtonBorder);
11
+ --_on: var(--slidebox-on, var(--_accent, light-dark(#0b75ff, SelectedItem)));
12
+ appearance: none;
13
+ -webkit-appearance: none;
14
+ box-sizing: border-box;
15
+ width: 100%;
16
+ min-width: 0;
17
+ height: var(--_size);
18
+ margin: 0;
19
+ padding-block: 0;
20
+ padding-inline: var(--_range-inset);
21
+ border: 1px solid var(--_border);
22
+ border-radius: var(--slidebox-radius, 100vw);
23
+ background: var(--_track);
24
+ color: var(--_on);
25
+ cursor: pointer;
26
+ accent-color: var(--_on);
27
+ }
28
+ .slidebox-range[data-slidebox-range-fill] {
29
+ --_fill-size: max(0px, min(var(--_thumb-size), calc(var(--_size) - 2px)));
30
+ --_fill-stop: clamp(0px, calc(var(--_thumb-size) * .5 + (100% - var(--_thumb-size)) * clamp(0, var(--slidebox-range-fill, 0), 1)), 100%);
31
+ background-image:
32
+ radial-gradient(circle closest-side, var(--_fill-color, var(--_on)) calc(100% - .5px), transparent 100%),
33
+ linear-gradient(to right, transparent 0 calc(var(--_fill-size) * .5), var(--_fill-color, var(--_on)) calc(var(--_fill-size) * .5) var(--_fill-stop), transparent var(--_fill-stop)),
34
+ var(--_range-track-image, none);
35
+ background-size: var(--_fill-size) var(--_fill-size), max(0px, calc(100% - 2 * var(--_range-inset))) var(--_fill-size), 100vw 100vh;
36
+ background-position: left var(--_range-inset) center, center, 0 0;
37
+ background-repeat: no-repeat;
38
+ background-attachment: scroll, scroll, fixed;
39
+ background-clip: padding-box;
40
+ }
41
+ .slidebox-range[data-slidebox-range-fill]:dir(rtl) {
42
+ background-image:
43
+ radial-gradient(circle closest-side, var(--_fill-color, var(--_on)) calc(100% - .5px), transparent 100%),
44
+ linear-gradient(to left, transparent 0 calc(var(--_fill-size) * .5), var(--_fill-color, var(--_on)) calc(var(--_fill-size) * .5) var(--_fill-stop), transparent var(--_fill-stop)),
45
+ var(--_range-track-image, none);
46
+ background-position: right var(--_range-inset) center, center, 0 0;
47
+ }
48
+ .slidebox-range::-webkit-slider-runnable-track {
49
+ box-sizing: border-box;
50
+ height: calc(var(--_size) - 2px);
51
+ border: 0;
52
+ border-radius: var(--slidebox-radius, 100vw);
53
+ background: transparent;
54
+ }
55
+ .slidebox-range::-moz-range-track {
56
+ box-sizing: border-box;
57
+ height: calc(var(--_size) - 2px);
58
+ border: 0;
59
+ border-radius: var(--slidebox-radius, 100vw);
60
+ background: transparent;
61
+ }
62
+ .slidebox-range::-webkit-slider-thumb {
63
+ appearance: none;
64
+ -webkit-appearance: none;
65
+ box-sizing: border-box;
66
+ width: calc(var(--_size) * (1 - var(--_spacing)));
67
+ height: calc(var(--_size) * (1 - var(--_spacing)));
68
+ margin-top: calc(var(--_size) * var(--_spacing) / 2 - 1px);
69
+ border: 1px solid var(--_track);
70
+ border-radius: var(--slidebox-radius, 100vw);
71
+ background: var(--_on);
72
+ transition: none;
73
+ }
74
+ .slidebox-range::-moz-range-thumb {
75
+ box-sizing: border-box;
76
+ width: calc(var(--_size) * (1 - var(--_spacing)));
77
+ height: calc(var(--_size) * (1 - var(--_spacing)));
78
+ border: 1px solid var(--_track);
79
+ border-radius: var(--slidebox-radius, 100vw);
80
+ background: var(--_on);
81
+ transition: none;
82
+ }
83
+ .slidebox-range:focus-visible { outline: 2px solid var(--_on); outline-offset: 3px; }
84
+ .slidebox-range[data-slidebox-range-dragging]::-webkit-slider-thumb { background: transparent; border-color: transparent; transition: none; }
85
+ .slidebox-range[data-slidebox-range-dragging]::-moz-range-thumb { background: transparent; border-color: transparent; transition: none; }
86
+ .slidebox-range:disabled { cursor: not-allowed; opacity: .5; }
87
+ @supports (color: AccentColor) {
88
+ .slidebox-range { --_accent: AccentColor; }
89
+ }
90
+ @media (forced-colors: active) {
91
+ .slidebox-range { --_track: Field; --_border: ButtonBorder; --_on: Highlight; }
92
+ .slidebox-range::-webkit-slider-thumb { border-color: Highlight; }
93
+ .slidebox-range::-moz-range-thumb { border-color: Highlight; }
94
+ }
95
+ @media (prefers-reduced-motion: reduce) {
96
+ .slidebox-range { transition-duration: .01ms; }
97
+ .slidebox-range::-webkit-slider-thumb { transition-duration: .01ms; }
98
+ .slidebox-range::-moz-range-thumb { transition-duration: .01ms; }
99
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Experimental, opt-in directional deformation for a native Slidebox range.
3
+ * Does not replace native values or emit synthetic input/change events.
4
+ * Call after mounting; call the returned cleanup function before unmounting.
5
+ * Importing during server rendering is safe.
6
+ */
7
+ export declare function enhanceSlideboxRange(input: HTMLInputElement): () => void;
8
+
9
+ /**
10
+ * Experimental progress fill, independent of directional motion.
11
+ * Updates on native input/change and form reset. Call cleanup before unmounting.
12
+ * After programmatic value changes, dispatch an input event to update the fill.
13
+ */
14
+ export declare function enhanceSlideboxRangeFill(input: HTMLInputElement): () => void;
@@ -0,0 +1,213 @@
1
+ const enhancements = new WeakMap();
2
+ const clamp = value => Math.max(0, Math.min(1, value));
3
+
4
+ /** Native value = leading edge; a delayed visual tail supplies the deformation. */
5
+ export function enhanceSlideboxRange(input) {
6
+ if (input?.tagName !== "INPUT" || input.type !== "range") {
7
+ throw new TypeError("enhanceSlideboxRange expects a range input");
8
+ }
9
+ if (enhancements.has(input)) return enhancements.get(input);
10
+ const view = input.ownerDocument.defaultView;
11
+ if (!view?.PointerEvent) throw new Error("Slidebox range motion requires a browser with Pointer Events");
12
+ let motion;
13
+ let previous = progress();
14
+
15
+ function progress() {
16
+ const min = Number.parseFloat(input.min) || 0;
17
+ const parsedMax = Number.parseFloat(input.max);
18
+ const max = Number.isFinite(parsedMax) ? parsedMax : 100;
19
+ return max > min ? clamp((input.valueAsNumber - min) / (max - min)) : 0;
20
+ }
21
+ function stop() {
22
+ if (!motion) return;
23
+ const current = motion;
24
+ motion = undefined;
25
+ current.visual.remove();
26
+ if (current.frame !== undefined) view.cancelAnimationFrame(current.frame);
27
+ if (current.attribute === null) input.removeAttribute("data-slidebox-range-dragging");
28
+ else input.setAttribute("data-slidebox-range-dragging", current.attribute);
29
+ if (current.style) input.style.setProperty("--slidebox-range-stretch", current.style, current.priority);
30
+ else input.style.removeProperty("--slidebox-range-stretch");
31
+ if (current.progressStyle) input.style.setProperty("--slidebox-range-progress", current.progressStyle, current.progressPriority);
32
+ else input.style.removeProperty("--slidebox-range-progress");
33
+ }
34
+ function start() {
35
+ if (motion) return motion;
36
+ if (!input.isConnected || !input.parentElement || input.matches(":disabled") || view.matchMedia("(prefers-reduced-motion: reduce)").matches || view.matchMedia("(forced-colors: active)").matches) return;
37
+ const computed = view.getComputedStyle(input);
38
+ const deform = Math.max(0, Math.min(1, Number.parseFloat(computed.getPropertyValue("--_deform")) || 0));
39
+ const time = computed.getPropertyValue("--_duration").trim();
40
+ const duration = (Number.parseFloat(time) || 0) * (time.endsWith("ms") ? 1 : 1000);
41
+ const box = input.getBoundingClientRect();
42
+ if (!deform || !duration || box.height <= 0 || box.width <= box.height) return;
43
+ // Keep the native thumb's layout/hit box constant. Resizing it changes the
44
+ // browser's pointer-to-value mapping mid-drag and makes the head jump.
45
+ const visual = input.ownerDocument.createElement("span");
46
+ visual.setAttribute("data-slidebox-range-visual", "");
47
+ visual.setAttribute("aria-hidden", "true");
48
+ Object.assign(visual.style, {
49
+ position: "absolute", display: "flex", alignItems: "center",
50
+ boxSizing: "border-box", pointerEvents: "none", zIndex: "1",
51
+ border: "1px solid transparent", padding: "0", margin: "0",
52
+ direction: computed.direction, colorScheme: computed.colorScheme,
53
+ });
54
+ const thumb = input.ownerDocument.createElement("span");
55
+ const size = Number.parseFloat(computed.height);
56
+ const spacing = Number.parseFloat(computed.getPropertyValue("--_spacing")) || 0;
57
+ Object.assign(thumb.style, {
58
+ display: "block", flex: "1 1 auto", minWidth: "0", boxSizing: "border-box",
59
+ height: `${size * (1 - spacing)}px`, width: `${size * (1 - spacing)}px`,
60
+ border: `1px solid ${computed.getPropertyValue("--_track").trim()}`,
61
+ borderRadius: computed.getPropertyValue("--slidebox-radius").trim() || "100vw",
62
+ background: computed.getPropertyValue("--_on").trim(), pointerEvents: "none",
63
+ });
64
+ visual.append(thumb);
65
+ input.after(visual);
66
+ motion = {
67
+ deform, duration, tail: previous, target: previous, direction: 0,
68
+ visual, thumb, size, gap: Math.max(0, size * spacing / 2),
69
+ travelSize: size * (1 - spacing) + 2 * Math.max(0, size * spacing / 2),
70
+ holdUntil: view.performance.now(),
71
+ distanceRatio: (box.width - box.height) / box.height,
72
+ attribute: input.getAttribute("data-slidebox-range-dragging"),
73
+ style: input.style.getPropertyValue("--slidebox-range-stretch"),
74
+ priority: input.style.getPropertyPriority("--slidebox-range-stretch"),
75
+ progressStyle: input.style.getPropertyValue("--slidebox-range-progress"),
76
+ progressPriority: input.style.getPropertyPriority("--slidebox-range-progress"),
77
+ };
78
+ input.setAttribute("data-slidebox-range-dragging", "");
79
+ return motion;
80
+ }
81
+ function draw() {
82
+ // Clamp the tail in progress space: it cannot extend beyond either endpoint.
83
+ const lag = Math.max(-motion.deform, Math.min(motion.deform, motion.target - motion.tail));
84
+ input.style.setProperty("--slidebox-range-stretch", String(lag * motion.distanceRatio));
85
+ input.style.setProperty("--slidebox-range-progress", String(motion.target));
86
+ const tail = motion.target - lag;
87
+ const start = Math.min(motion.target, tail);
88
+ const end = 1 - Math.max(motion.target, tail);
89
+ motion.thumb.style.marginInlineStart = `calc((100% - ${motion.travelSize}px) * ${start} + ${motion.gap}px)`;
90
+ motion.thumb.style.marginInlineEnd = `calc((100% - ${motion.travelSize}px) * ${end} + ${motion.gap}px)`;
91
+ positionVisual();
92
+ }
93
+ function positionVisual() {
94
+ if (!motion) return;
95
+ const rect = input.getBoundingClientRect();
96
+ const parent = motion.visual.offsetParent;
97
+ let left = rect.left + view.scrollX;
98
+ let top = rect.top + view.scrollY;
99
+ if (parent && !(parent === input.ownerDocument.body && view.getComputedStyle(parent).position === "static")) {
100
+ const box = parent.getBoundingClientRect();
101
+ left = rect.left - box.left - parent.clientLeft + parent.scrollLeft;
102
+ top = rect.top - box.top - parent.clientTop + parent.scrollTop;
103
+ }
104
+ Object.assign(motion.visual.style, {
105
+ left: `${left}px`, top: `${top}px`, width: `${rect.width}px`, height: `${rect.height}px`,
106
+ });
107
+ }
108
+ function animate(now) {
109
+ if (!motion) return;
110
+ if (!input.isConnected || input.matches(":disabled") || view.matchMedia("(prefers-reduced-motion: reduce)").matches || view.matchMedia("(forced-colors: active)").matches) { stop(); return; }
111
+ const from = Math.max(motion.lastFrame ?? now, motion.holdUntil);
112
+ const elapsed = Math.max(0, now - from);
113
+ motion.lastFrame = now;
114
+ // The leading edge is always positioned by the browser's native value.
115
+ // Only the tail eases toward that destination after the initial delay.
116
+ motion.tail += (motion.target - motion.tail) * (1 - Math.exp(-elapsed / Math.max(16, motion.duration / 3)));
117
+ if (Math.abs(motion.target - motion.tail) < .0001) { stop(); return; }
118
+ draw();
119
+ motion.frame = view.requestAnimationFrame(animate);
120
+ }
121
+ function changed() {
122
+ const next = progress();
123
+ if (next === previous) return;
124
+ const current = start();
125
+ if (current) {
126
+ const direction = Math.sign(next - current.target);
127
+ if (direction && direction !== current.direction) {
128
+ // On reversal, the old leading edge becomes the new trailing edge.
129
+ current.tail = current.target;
130
+ current.direction = direction;
131
+ current.holdUntil = view.performance.now() + current.duration * current.deform;
132
+ }
133
+ current.target = next;
134
+ draw();
135
+ if (current.frame === undefined) current.frame = view.requestAnimationFrame(animate);
136
+ }
137
+ previous = next;
138
+ }
139
+ function baseline() { if (!motion) previous = progress(); }
140
+ function cancel() { stop(); previous = progress(); }
141
+ input.addEventListener("pointerdown", baseline);
142
+ input.addEventListener("keydown", baseline);
143
+ input.addEventListener("input", changed);
144
+ input.addEventListener("pointercancel", cancel);
145
+ view.addEventListener("blur", cancel);
146
+ view.addEventListener("scroll", positionVisual, true);
147
+ view.addEventListener("resize", positionVisual);
148
+ const form = input.form;
149
+ form?.addEventListener("reset", cancel);
150
+
151
+ function destroy() {
152
+ cancel();
153
+ input.removeEventListener("pointerdown", baseline);
154
+ input.removeEventListener("keydown", baseline);
155
+ input.removeEventListener("input", changed);
156
+ input.removeEventListener("pointercancel", cancel);
157
+ view.removeEventListener("blur", cancel);
158
+ view.removeEventListener("scroll", positionVisual, true);
159
+ view.removeEventListener("resize", positionVisual);
160
+ form?.removeEventListener("reset", cancel);
161
+ enhancements.delete(input);
162
+ }
163
+ enhancements.set(input, destroy);
164
+ return destroy;
165
+ }
166
+
167
+ const fills = new WeakMap();
168
+
169
+ /** Optional progress paint; independent of directional motion. */
170
+ export function enhanceSlideboxRangeFill(input) {
171
+ if (input?.tagName !== "INPUT" || input.type !== "range") {
172
+ throw new TypeError("enhanceSlideboxRangeFill expects a range input");
173
+ }
174
+ if (fills.has(input)) return fills.get(input);
175
+ const view = input.ownerDocument.defaultView;
176
+ if (!view) throw new Error("Slidebox range fill requires a mounted browser document");
177
+ const attribute = input.getAttribute("data-slidebox-range-fill");
178
+ const value = input.style.getPropertyValue("--slidebox-range-fill");
179
+ const priority = input.style.getPropertyPriority("--slidebox-range-fill");
180
+ let resetFrame;
181
+ function update() {
182
+ const min = Number.parseFloat(input.min) || 0;
183
+ const parsedMax = Number.parseFloat(input.max);
184
+ const max = Number.isFinite(parsedMax) ? parsedMax : 100;
185
+ const progress = max > min ? clamp((input.valueAsNumber - min) / (max - min)) : 0;
186
+ input.style.setProperty("--slidebox-range-fill", String(progress));
187
+ }
188
+ function reset() {
189
+ view.cancelAnimationFrame(resetFrame);
190
+ // Native reset applies defaultValue after dispatching the reset event.
191
+ resetFrame = view.requestAnimationFrame(update);
192
+ }
193
+ input.setAttribute("data-slidebox-range-fill", "");
194
+ input.addEventListener("input", update);
195
+ input.addEventListener("change", update);
196
+ const form = input.form;
197
+ form?.addEventListener("reset", reset);
198
+ update();
199
+
200
+ function destroy() {
201
+ view.cancelAnimationFrame(resetFrame);
202
+ input.removeEventListener("input", update);
203
+ input.removeEventListener("change", update);
204
+ form?.removeEventListener("reset", reset);
205
+ if (attribute === null) input.removeAttribute("data-slidebox-range-fill");
206
+ else input.setAttribute("data-slidebox-range-fill", attribute);
207
+ if (value) input.style.setProperty("--slidebox-range-fill", value, priority);
208
+ else input.style.removeProperty("--slidebox-range-fill");
209
+ fills.delete(input);
210
+ }
211
+ fills.set(input, destroy);
212
+ return destroy;
213
+ }