@zag-js/slider 0.24.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.
@@ -13,178 +13,118 @@ import type { NormalizeProps, PropTypes } from "@zag-js/types"
13
13
  import { parts } from "./slider.anatomy"
14
14
  import { dom } from "./slider.dom"
15
15
  import type { MachineApi, Send, State } from "./slider.types"
16
+ import { getRangeAtIndex } from "./slider.utils"
16
17
 
17
18
  export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>): MachineApi<T> {
18
19
  const ariaLabel = state.context["aria-label"]
19
20
  const ariaLabelledBy = state.context["aria-labelledby"]
20
- const ariaValueText = state.context.getAriaValueText?.(state.context.value)
21
+ const sliderValue = state.context.value
21
22
 
22
23
  const isFocused = state.matches("focus")
23
24
  const isDragging = state.matches("dragging")
25
+
24
26
  const isDisabled = state.context.isDisabled
25
- const isInteractive = state.context.isInteractive
26
27
  const isInvalid = state.context.invalid
27
-
28
- function getPercentValueFn(percent: number) {
29
- return getPercentValue(percent, state.context.min, state.context.max, state.context.step)
30
- }
28
+ const isInteractive = state.context.isInteractive
31
29
 
32
30
  function getValuePercentFn(value: number) {
33
31
  return getValuePercent(value, state.context.min, state.context.max)
34
32
  }
35
33
 
34
+ function getPercentValueFn(percent: number) {
35
+ return getPercentValue(percent, state.context.min, state.context.max, state.context.step)
36
+ }
37
+
36
38
  return {
37
- isFocused,
38
- isDragging,
39
39
  value: state.context.value,
40
- percent: getValuePercent(state.context.value, state.context.min, state.context.max),
40
+ isDragging,
41
+ isFocused,
41
42
 
42
- setValue(value: number) {
43
- send({ type: "SET_VALUE", value })
43
+ setValue(value) {
44
+ send({ type: "SET_VALUE", value: value })
44
45
  },
45
46
 
46
- getPercentValue: getPercentValueFn,
47
+ getThumbValue(index) {
48
+ return sliderValue[index]
49
+ },
50
+
51
+ setThumbValue(index, value) {
52
+ send({ type: "SET_VALUE", index, value })
53
+ },
47
54
 
48
55
  getValuePercent: getValuePercentFn,
49
56
 
50
- focus() {
51
- dom.getThumbEl(state.context)?.focus()
57
+ getPercentValue: getPercentValueFn,
58
+
59
+ getThumbPercent(index) {
60
+ return getValuePercentFn(sliderValue[index])
52
61
  },
53
- /**
54
- * Function to increment the value of the slider by the step.
55
- */
56
- increment() {
57
- send("INCREMENT")
62
+
63
+ setThumbPercent(index, percent) {
64
+ const value = getPercentValueFn(percent)
65
+ send({ type: "SET_VALUE", index, value })
58
66
  },
59
67
 
60
- decrement() {
61
- send("DECREMENT")
68
+ getThumbMin(index) {
69
+ return getRangeAtIndex(state.context, index).min
62
70
  },
63
71
 
64
- rootProps: normalize.element({
65
- ...parts.root.attrs,
66
- "data-disabled": dataAttr(isDisabled),
67
- "data-focus": dataAttr(isFocused),
68
- "data-orientation": state.context.orientation,
69
- "data-invalid": dataAttr(isInvalid),
70
- id: dom.getRootId(state.context),
71
- dir: state.context.dir,
72
- style: dom.getRootStyle(state.context),
73
- }),
72
+ getThumbMax(index) {
73
+ return getRangeAtIndex(state.context, index).max
74
+ },
75
+
76
+ increment(index) {
77
+ send({ type: "INCREMENT", index })
78
+ },
79
+
80
+ decrement(index) {
81
+ send({ type: "DECREMENT", index })
82
+ },
83
+
84
+ focus() {
85
+ if (!isInteractive) return
86
+ send({ type: "FOCUS", index: 0 })
87
+ },
74
88
 
75
89
  labelProps: normalize.label({
76
90
  ...parts.label.attrs,
91
+ dir: state.context.dir,
77
92
  "data-disabled": dataAttr(isDisabled),
93
+ "data-orientation": state.context.orientation,
78
94
  "data-invalid": dataAttr(isInvalid),
79
95
  "data-focus": dataAttr(isFocused),
80
- dir: state.context.dir,
81
96
  id: dom.getLabelId(state.context),
82
- htmlFor: dom.getHiddenInputId(state.context),
97
+ htmlFor: dom.getHiddenInputId(state.context, 0),
83
98
  onClick(event) {
84
99
  if (!isInteractive) return
85
100
  event.preventDefault()
86
- dom.getThumbEl(state.context)?.focus()
101
+ dom.getFirstEl(state.context)?.focus()
102
+ },
103
+ style: {
104
+ userSelect: "none",
87
105
  },
88
- style: dom.getLabelStyle(),
89
106
  }),
90
107
 
91
- thumbProps: normalize.element({
92
- ...parts.thumb.attrs,
93
- dir: state.context.dir,
94
- id: dom.getThumbId(state.context),
108
+ rootProps: normalize.element({
109
+ ...parts.root.attrs,
95
110
  "data-disabled": dataAttr(isDisabled),
96
111
  "data-orientation": state.context.orientation,
97
- "data-focus": dataAttr(isFocused),
98
- draggable: false,
99
- "aria-invalid": ariaAttr(isInvalid),
100
112
  "data-invalid": dataAttr(isInvalid),
101
- "aria-disabled": ariaAttr(isDisabled),
102
- "aria-label": ariaLabel,
103
- "aria-labelledby": ariaLabel ? undefined : ariaLabelledBy ?? dom.getLabelId(state.context),
104
- "aria-orientation": state.context.orientation,
105
- "aria-valuemax": state.context.max,
106
- "aria-valuemin": state.context.min,
107
- "aria-valuenow": state.context.value,
108
- "aria-valuetext": ariaValueText,
109
- role: "slider",
110
- tabIndex: isDisabled ? undefined : 0,
111
- onPointerDown(event) {
112
- if (!isInteractive) return
113
- send({ type: "THUMB_POINTER_DOWN" })
114
- event.stopPropagation()
115
- },
116
- onBlur() {
117
- if (!isInteractive) return
118
- send("BLUR")
119
- },
120
- onFocus() {
121
- if (!isInteractive) return
122
- send("FOCUS")
123
- },
124
- onKeyDown(event) {
125
- if (!isInteractive) return
126
- const step = getEventStep(event) * state.context.step
127
- let prevent = true
128
- const keyMap: EventKeyMap = {
129
- ArrowUp() {
130
- send({ type: "ARROW_UP", step })
131
- prevent = state.context.isVertical
132
- },
133
- ArrowDown() {
134
- send({ type: "ARROW_DOWN", step })
135
- prevent = state.context.isVertical
136
- },
137
- ArrowLeft() {
138
- send({ type: "ARROW_LEFT", step })
139
- prevent = state.context.isHorizontal
140
- },
141
- ArrowRight() {
142
- send({ type: "ARROW_RIGHT", step })
143
- prevent = state.context.isHorizontal
144
- },
145
- PageUp() {
146
- send({ type: "PAGE_UP", step })
147
- },
148
- PageDown() {
149
- send({ type: "PAGE_DOWN", step })
150
- },
151
- Home() {
152
- send("HOME")
153
- },
154
- End() {
155
- send("END")
156
- },
157
- }
158
-
159
- const key = getEventKey(event, state.context)
160
- const exec = keyMap[key]
161
-
162
- if (!exec) return
163
- exec(event)
164
-
165
- if (prevent) {
166
- event.preventDefault()
167
- }
168
- },
169
- style: dom.getThumbStyle(state.context),
170
- }),
171
-
172
- hiddenInputProps: normalize.input({
173
- defaultValue: state.context.value,
174
- name: state.context.name,
175
- form: state.context.form,
176
- id: dom.getHiddenInputId(state.context),
177
- hidden: true,
113
+ "data-focus": dataAttr(isFocused),
114
+ id: dom.getRootId(state.context),
115
+ dir: state.context.dir,
116
+ style: dom.getRootStyle(state.context),
178
117
  }),
179
118
 
180
119
  outputProps: normalize.output({
181
120
  ...parts.output.attrs,
121
+ dir: state.context.dir,
182
122
  "data-disabled": dataAttr(isDisabled),
183
- "data-invalid": dataAttr(isInvalid),
184
123
  "data-orientation": state.context.orientation,
124
+ "data-invalid": dataAttr(isInvalid),
125
+ "data-focus": dataAttr(isFocused),
185
126
  id: dom.getOutputId(state.context),
186
- dir: state.context.dir,
187
- htmlFor: dom.getHiddenInputId(state.context),
127
+ htmlFor: sliderValue.map((_v, i) => dom.getHiddenInputId(state.context, i)).join(" "),
188
128
  "aria-live": "off",
189
129
  }),
190
130
 
@@ -193,16 +133,119 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
193
133
  dir: state.context.dir,
194
134
  id: dom.getTrackId(state.context),
195
135
  "data-disabled": dataAttr(isDisabled),
196
- "data-focus": dataAttr(isFocused),
197
136
  "data-invalid": dataAttr(isInvalid),
198
137
  "data-orientation": state.context.orientation,
199
- style: dom.getTrackStyle(),
138
+ "data-focus": dataAttr(isFocused),
139
+ style: { position: "relative" },
200
140
  }),
201
141
 
142
+ getThumbProps(props) {
143
+ const { index } = props
144
+
145
+ const value = sliderValue[index]
146
+ const range = getRangeAtIndex(state.context, index)
147
+ const ariaValueText = state.context.getAriaValueText?.(value, index)
148
+ const _ariaLabel = Array.isArray(ariaLabel) ? ariaLabel[index] : ariaLabel
149
+ const _ariaLabelledBy = Array.isArray(ariaLabelledBy) ? ariaLabelledBy[index] : ariaLabelledBy
150
+
151
+ return normalize.element({
152
+ ...parts.thumb.attrs,
153
+ dir: state.context.dir,
154
+ "data-index": index,
155
+ id: dom.getThumbId(state.context, index),
156
+ "data-disabled": dataAttr(isDisabled),
157
+ "data-orientation": state.context.orientation,
158
+ "data-focus": dataAttr(isFocused && state.context.focusedIndex === index),
159
+ draggable: false,
160
+ "aria-disabled": ariaAttr(isDisabled),
161
+ "aria-label": _ariaLabel,
162
+ "aria-labelledby": _ariaLabelledBy ?? dom.getLabelId(state.context),
163
+ "aria-orientation": state.context.orientation,
164
+ "aria-valuemax": range.max,
165
+ "aria-valuemin": range.min,
166
+ "aria-valuenow": sliderValue[index],
167
+ "aria-valuetext": ariaValueText,
168
+ role: "slider",
169
+ tabIndex: isDisabled ? undefined : 0,
170
+ style: dom.getThumbStyle(state.context, index),
171
+ onPointerDown(event) {
172
+ if (!isInteractive) return
173
+ send({ type: "THUMB_POINTER_DOWN", index })
174
+ event.stopPropagation()
175
+ },
176
+ onBlur() {
177
+ if (!isInteractive) return
178
+ send("BLUR")
179
+ },
180
+ onFocus() {
181
+ if (!isInteractive) return
182
+ send({ type: "FOCUS", index })
183
+ },
184
+ onKeyDown(event) {
185
+ if (!isInteractive) return
186
+ const step = getEventStep(event) * state.context.step
187
+ let prevent = true
188
+ const keyMap: EventKeyMap = {
189
+ ArrowUp() {
190
+ send({ type: "ARROW_UP", step })
191
+ prevent = state.context.isVertical
192
+ },
193
+ ArrowDown() {
194
+ send({ type: "ARROW_DOWN", step })
195
+ prevent = state.context.isVertical
196
+ },
197
+ ArrowLeft() {
198
+ send({ type: "ARROW_LEFT", step })
199
+ prevent = state.context.isHorizontal
200
+ },
201
+ ArrowRight() {
202
+ send({ type: "ARROW_RIGHT", step })
203
+ prevent = state.context.isHorizontal
204
+ },
205
+ PageUp() {
206
+ send({ type: "PAGE_UP", step })
207
+ },
208
+ PageDown() {
209
+ send({ type: "PAGE_DOWN", step })
210
+ },
211
+ Home() {
212
+ send("HOME")
213
+ },
214
+ End() {
215
+ send("END")
216
+ },
217
+ }
218
+
219
+ const key = getEventKey(event, state.context)
220
+ const exec = keyMap[key]
221
+
222
+ if (!exec) return
223
+ exec(event)
224
+
225
+ if (prevent) {
226
+ event.preventDefault()
227
+ event.stopPropagation()
228
+ }
229
+ },
230
+ })
231
+ },
232
+
233
+ getHiddenInputProps(props) {
234
+ const { index } = props
235
+ return normalize.input({
236
+ name: `${state.context.name}[${index}]`,
237
+ form: state.context.form,
238
+ type: "text",
239
+ hidden: true,
240
+ defaultValue: state.context.value[index],
241
+ id: dom.getHiddenInputId(state.context, index),
242
+ })
243
+ },
244
+
202
245
  rangeProps: normalize.element({
246
+ id: dom.getRangeId(state.context),
203
247
  ...parts.range.attrs,
204
248
  dir: state.context.dir,
205
- id: dom.getRangeId(state.context),
206
249
  "data-focus": dataAttr(isFocused),
207
250
  "data-invalid": dataAttr(isInvalid),
208
251
  "data-disabled": dataAttr(isDisabled),
@@ -215,9 +258,10 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
215
258
  dir: state.context.dir,
216
259
  id: dom.getControlId(state.context),
217
260
  "data-disabled": dataAttr(isDisabled),
218
- "data-invalid": dataAttr(isInvalid),
219
261
  "data-orientation": state.context.orientation,
262
+ "data-invalid": dataAttr(isInvalid),
220
263
  "data-focus": dataAttr(isFocused),
264
+ style: dom.getControlStyle(),
221
265
  onPointerDown(event) {
222
266
  if (!isInteractive) return
223
267
 
@@ -230,30 +274,39 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
230
274
  event.preventDefault()
231
275
  event.stopPropagation()
232
276
  },
233
- style: dom.getControlStyle(),
234
277
  }),
235
278
 
236
279
  markerGroupProps: normalize.element({
237
280
  ...parts.markerGroup.attrs,
238
- dir: state.context.dir,
239
281
  role: "presentation",
282
+ dir: state.context.dir,
240
283
  "aria-hidden": true,
241
284
  "data-orientation": state.context.orientation,
242
285
  style: dom.getMarkerGroupStyle(),
243
286
  }),
244
287
 
245
- getMarkerProps({ value }: { value: number }) {
246
- const style = dom.getMarkerStyle(state.context, value)
247
- const markerState =
248
- value > state.context.value ? "over-value" : value === state.context.value ? "at-value" : "under-value"
288
+ getMarkerProps(props) {
289
+ const style = dom.getMarkerStyle(state.context, props.value)
290
+ let markerState: "over-value" | "under-value" | "at-value"
291
+
292
+ const first = state.context.value[0]
293
+ const last = state.context.value[state.context.value.length - 1]
294
+
295
+ if (props.value < first) {
296
+ markerState = "under-value"
297
+ } else if (props.value > last) {
298
+ markerState = "over-value"
299
+ } else {
300
+ markerState = "at-value"
301
+ }
249
302
 
250
303
  return normalize.element({
251
304
  ...parts.marker.attrs,
252
- dir: state.context.dir,
253
- id: dom.getMarkerId(state.context, value),
305
+ id: dom.getMarkerId(state.context, props.value),
254
306
  role: "presentation",
307
+ dir: state.context.dir,
255
308
  "data-orientation": state.context.orientation,
256
- "data-value": value,
309
+ "data-value": props.value,
257
310
  "data-disabled": dataAttr(isDisabled),
258
311
  "data-state": markerState,
259
312
  style,
package/src/slider.dom.ts CHANGED
@@ -1,32 +1,33 @@
1
- import { getRelativePoint } from "@zag-js/dom-event"
2
- import { createScope } from "@zag-js/dom-query"
1
+ import { getRelativePoint, type Point } from "@zag-js/dom-event"
2
+ import { createScope, queryAll } from "@zag-js/dom-query"
3
3
  import { dispatchInputValueEvent } from "@zag-js/form-utils"
4
4
  import { getPercentValue } from "@zag-js/numeric-range"
5
- import { styles } from "./slider.style"
6
- import type { MachineContext as Ctx, Point } from "./slider.types"
5
+ import { styleGetterFns } from "./slider.style"
6
+ import type { MachineContext as Ctx } from "./slider.types"
7
7
 
8
8
  export const dom = createScope({
9
- ...styles,
10
-
9
+ ...styleGetterFns,
11
10
  getRootId: (ctx: Ctx) => ctx.ids?.root ?? `slider:${ctx.id}`,
12
- getThumbId: (ctx: Ctx) => ctx.ids?.thumb ?? `slider:${ctx.id}:thumb`,
11
+ getThumbId: (ctx: Ctx, index: number) => ctx.ids?.thumb?.(index) ?? `slider:${ctx.id}:thumb:${index}`,
12
+ getHiddenInputId: (ctx: Ctx, index: number) => `slider:${ctx.id}:input:${index}`,
13
13
  getControlId: (ctx: Ctx) => ctx.ids?.control ?? `slider:${ctx.id}:control`,
14
- getHiddenInputId: (ctx: Ctx) => ctx.ids?.hiddenInput ?? `slider:${ctx.id}:input`,
15
- getOutputId: (ctx: Ctx) => ctx.ids?.output ?? `slider:${ctx.id}:output`,
16
- getTrackId: (ctx: Ctx) => ctx.ids?.track ?? `slider:${ctx.id}track`,
17
- getRangeId: (ctx: Ctx) => ctx.ids?.track ?? `slider:${ctx.id}:range`,
14
+ getTrackId: (ctx: Ctx) => ctx.ids?.track ?? `slider:${ctx.id}:track`,
15
+ getRangeId: (ctx: Ctx) => ctx.ids?.range ?? `slider:${ctx.id}:range`,
18
16
  getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `slider:${ctx.id}:label`,
19
- getMarkerId: (ctx: Ctx, value: number) => `slider:${ctx.id}:marker:${value}`,
17
+ getOutputId: (ctx: Ctx) => ctx.ids?.output ?? `slider:${ctx.id}:output`,
18
+ getMarkerId: (ctx: Ctx, value: number) => ctx.ids?.marker?.(value) ?? `slider:${ctx.id}:marker:${value}`,
20
19
 
21
20
  getRootEl: (ctx: Ctx) => dom.getById(ctx, dom.getRootId(ctx)),
22
- getThumbEl: (ctx: Ctx) => dom.getById(ctx, dom.getThumbId(ctx)),
21
+ getThumbEl: (ctx: Ctx, index: number) => dom.getById(ctx, dom.getThumbId(ctx, index)),
22
+ getHiddenInputEl: (ctx: Ctx, index: number) => dom.getById<HTMLInputElement>(ctx, dom.getHiddenInputId(ctx, index)),
23
23
  getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)),
24
- getHiddenInputEl: (ctx: Ctx) => dom.getById<HTMLInputElement>(ctx, dom.getHiddenInputId(ctx)),
24
+ getElements: (ctx: Ctx) => queryAll(dom.getControlEl(ctx), "[role=slider]"),
25
+ getFirstEl: (ctx: Ctx) => dom.getElements(ctx)[0],
26
+ getRangeEl: (ctx: Ctx) => dom.getById(ctx, dom.getRangeId(ctx)),
25
27
 
26
- getValueFromPoint(ctx: Ctx, point: Point): number | undefined {
28
+ getValueFromPoint(ctx: Ctx, point: Point) {
27
29
  const controlEl = dom.getControlEl(ctx)
28
30
  if (!controlEl) return
29
-
30
31
  const relativePoint = getRelativePoint(point, controlEl)
31
32
  const percent = relativePoint.getPercentValue({
32
33
  orientation: ctx.orientation,
@@ -35,10 +36,12 @@ export const dom = createScope({
35
36
  })
36
37
  return getPercentValue(percent, ctx.min, ctx.max, ctx.step)
37
38
  },
38
-
39
39
  dispatchChangeEvent(ctx: Ctx) {
40
- const input = dom.getHiddenInputEl(ctx)
41
- if (!input) return
42
- dispatchInputValueEvent(input, { value: ctx.value })
40
+ const valueArray = Array.from(ctx.value)
41
+ valueArray.forEach((value, index) => {
42
+ const inputEl = dom.getHiddenInputEl(ctx, index)
43
+ if (!inputEl) return
44
+ dispatchInputValueEvent(inputEl, { value })
45
+ })
43
46
  },
44
47
  })