@zag-js/slider 0.23.0 → 0.25.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.
@@ -1,13 +1,25 @@
1
1
  import { createMachine } from "@zag-js/core"
2
2
  import { trackPointerMove } from "@zag-js/dom-event"
3
3
  import { raf } from "@zag-js/dom-query"
4
- import { trackElementSize } from "@zag-js/element-size"
4
+ import { trackElementsSize, type ElementSize } from "@zag-js/element-size"
5
5
  import { trackFormControl } from "@zag-js/form-utils"
6
- import { clampValue, getValuePercent } from "@zag-js/numeric-range"
6
+ import { getValuePercent } from "@zag-js/numeric-range"
7
7
  import { compact, isEqual } from "@zag-js/utils"
8
8
  import { dom } from "./slider.dom"
9
9
  import type { MachineContext, MachineState, UserDefinedContext } from "./slider.types"
10
- import { constrainValue, decrement, increment } from "./slider.utils"
10
+ import {
11
+ assignArray,
12
+ constrainValue,
13
+ decrement,
14
+ getClosestIndex,
15
+ getRangeAtIndex,
16
+ increment,
17
+ normalizeValues,
18
+ } from "./slider.utils"
19
+
20
+ const isEqualSize = (a: ElementSize | null, b: ElementSize | null) => {
21
+ return a?.width === b?.width && a?.height === b?.height
22
+ }
11
23
 
