@marianmeres/stuic 3.146.0 → 3.148.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.
@@ -10,37 +10,38 @@ variant for custom UI (volume/brightness controls, dashboards, media players).
10
10
 
11
11
  ## Props
12
12
 
13
- | Prop | Type | Default | Description |
14
- | --------------- | ------------------------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
15
- | `value` | `number` | `min` | Current value (bindable; non-finite / out-of-range / off-grid writes are normalized back) |
16
- | `min` | `number` | `0` | Minimum value |
17
- | `max` | `number` | `100` | Maximum value |
18
- | `step` | `number \| "any"` | `1` | Snap increment (`"any"` or non-positive = continuous) |
19
- | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Slider direction (vertical fills bottom-up) |
20
- | `size` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Cross-axis thickness preset |
21
- | `intent` | `"primary" \| "accent" \| "success" \| "warning" \| "destructive"` | - | Semantic fill color |
22
- | `thumb` | `boolean \| Snippet<[SliderRenderCtx]>` | `true` | `false` hides the thumb (fill-only look), snippet renders inside thumb |
23
- | `thumbPosition` | `"value" \| "start"` | `"value"` | `"value"` rides the fill edge; `"start"` pins it to the left/bottom so only the bar moves (true iOS volume look) |
24
- | `fillRounded` | `boolean` | `false` | Round the fill's leading edge ("pill inside a pill") |
25
- | `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 |
26
- | `valueLabel` | `Snippet<[SliderRenderCtx]>` | - | Floating label at the current value along the track |
27
- | `disabled` | `boolean` | `false` | Disable interaction |
28
- | `label` | `string` | - | Screen reader label for the underlying input |
29
- | `id` | `string` | - | Id for the underlying input (enables `<label for>` association) |
30
- | `name` | `string` | - | Form field name for the hidden range input |
31
- | `required` | `boolean` | `false` | Forwarded to the input; per HTML spec inert on range inputs (use `validate.customValidator` for custom rules) |
32
- | `oninput` | `(value: number) => void` | - | Fires on every value change (drag, keyboard) |
33
- | `onchange` | `(value: number) => void` | - | Fires when a change is committed (drag release, keyboard) |
34
- | `validate` | `boolean \| ValidateOptions` | - | Enable validation (stuic validate action) |
35
- | `unstyled` | `boolean` | `false` | Skip all default styling |
36
- | `class` | `string` | - | Classes for the root element |
37
- | `trackClass` | `string` | - | Classes for the track (pill background) |
38
- | `fillClass` | `string` | - | Classes for the fill (value indicator) |
39
- | `thumbClass` | `string` | - | Classes for the thumb |
40
- | `tickClass` | `string` | - | Classes for each tick mark |
41
- | `valueClass` | `string` | - | Classes for the value label wrapper |
42
- | `el` | `HTMLDivElement` | - | Root element reference (bindable) |
43
- | `inputEl` | `HTMLInputElement` | - | Hidden range input reference (bindable) |
13
+ | Prop | Type | Default | Description |
14
+ | --------------- | ------------------------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `value` | `number` | `min` | Current value (bindable; non-finite / out-of-range / off-grid writes are normalized back) |
16
+ | `min` | `number` | `0` | Minimum value |
17
+ | `max` | `number` | `100` | Maximum value |
18
+ | `step` | `number \| "any"` | `1` | Snap increment (`"any"` or non-positive = continuous) |
19
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Slider direction (vertical fills bottom-up) |
20
+ | `size` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Cross-axis thickness preset |
21
+ | `intent` | `"primary" \| "accent" \| "success" \| "warning" \| "destructive"` | - | Semantic fill color |
22
+ | `thumb` | `boolean \| Snippet<[SliderRenderCtx]>` | `true` | `false` hides the thumb (fill-only look), snippet renders inside thumb |
23
+ | `thumbPosition` | `"value" \| "start"` | `"value"` | `"value"` rides the fill edge; `"start"` pins it to the left/bottom so only the bar moves (true iOS volume look) |
24
+ | `thumbReserve` | `boolean` | `true` | Reserve the thumb's footprint along the track, so the fill never shrinks below the thumb. `false` = linear mapping across the full track (fill collapses to zero at `min`); only honored with `thumbPosition="start"` |
25
+ | `fillRounded` | `boolean` | `false` | Round the fill's leading edge ("pill inside a pill") |
26
+ | `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 |
27
+ | `valueLabel` | `Snippet<[SliderRenderCtx]>` | - | Floating label at the current value along the track |
28
+ | `disabled` | `boolean` | `false` | Disable interaction |
29
+ | `label` | `string` | - | Screen reader label for the underlying input |
30
+ | `id` | `string` | - | Id for the underlying input (enables `<label for>` association) |
31
+ | `name` | `string` | - | Form field name for the hidden range input |
32
+ | `required` | `boolean` | `false` | Forwarded to the input; per HTML spec inert on range inputs (use `validate.customValidator` for custom rules) |
33
+ | `oninput` | `(value: number) => void` | - | Fires on every value change (drag, keyboard) |
34
+ | `onchange` | `(value: number) => void` | - | Fires when a change is committed (drag release, keyboard) |
35
+ | `validate` | `boolean \| ValidateOptions` | - | Enable validation (stuic validate action) |
36
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
37
+ | `class` | `string` | - | Classes for the root element |
38
+ | `trackClass` | `string` | - | Classes for the track (pill background) |
39
+ | `fillClass` | `string` | - | Classes for the fill (value indicator) |
40
+ | `thumbClass` | `string` | - | Classes for the thumb |
41
+ | `tickClass` | `string` | - | Classes for each tick mark |
42
+ | `valueClass` | `string` | - | Classes for the value label wrapper |
43
+ | `el` | `HTMLDivElement` | - | Root element reference (bindable) |
44
+ | `inputEl` | `HTMLInputElement` | - | Hidden range input reference (bindable) |
44
45
 
