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