12
24
  export function machine(userContext: UserDefinedContext) {
13
25
  const ctx = compact(userContext)
@@ -15,17 +27,19 @@ export function machine(userContext: UserDefinedContext) {
15
27
  {
16
28
  id: "slider",
17
29
  initial: "idle",
30
+
18
31
  context: {
19
32
  thumbSize: null,
20
33
  thumbAlignment: "contain",
21
- threshold: 5,
22
- dir: "ltr",
23
- origin: "start",
24
- orientation: "horizontal",
25
- value: 0,
26
- step: 1,
34
+ focusedIndex: -1,
27
35
  min: 0,
28
36
  max: 100,
37
+ step: 1,
38
+ value: [0],
39
+ origin: "start",
40
+ orientation: "horizontal",
41
+ dir: "ltr",
42
+ minStepsBetweenThumbs: 0,
29
43
  disabled: false,
30
44
  ...ctx,
31
45
  fieldsetDisabled: false,
@@ -35,92 +49,103 @@ export function machine(userContext: UserDefinedContext) {
35
49
  isHorizontal: (ctx) => ctx.orientation === "horizontal",
36
50
  isVertical: (ctx) => ctx.orientation === "vertical",
37
51
  isRtl: (ctx) => ctx.orientation === "horizontal" && ctx.dir === "rtl",
38
- isDisabled: (ctx) => ctx.disabled || ctx.fieldsetDisabled,
39
- isInteractive: (ctx) => !(ctx.isDisabled || ctx.readOnly),
40
- hasMeasuredThumbSize: (ctx) => ctx.thumbSize !== null,
41
- valuePercent: (ctx) => 100 * getValuePercent(ctx.value, ctx.min, ctx.max),
52
+ isDisabled: (ctx) => !!ctx.disabled || ctx.fieldsetDisabled,
53
+ isInteractive: (ctx) => !(ctx.readOnly || ctx.isDisabled),
54
+ spacing: (ctx) => ctx.minStepsBetweenThumbs * ctx.step,
55
+ hasMeasuredThumbSize: (ctx) => ctx.thumbSize != null,
56
+ valuePercent(ctx) {
57
+ return ctx.value.map((value) => 100 * getValuePercent(value, ctx.min, ctx.max))
58
+ },
42
59
  },
43
60
 
44
61
  watch: {
45
- value: ["syncInputElement"],
62
+ value: ["syncInputElements"],
46
63
  },
47
64
 
48
- activities: ["trackFormControlState", "trackThumbSize"],
65
+ entry: ["coarseValue"],
66
+
67
+ activities: ["trackFormControlState", "trackThumbsSize"],
49
68
 
50
69
  on: {
51
- SET_VALUE: {
52
- actions: "setValue",
53
- },
70
+ SET_VALUE: [
71
+ {
72
+ guard: "hasIndex",
73
+ actions: "setValueAtIndex",
74
+ },
75
+ { actions: "setValue" },
76
+ ],
54
77
  INCREMENT: {
55
- actions: "increment",
78
+ actions: "incrementAtIndex",
56
79
  },
57
80
  DECREMENT: {
58
- actions: "decrement",
81
+ actions: "decrementAtIndex",
59
82
  },
60
83
  },
61
84
 
62
- entry: ["checkValue"],
63
-
64
85
  states: {
65
86
  idle: {
66
87
  on: {
67
88
  POINTER_DOWN: {
68
89
  target: "dragging",
69
- actions: ["setPointerValue", "invokeOnChangeStart", "focusThumb"],
90
+ actions: ["setClosestThumbIndex", "setPointerValue", "focusActiveThumb"],
91
+ },
92
+ FOCUS: {
93
+ target: "focus",
94
+ actions: "setFocusedIndex",
70
95
  },
71
- FOCUS: "focus",
72
96
  THUMB_POINTER_DOWN: {
73
97
  target: "dragging",
74
- actions: ["invokeOnChangeStart", "focusThumb"],
98
+ actions: ["setFocusedIndex", "focusActiveThumb"],
75
99
  },
76
100
  },
77
101
  },
78
-
79
102
  focus: {
80
- entry: "focusThumb",
103
+ entry: "focusActiveThumb",
81
104
  on: {
82
105
  POINTER_DOWN: {
83
106
  target: "dragging",
84
- actions: ["setPointerValue", "invokeOnChangeStart", "focusThumb"],
107
+ actions: ["setClosestThumbIndex", "setPointerValue", "focusActiveThumb"],
85
108
  },
86
109
  THUMB_POINTER_DOWN: {
87
110
  target: "dragging",
88
- actions: ["invokeOnChangeStart", "focusThumb"],
111
+ actions: ["setFocusedIndex", "focusActiveThumb"],
89
112
  },
90
113
  ARROW_LEFT: {
91
114
  guard: "isHorizontal",
92
- actions: "decrement",
115
+ actions: "decrementAtIndex",
93
116
  },
94
117
  ARROW_RIGHT: {
95
118
  guard: "isHorizontal",
96
- actions: "increment",
119
+ actions: "incrementAtIndex",
97
120
  },
98
121
  ARROW_UP: {
99
122
  guard: "isVertical",
100
- actions: "increment",
123
+ actions: "incrementAtIndex",
101
124
  },
102
125
  ARROW_DOWN: {
103
126
  guard: "isVertical",
104
- actions: "decrement",
127
+ actions: "decrementAtIndex",
105
128
  },
106
129
  PAGE_UP: {
107
- actions: "increment",
130
+ actions: "incrementAtIndex",
108
131
  },
109
132
  PAGE_DOWN: {
110
- actions: "decrement",
133
+ actions: "decrementAtIndex",
111
134
  },
112
135
  HOME: {
113
- actions: "setToMin",
136
+ actions: "setActiveThumbToMin",
114
137
  },
115
138
  END: {
116
- actions: "setToMax",
139
+ actions: "setActiveThumbToMax",
140
+ },
141
+ BLUR: {
142
+ target: "idle",
143
+ actions: "clearFocusedIndex",
117
144
  },
118
- BLUR: "idle",
119
145
  },
120
146
  },
121
-
122
147
  dragging: {
123
- entry: "focusThumb",
148
+ entry: "focusActiveThumb",
124
149
  activities: "trackPointerMove",
125
150
  on: {
126
151
  POINTER_UP: {
@@ -138,11 +163,11 @@ export function machine(userContext: UserDefinedContext) {
138
163
  guards: {
139
164
  isHorizontal: (ctx) => ctx.isHorizontal,
140
165
  isVertical: (ctx) => ctx.isVertical,
166
+ hasIndex: (_ctx, evt) => evt.index != null,
141
167
  },
142
-
143
168
  activities: {
144
169
  trackFormControlState(ctx, _evt, { initialContext }) {
145
- return trackFormControl(dom.getHiddenInputEl(ctx), {
170
+ return trackFormControl(dom.getRootEl(ctx), {
146
171
  onFieldsetDisabledChange(disabled) {
147
172
  ctx.fieldsetDisabled = disabled
148
173
  },
@@ -162,53 +187,82 @@ export function machine(userContext: UserDefinedContext) {
162
187
  },
163
188
  })
164
189
  },
165
- trackThumbSize(ctx, _evt) {
190
+ trackThumbsSize(ctx) {
166
191
  if (ctx.thumbAlignment !== "contain" || ctx.thumbSize) return
167
- return trackElementSize(dom.getThumbEl(ctx), (size) => {
168
- if (size) ctx.thumbSize = size
192
+
193
+ return trackElementsSize({
194
+ getNodes: () => dom.getElements(ctx),
195
+ observeMutation: true,
196
+ callback(size) {
197
+ if (!size || isEqualSize(ctx.thumbSize, size)) return
198
+ ctx.thumbSize = size
199
+ },
169
200
  })
170
201
  },
171
202
  },
172
-
173
203
  actions: {
174
- checkValue(ctx) {
175
- ctx.value = constrainValue(ctx, ctx.value)
176
- },
177
- invokeOnChangeStart(ctx) {
178
- ctx.onValueChangeStart?.({ value: ctx.value })
204
+ syncInputElements(ctx) {
205
+ ctx.value.forEach((value, index) => {
206
+ const inputEl = dom.getHiddenInputEl(ctx, index)
207
+ dom.setValue(inputEl, value)
208
+ })
179
209
  },
180
210
  invokeOnChangeEnd(ctx) {
181
211
  ctx.onValueChangeEnd?.({ value: ctx.value })
182
212
  },
213
+ setClosestThumbIndex(ctx, evt) {
214
+ const pointValue = dom.getValueFromPoint(ctx, evt.point)
215
+ if (pointValue == null) return
216
+
217
+ const focusedIndex = getClosestIndex(ctx, pointValue)
218
+ set.focusedIndex(ctx, focusedIndex)
219
+ },
220
+ setFocusedIndex(ctx, evt) {
221
+ set.focusedIndex(ctx, evt.index)
222
+ },
223
+ clearFocusedIndex(ctx) {
224
+ set.focusedIndex(ctx, -1)
225
+ },
183
226
  setPointerValue(ctx, evt) {
184
- const value = dom.getValueFromPoint(ctx, evt.point)
185
- if (value == null) return
186
- set.value(ctx, clampValue(value, ctx.min, ctx.max))
227
+ const pointerValue = dom.getValueFromPoint(ctx, evt.point)
228
+ if (pointerValue == null) return
229
+
230
+ const value = constrainValue(ctx, pointerValue, ctx.focusedIndex)
231
+ set.valueAtIndex(ctx, ctx.focusedIndex, value)
187
232
  },
188
- focusThumb(ctx) {
189
- raf(() => dom.getThumbEl(ctx)?.focus({ preventScroll: true }))
233
+ focusActiveThumb(ctx) {
234
+ raf(() => {
235
+ const thumbEl = dom.getThumbEl(ctx, ctx.focusedIndex)
236
+ thumbEl?.focus({ preventScroll: true })
237
+ })
190
238
  },
191
- decrement(ctx, evt) {
192
- const value = decrement(ctx, evt.step)
239
+ decrementAtIndex(ctx, evt) {
240
+ const value = decrement(ctx, evt.index, evt.step)
193
241
  set.value(ctx, value)
194
242
  },
195
- increment(ctx, evt) {
196
- const value = increment(ctx, evt.step)
243
+ incrementAtIndex(ctx, evt) {
244
+ const value = increment(ctx, evt.index, evt.step)
197
245
  set.value(ctx, value)
198
246
  },
199
- setToMin(ctx) {
200
- set.value(ctx, ctx.min)
247
+ setActiveThumbToMin(ctx) {
248
+ const { min } = getRangeAtIndex(ctx, ctx.focusedIndex)
249
+ set.valueAtIndex(ctx, ctx.focusedIndex, min)
201
250
  },
202
- setToMax(ctx) {
203
- set.value(ctx, ctx.max)
251
+ setActiveThumbToMax(ctx) {
252
+ const { max } = getRangeAtIndex(ctx, ctx.focusedIndex)
253
+ set.valueAtIndex(ctx, ctx.focusedIndex, max)
204
254
  },
205
- setValue(ctx, evt) {
206
- const value = constrainValue(ctx, evt.value)
255
+ coarseValue(ctx) {
256
+ const value = normalizeValues(ctx, ctx.value)
207
257
  set.value(ctx, value)
208
258
  },
209
- syncInputElement(ctx) {
210
- const inputEl = dom.getHiddenInputEl(ctx)
211
- dom.setValue(inputEl, ctx.value)
259
+ setValueAtIndex(ctx, evt) {
260
+ const value = constrainValue(ctx, evt.value, evt.index)
261
+ set.valueAtIndex(ctx, evt.index, value)
262
+ },
263
+ setValue(ctx, evt) {
264
+ const value = normalizeValues(ctx, evt.value)
265
+ set.value(ctx, value)
212
266
  },
213
267
  },
214
268
  },
@@ -217,15 +271,33 @@ export function machine(userContext: UserDefinedContext) {
217
271
 
218
272
  const invoke = {
219
273
  change: (ctx: MachineContext) => {
220
- ctx.onValueChange?.({ value: ctx.value })
274
+ ctx.onValueChange?.({
275
+ value: Array.from(ctx.value),
276
+ })
221
277
  dom.dispatchChangeEvent(ctx)
222
278
  },
279
+ focusChange: (ctx: MachineContext) => {
280
+ ctx.onFocusChange?.({
281
+ value: Array.from(ctx.value),
282
+ focusedIndex: ctx.focusedIndex,
283
+ })
284
+ },
223
285
  }
224
286
 
225
287
  const set = {
226
- value: (ctx: MachineContext, value: number) => {
288
+ valueAtIndex: (ctx: MachineContext, index: number, value: number) => {
289
+ if (isEqual(ctx.value[index], value)) return
290
+ ctx.value[index] = value
291
+ invoke.change(ctx)
292
+ },
293
+ value: (ctx: MachineContext, value: number[]) => {
227
294
  if (isEqual(ctx.value, value)) return
228
- ctx.value = value
295
+ assignArray(ctx.value, value)
229
296
  invoke.change(ctx)
230
297
  },
298
+ focusedIndex: (ctx: MachineContext, index: number) => {
299
+ if (isEqual(ctx.focusedIndex, index)) return
300
+ ctx.focusedIndex = index
301
+ invoke.focusChange(ctx)
302
+ },
231
303
  }
@@ -2,6 +2,50 @@ import { getValuePercent, getValueTransformer } from "@zag-js/numeric-range"
2
2
  import type { Style } from "@zag-js/types"
3
3
  import type { MachineContext as Ctx, SharedContext } from "./slider.types"
4
4
 
5
+ /* -----------------------------------------------------------------------------
6
+ * Range style calculations
7
+ * -----------------------------------------------------------------------------*/
8
+
9
+ function getBounds<T>(value: T[]): [T, T] {
10
+ const firstValue = value[0]
11
+ const lastThumb = value[value.length - 1]
12
+ return [firstValue, lastThumb]
13
+ }
14
+
15
+ export function getRangeOffsets(ctx: Ctx) {
16
+ const [firstPercent, lastPercent] = getBounds(ctx.valuePercent)
17
+
18
+ if (ctx.valuePercent.length === 1) {
19
+ if (ctx.origin === "center") {
20
+ const isNegative = ctx.valuePercent[0] < 50
21
+ const start = isNegative ? `${ctx.valuePercent[0]}%` : "50%"
22
+ const end = isNegative ? "50%" : `${100 - ctx.valuePercent[0]}%`
23
+
24
+ return { start, end }
25
+ }
26
+
27
+ return { start: "0%", end: `${100 - lastPercent}%` }
28
+ }
29
+
30
+ return { start: `${firstPercent}%`, end: `${100 - lastPercent}%` }
31
+ }
32
+
33
+ function getRangeStyle(ctx: Pick<SharedContext, "isVertical" | "isRtl">): Style {
34
+ if (ctx.isVertical) {
35
+ return {
36
+ position: "absolute",
37
+ bottom: "var(--slider-range-start)",
38
+ top: "var(--slider-range-end)",
39
+ }
40
+ }
41
+
42
+ return {
43
+ position: "absolute",
44
+ [ctx.isRtl ? "right" : "left"]: "var(--slider-range-start)",
45
+ [ctx.isRtl ? "left" : "right"]: "var(--slider-range-end)",
46
+ }
47
+ }
48
+
5
49
  /* -----------------------------------------------------------------------------
6
50
  * Thumb style calculations
7
51
  * -----------------------------------------------------------------------------*/
@@ -35,7 +79,7 @@ function getThumbOffset(ctx: SharedContext) {
35
79
  return getOffset(ctx, percent)
36
80
  }
37
81
 
38
- function getVisibility(ctx: Pick<SharedContext, "thumbAlignment" | "hasMeasuredThumbSize">) {
82
+ function getVisibility(ctx: Ctx) {
39
83
  let visibility: "visible" | "hidden" = "visible"
40
84
  if (ctx.thumbAlignment === "contain" && !ctx.hasMeasuredThumbSize) {
41
85
  visibility = "hidden"
@@ -43,46 +87,13 @@ function getVisibility(ctx: Pick<SharedContext, "thumbAlignment" | "hasMeasuredT
43
87
  return visibility
44
88
  }
45
89
 
46
- function getThumbStyle(ctx: SharedContext): Style {
90
+ function getThumbStyle(ctx: Ctx, index: number): Style {
47
91
  const placementProp = ctx.isVertical ? "bottom" : "insetInlineStart"
48
92
  return {
49
93
  visibility: getVisibility(ctx),
50
94
  position: "absolute",
51
95
  transform: "var(--slider-thumb-transform)",
52
- [placementProp]: "var(--slider-thumb-offset)",
53
- }
54
- }
55
-
56
- /* -----------------------------------------------------------------------------
57
- * Range style calculations
58
- * -----------------------------------------------------------------------------*/
59
-
60
- function getRangeOffsets(ctx: Ctx) {
61
- let start = "0%"
62
- let end = `${100 - ctx.valuePercent}%`
63
-
64
- if (ctx.origin === "center") {
65
- const isNegative = ctx.valuePercent < 50
66
- start = isNegative ? `${ctx.valuePercent}%` : "50%"
67
- end = isNegative ? "50%" : end
68
- }
69
-
70
- return { start, end }
71
- }
72
-
73
- function getRangeStyle(ctx: Pick<SharedContext, "isVertical" | "isRtl">): Style {
74
- if (ctx.isVertical) {
75
- return {
76
- position: "absolute",
77
- bottom: "var(--slider-range-start)",
78
- top: "var(--slider-range-end)",
79
- }
80
- }
81
-
82
- return {
83
- position: "absolute",
84
- [ctx.isRtl ? "right" : "left"]: "var(--slider-range-start)",
85
- [ctx.isRtl ? "left" : "right"]: "var(--slider-range-end)",
96
+ [placementProp]: `var(--slider-thumb-offset-${index})`,
86
97
  }
87
98
  }
88
99
 
@@ -104,9 +115,15 @@ function getControlStyle(): Style {
104
115
 
105
116
  function getRootStyle(ctx: Ctx): Style {
106
117
  const range = getRangeOffsets(ctx)
118
+
119
+ const offsetStyles = ctx.value.reduce<Style>((styles, value, index) => {
120
+ const offset = getThumbOffset({ ...ctx, value })
121
+ return { ...styles, [`--slider-thumb-offset-${index}`]: offset }
122
+ }, {})
123
+
107
124
  return {
125
+ ...offsetStyles,
108
126
  "--slider-thumb-transform": ctx.isVertical ? "translateY(50%)" : ctx.isRtl ? "translateX(50%)" : "translateX(-50%)",
109
- "--slider-thumb-offset": getThumbOffset(ctx),
110
127
  "--slider-range-start": range.start,
111
128
  "--slider-range-end": range.end,
112
129
  }
@@ -121,6 +138,7 @@ function getMarkerStyle(
121
138
  value: number,
122
139
  ): Style {
123
140
  return {
141
+ // @ts-expect-error
124
142
  visibility: getVisibility(ctx),
125
143
  position: "absolute",
126
144
  pointerEvents: "none",
@@ -132,22 +150,6 @@ function getMarkerStyle(
132
150
  }
133
151
  }
134
152
 
135
- /* -----------------------------------------------------------------------------
136
- * Label style calculations
137
- * -----------------------------------------------------------------------------*/
138
-
139
- function getLabelStyle(): Style {
140
- return { userSelect: "none" }
141
- }
142
-
143
- /* -----------------------------------------------------------------------------
144
- * Label style calculations
145
- * -----------------------------------------------------------------------------*/
146
-
147
- function getTrackStyle(): Style {
148
- return { position: "relative" }
149
- }
150
-
151
153
  /* -----------------------------------------------------------------------------
152
154
  * Label style calculations
153
155
  * -----------------------------------------------------------------------------*/
@@ -160,14 +162,11 @@ function getMarkerGroupStyle(): Style {
160
162
  }
161
163
  }
162
164
 
163
- export const styles = {
164
- getThumbOffset,
165
+ export const styleGetterFns = {
166
+ getRootStyle,
165
167
  getControlStyle,
166
168
  getThumbStyle,
167
169
  getRangeStyle,
168
- getRootStyle,
169
170
  getMarkerStyle,
170
- getLabelStyle,
171
- getTrackStyle,
172
171
  getMarkerGroupStyle,
173
172
  }