45
46
  `SliderRenderCtx` (passed to the `thumb` and `valueLabel` snippets):
46
47
  `{ value: number; ratio: number /* 0..1 */; percent: number /* 0..100 */; dragging: boolean }`
@@ -92,6 +93,9 @@ Remaining props are spread onto the root `<div>`.
92
93
  <Slider thumb={false} bind:value={brightness} />
93
94
  ```
94
95
 
96
+ With no thumb there is nothing to reserve: the value maps linearly across the whole
97
+ track and the fill collapses to zero at `min`.
98
+
95
99
  ### True iOS volume: fixed icon at the start, only the bar moves
96
100
 
97
101
  ```svelte
@@ -116,8 +120,19 @@ Remaining props are spread onto the root `<div>`.
116
120
  </Slider>
117
121
  ```
118
122
 
119
- With `thumbPosition="start"` the thumb is decorative (`pointer-events: none`) and the
120
- value maps linearly across the full track there is no thumb-travel inset.
123
+ With `thumbPosition="start"` the thumb never moves only the bar does. It is purely
124
+ decorative (the full-size hidden input is always the topmost hit target, so the thumb
125
+ never receives pointer events), but it still **reserves its footprint** along the track:
126
+ the fill can never shrink below it, and at `min` it is exactly a thumb-sized nub — with
127
+ `fillRounded`, a circle enclosing the pinned thumb, same as the default
128
+ `thumbPosition="value"` look.
129
+
130
+ Opt out with `thumbReserve={false}` for a linear mapping across the full track, where
131
+ the fill collapses to zero at `min` (and, at low values, is shorter than the thumb):
132
+
133
+ ```svelte
134
+ <Slider thumbPosition="start" thumbReserve={false} bind:value={volume} />
135
+ ```
121
136
 
122
137
  ### Steps and ticks
123
138
 
@@ -156,6 +171,10 @@ value maps linearly across the full track — there is no thumb-travel inset.
156
171
  the thumb itself does not jump (drag continues from the grab point).
157
172
  - **Keyboard**: focus and use Arrow keys / PageUp / PageDown / Home / End — native
158
173
  `input[type=range]` behavior (the real input is visually hidden but focusable).
174
+ - **Thumb reserve**: with a thumb rendered (traveling or pinned) the value maps onto the
175
+ thumb-center travel — the outer half-thumb at each end of the track resolves to
176
+ `min`/`max`, and the fill never shrinks below the thumb. `thumb={false}` or
177
+ `thumbReserve={false}` maps linearly across the whole track instead.
159
178
  - **Vertical**: bottom is `min`, top is `max`; ArrowUp increases.
160
179
  - **RTL**: horizontal sliders flip automatically (logical CSS properties + pointer math).
161
180
  - **Commit semantics**: `oninput` fires only on actual value changes; `onchange` only
@@ -89,6 +89,16 @@
89
89
  * the true iOS volume look with a fixed icon.
90
90
  */
91
91
  thumbPosition?: SliderThumbPosition;
92
+ /**
93
+ * Reserve the thumb's footprint at both ends of the track: the value maps
94
+ * onto the thumb-center travel, so the fill can never shrink below the
95
+ * thumb (at `min` it is exactly a thumb-sized nub). `false` maps the value
96
+ * linearly across the whole track instead, letting the fill collapse to
97
+ * zero — only honored with `thumbPosition="start"` (a traveling thumb's
98
+ * position is defined against the reserved travel) and irrelevant without
99
+ * a thumb (`thumb={false}` never reserves).
100
+ */
101
+ thumbReserve?: boolean;
92
102
  /**
93
103
  * Round the fill's leading edge (instead of a flat cut), giving the
94
104
  * "pill inside a pill" look.
@@ -141,6 +151,7 @@
141
151
  valueClass,
142
152
  thumb = true,
143
153
  thumbPosition = "value",
154
+ thumbReserve = true,
144
155
  fillRounded = false,
145
156
  ticks,
146
157
  valueLabel,
@@ -165,9 +176,18 @@
165
176
  // Value at drag start — commit (change) fires only if the drag changed it.
166
177
  let _dragStartValue = 0;
167
178
 
168
- // Only a value-tracking thumb insets the usable travel; a start-pinned thumb
169
- // (or none) lets the fill map linearly across the whole track.
179
+ // Two related but distinct concepts:
180
+ // - _thumbTravels: the thumb rides the fill edge (drives only its CSS position
181
+ // and the grab-offset detection).
182
+ // - _thumbReserved: the thumb's footprint is reserved at both ends, so the
183
+ // value maps onto the thumb-center travel and the fill/ticks/value label
184
+ // follow that same travel. This is what keeps the fill from ever shrinking
185
+ // below the thumb (at min it is exactly a thumb-sized nub).
186
+ // A traveling thumb always reserves (its position is defined against that
187
+ // travel); a pinned one reserves by default but can opt out via
188
+ // `thumbReserve={false}`, and no thumb never reserves.
170
189
  let _thumbTravels = $derived(thumb !== false && thumbPosition === "value");
190
+ let _thumbReserved = $derived(thumb !== false && (_thumbTravels || thumbReserve));
171
191
 
172
192
  // Bounds must be finite before anything else: a NaN bound would make every
173
193
  // normalization write NaN, and NaN !== NaN would re-trigger the effect below
@@ -258,12 +278,12 @@
258
278
  const rtl = horizontal && getComputedStyle(el!).direction === "rtl";
259
279
  const trackLen = horizontal ? rect.width : rect.height;
260
280
  const thickness = horizontal ? rect.height : rect.width;
261
- // With a thumb, the thumb center travels within [thickness/2, len - thickness/2]
262
- // (mirrors the CSS `(100% - thickness) * ratio` positioning); without it, the
263
- // value maps linearly across the whole track. NOTE: this assumes the rendered
264
- // cross-axis size equals --_thickness — size the cross-axis via the size
265
- // presets or --stuic-slider-thickness, not via utility classes (see README).
266
- const pad = _thumbTravels ? thickness / 2 : 0;
281
+ // With a reserved thumb the value maps onto [thickness/2, len - thickness/2]
282
+ // (mirrors the CSS `(100% - thickness) * ratio` sizing/positioning); without
283
+ // the reserve it maps linearly across the whole track. NOTE: this assumes the
284
+ // rendered cross-axis size equals --_thickness — size the cross-axis via the
285
+ // size presets or --stuic-slider-thickness, not via utility classes (see README).
286
+ const pad = _thumbReserved ? thickness / 2 : 0;
267
287
  const travel = Math.max(1, trackLen - 2 * pad);
268
288
  return { horizontal, rtl, rect, trackLen, pad, travel };
269
289
  }
@@ -426,6 +446,7 @@
426
446
  data-thumb={thumb !== false ? "true" : "false"}
427
447
  data-thumb-position={thumb !== false ? thumbPosition : undefined}
428
448
  data-thumb-travels={_thumbTravels ? "true" : "false"}
449
+ data-thumb-reserved={_thumbReserved ? "true" : "false"}
429
450
  data-fill-rounded={fillRounded ? "true" : undefined}
430
451
  data-size={!unstyled ? size : undefined}
431
452
  data-intent={!unstyled ? intent : undefined}
@@ -69,6 +69,16 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children" |
69
69
  * the true iOS volume look with a fixed icon.
70
70
  */
71
71
  thumbPosition?: SliderThumbPosition;
72
+ /**
73
+ * Reserve the thumb's footprint at both ends of the track: the value maps
74
+ * onto the thumb-center travel, so the fill can never shrink below the
75
+ * thumb (at `min` it is exactly a thumb-sized nub). `false` maps the value
76
+ * linearly across the whole track instead, letting the fill collapse to
77
+ * zero — only honored with `thumbPosition="start"` (a traveling thumb's
78
+ * position is defined against the reserved travel) and irrelevant without
79
+ * a thumb (`thumb={false}` never reserves).
80
+ */
81
+ thumbReserve?: boolean;
72
82
  /**
73
83
  * Round the fill's leading edge (instead of a flat cut), giving the
74
84
  * "pill inside a pill" look.
@@ -116,8 +116,12 @@
116
116
  position: absolute;
117
117
  background: var(--_fill);
118
118
  }
119
- /* With a value-tracking thumb, the fill reaches the far edge of the thumb */
120
- .stuic-slider[data-thumb-travels="true"] {
119
+ /* With a reserved thumb (traveling OR pinned), the fill spans the thumb-center
120
+ travel plus the thumb itself: it reaches the far edge of a traveling thumb and,
121
+ at ratio 0, is exactly one thickness — a thumb-sized nub that the fill can
122
+ never shrink below. `thumbReserve={false}` (and no thumb) falls back to the
123
+ plain linear `ratio * 100%` above, which collapses to zero at min. */
124
+ .stuic-slider[data-thumb-reserved="true"] {
121
125
  --_fill-len: calc(var(--_ratio) * (100% - var(--_thickness)) + var(--_thickness));
122
126
  }
123
127
  .stuic-slider[data-orientation="horizontal"] .fill {
@@ -164,7 +168,9 @@
164
168
  bottom: var(--_thumb-inset);
165
169
  }
166
170
  /* thumbPosition="value": ride the fill edge (default). thumbPosition="start"
167
- keeps the offsets above, so only the bar moves (iOS volume look). */
171
+ keeps the offsets above, so only the bar moves (iOS volume look) — note this
172
+ is data-thumb-travels, NOT data-thumb-reserved: a pinned thumb reserves the
173
+ travel (for the fill/ticks/value label) without moving itself. */
168
174
  .stuic-slider[data-thumb-travels="true"][data-orientation="horizontal"] > .thumb {
169
175
  inset-inline-start: calc(
170
176
  var(--_thumb-inset) + (100% - var(--_thickness)) * var(--_ratio)
@@ -234,7 +240,7 @@
234
240
  border-radius: 9999px;
235
241
  background: var(--_tick);
236
242
  }
237
- /* Without a traveling thumb there is no thickness inset to hide behind, so the
243
+ /* Without a reserved thumb there is no thickness inset to hide behind, so the
238
244
  travel is inset by half a tick — otherwise the ticks at ratio 0 and 1 sit
239
245
  centered on the track edges and the overflow:hidden track halves them. */
240
246
  .stuic-slider[data-orientation="horizontal"] .tick {
@@ -249,13 +255,13 @@
249
255
  bottom: calc(var(--_tick-size) / 2 + (100% - var(--_tick-size)) * var(--_tick-ratio));
250
256
  transform: translate(-50%, 50%);
251
257
  }
252
- /* With a value-tracking thumb, ticks align to the thumb-center travel */
253
- .stuic-slider[data-thumb-travels="true"][data-orientation="horizontal"] .tick {
258
+ /* With a reserved thumb, ticks align to the thumb-center travel */
259
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="horizontal"] .tick {
254
260
  inset-inline-start: calc(
255
261
  var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_tick-ratio)
256
262
  );
257
263
  }
258
- .stuic-slider[data-thumb-travels="true"][data-orientation="vertical"] .tick {
264
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="vertical"] .tick {
259
265
  bottom: calc(var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_tick-ratio));
260
266
  }
261
267
  /* Logical-property positioning + physical translate: flip centering in RTL */
@@ -284,12 +290,12 @@
284
290
  bottom: calc(var(--_ratio) * 100%);
285
291
  transform: translateY(50%);
286
292
  }
287
- .stuic-slider[data-thumb-travels="true"][data-orientation="horizontal"] > .value {
293
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="horizontal"] > .value {
288
294
  inset-inline-start: calc(
289
295
  var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_ratio)
290
296
  );
291
297
  }
292
- .stuic-slider[data-thumb-travels="true"][data-orientation="vertical"] > .value {
298
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="vertical"] > .value {
293
299
  bottom: calc(var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_ratio));
294
300
  }
295
301
  .stuic-slider[data-orientation="horizontal"]:dir(rtl) > .value {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.146.0",
3
+ "version": "3.148.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",