@zag-js/number-input 0.9.1 → 0.10.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.
@@ -9,7 +9,7 @@ import {
9
9
  } from "./chunk-AXXDGYUW.mjs";
10
10
 
11
11
  // src/number-input.connect.ts
12
- import { getEventStep, getNativeEvent, isLeftClick } from "@zag-js/dom-event";
12
+ import { getEventPoint, getEventStep, getNativeEvent, isLeftClick } from "@zag-js/dom-event";
13
13
  import { ariaAttr, dataAttr } from "@zag-js/dom-query";
14
14
  import { roundToDevicePixel } from "@zag-js/number-utils";
15
15
  function connect(state, send, normalize) {
@@ -139,9 +139,6 @@ function connect(state, send, normalize) {
139
139
  send("BLUR");
140
140
  },
141
141
  onChange(event) {
142
- const evt = getNativeEvent(event);
143
- if (evt.isComposing)
144
- return;
145
142
  send({ type: "CHANGE", target: event.currentTarget, hint: "set" });
146
143
  },
147
144
  onKeyDown(event) {
@@ -228,11 +225,11 @@ function connect(state, send, normalize) {
228
225
  if (isDisabled)
229
226
  return;
230
227
  const evt = getNativeEvent(event);
231
- event.preventDefault();
232
- const point = { x: evt.clientX, y: evt.clientY };
228
+ const point = getEventPoint(evt);
233
229
  point.x = point.x - roundToDevicePixel(7.5);
234
230
  point.y = point.y - roundToDevicePixel(7.5);
235
231
  send({ type: "PRESS_DOWN_SCRUBBER", point });
232
+ event.preventDefault();
236
233
  },
237
234
  style: {
238
235
  cursor: isDisabled ? void 0 : "ew-resize"
package/dist/index.js CHANGED
@@ -310,9 +310,6 @@ function connect(state, send, normalize) {
310
310
  send("BLUR");
311
311
  },
312
312
  onChange(event) {
313
- const evt = (0, import_dom_event2.getNativeEvent)(event);
314
- if (evt.isComposing)
315
- return;
316
313
  send({ type: "CHANGE", target: event.currentTarget, hint: "set" });
317
314
  },
318
315
  onKeyDown(event) {
@@ -399,11 +396,11 @@ function connect(state, send, normalize) {
399
396
  if (isDisabled)
400
397
  return;
401
398
  const evt = (0, import_dom_event2.getNativeEvent)(event);
402
- event.preventDefault();
403
- const point = { x: evt.clientX, y: evt.clientY };
399
+ const point = (0, import_dom_event2.getEventPoint)(evt);
404
400
  point.x = point.x - (0, import_number_utils3.roundToDevicePixel)(7.5);
405
401
  point.y = point.y - (0, import_number_utils3.roundToDevicePixel)(7.5);
406
402
  send({ type: "PRESS_DOWN_SCRUBBER", point });
403
+ event.preventDefault();
407
404
  },
408
405
  style: {
409
406
  cursor: isDisabled ? void 0 : "ew-resize"
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  connect
3
- } from "./chunk-DYFCS7PF.mjs";
3
+ } from "./chunk-5ENKYWE3.mjs";
4
4
  import {
5
5
  anatomy
6
6
  } from "./chunk-XHRILSH3.mjs";
@@ -306,9 +306,6 @@ function connect(state, send, normalize) {
306
306
  send("BLUR");
307
307
  },
308
308
  onChange(event) {
309
- const evt = (0, import_dom_event2.getNativeEvent)(event);
310
- if (evt.isComposing)
311
- return;
312
309
  send({ type: "CHANGE", target: event.currentTarget, hint: "set" });
313
310
  },
314
311
  onKeyDown(event) {
@@ -395,11 +392,11 @@ function connect(state, send, normalize) {
395
392
  if (isDisabled)
396
393
  return;
397
394
  const evt = (0, import_dom_event2.getNativeEvent)(event);
398
- event.preventDefault();
399
- const point = { x: evt.clientX, y: evt.clientY };
395
+ const point = (0, import_dom_event2.getEventPoint)(evt);
400
396
  point.x = point.x - (0, import_number_utils3.roundToDevicePixel)(7.5);
401
397
  point.y = point.y - (0, import_number_utils3.roundToDevicePixel)(7.5);
402
398
  send({ type: "PRESS_DOWN_SCRUBBER", point });
399
+ event.preventDefault();
403
400
  },
404
401
  style: {
405
402
  cursor: isDisabled ? void 0 : "ew-resize"
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  connect
3
- } from "./chunk-DYFCS7PF.mjs";
3
+ } from "./chunk-5ENKYWE3.mjs";
4
4
  import "./chunk-XHRILSH3.mjs";
5
5
  import "./chunk-QYY4CWRS.mjs";
6
6
  import "./chunk-AXXDGYUW.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zag-js/number-input",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Core logic for the number-input widget implemented as a state machine",
5
5
  "keywords": [
6
6
  "js",
@@ -17,7 +17,8 @@
17
17
  "repository": "https://github.com/chakra-ui/zag/tree/main/packages/number-input",
18
18
  "sideEffects": false,
19
19
  "files": [
20
- "dist/**/*"
20
+ "dist",
21
+ "src"
21
22
  ],
22
23
  "publishConfig": {
23
24
  "access": "public"
@@ -26,15 +27,15 @@
26
27
  "url": "https://github.com/chakra-ui/zag/issues"
27
28
  },
28
29
  "dependencies": {
29
- "@zag-js/anatomy": "0.9.1",
30
- "@zag-js/core": "0.9.1",
31
- "@zag-js/dom-query": "0.9.1",
32
- "@zag-js/dom-event": "0.9.1",
33
- "@zag-js/form-utils": "0.9.1",
34
- "@zag-js/mutation-observer": "0.9.1",
35
- "@zag-js/number-utils": "0.9.1",
36
- "@zag-js/utils": "0.9.1",
37
- "@zag-js/types": "0.9.1"
30
+ "@zag-js/anatomy": "0.10.0",
31
+ "@zag-js/core": "0.10.0",
32
+ "@zag-js/dom-query": "0.10.0",
33
+ "@zag-js/dom-event": "0.10.0",
34
+ "@zag-js/form-utils": "0.10.0",
35
+ "@zag-js/mutation-observer": "0.10.0",
36
+ "@zag-js/number-utils": "0.10.0",
37
+ "@zag-js/utils": "0.10.0",
38
+ "@zag-js/types": "0.10.0"
38
39
  },
39
40
  "devDependencies": {
40
41
  "clean-package": "2.2.0"
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { anatomy } from "./number-input.anatomy"
2
+ export { connect } from "./number-input.connect"
3
+ export { machine } from "./number-input.machine"
4
+ export type { UserDefinedContext as Context } from "./number-input.types"
@@ -0,0 +1,12 @@
1
+ import { createAnatomy } from "@zag-js/anatomy"
2
+
3
+ export const anatomy = createAnatomy("numberInput").parts(
4
+ "root",
5
+ "label",
6
+ "input",
7
+ "control",
8
+ "incrementTrigger",
9
+ "decrementTrigger",
10
+ "scrubber",
11
+ )
12
+ export const parts = anatomy.build()
@@ -0,0 +1,248 @@
1
+ import { EventKeyMap, getEventPoint, getEventStep, getNativeEvent, isLeftClick } from "@zag-js/dom-event"
2
+ import { ariaAttr, dataAttr } from "@zag-js/dom-query"
3
+ import { roundToDevicePixel } from "@zag-js/number-utils"
4
+ import type { NormalizeProps, PropTypes } from "@zag-js/types"
5
+ import { parts } from "./number-input.anatomy"
6
+ import { dom } from "./number-input.dom"
7
+ import type { Send, State } from "./number-input.types"
8
+ import { utils } from "./number-input.utils"
9
+
10
+ export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>) {
11
+ const isFocused = state.hasTag("focus")
12
+ const isInvalid = state.context.isOutOfRange || !!state.context.invalid
13
+
14
+ const isDisabled = !!state.context.disabled
15
+ const isValueEmpty = state.context.isValueEmpty
16
+ const isIncrementDisabled = isDisabled || !state.context.canIncrement
17
+ const isDecrementDisabled = isDisabled || !state.context.canDecrement
18
+
19
+ const translations = state.context.translations
20
+
21
+ return {
22
+ /**
23
+ * Whether the input is focused.
24
+ */
25
+ isFocused,
26
+ /**
27
+ * Whether the input is invalid.
28
+ */
29
+ isInvalid,
30
+ /**
31
+ * Whether the input value is empty.
32
+ */
33
+ isValueEmpty,
34
+ /**
35
+ * The formatted value of the input.
36
+ */
37
+ value: state.context.formattedValue,
38
+ /**
39
+ * The value of the input as a number.
40
+ */
41
+ valueAsNumber: state.context.valueAsNumber,
42
+ /**
43
+ * Function to set the value of the input.
44
+ */
45
+ setValue(value: string | number) {
46
+ send({ type: "SET_VALUE", value: value.toString() })
47
+ },
48
+ /**
49
+ * Function to clear the value of the input.
50
+ */
51
+ clearValue() {
52
+ send("CLEAR_VALUE")
53
+ },
54
+ /**
55
+ * Function to increment the value of the input by the step.
56
+ */
57
+ increment() {
58
+ send("INCREMENT")
59
+ },
60
+ /**
61
+ * Function to decrement the value of the input by the step.
62
+ */
63
+ decrement() {
64
+ send("DECREMENT")
65
+ },
66
+ /**
67
+ * Function to set the value of the input to the max.
68
+ */
69
+ setToMax() {
70
+ send({ type: "SET_VALUE", value: state.context.max })
71
+ },
72
+ /**
73
+ * Function to set the value of the input to the min.
74
+ */
75
+ setToMin() {
76
+ send({ type: "SET_VALUE", value: state.context.min })
77
+ },
78
+ /**
79
+ * Function to focus the input.
80
+ */
81
+ focus() {
82
+ dom.getInputEl(state.context)?.focus()
83
+ },
84
+ /**
85
+ * Function to blur the input.
86
+ */
87
+ blur() {
88
+ dom.getInputEl(state.context)?.blur()
89
+ },
90
+
91
+ rootProps: normalize.element({
92
+ id: dom.getRootId(state.context),
93
+ ...parts.root.attrs,
94
+ "data-disabled": dataAttr(isDisabled),
95
+ }),
96
+
97
+ labelProps: normalize.label({
98
+ ...parts.label.attrs,
99
+ "data-disabled": dataAttr(isDisabled),
100
+ "data-invalid": dataAttr(isInvalid),
101
+ id: dom.getLabelId(state.context),
102
+ htmlFor: dom.getInputId(state.context),
103
+ }),
104
+
105
+ controlProps: normalize.element({
106
+ ...parts.control.attrs,
107
+ role: "group",
108
+ "aria-disabled": isDisabled,
109
+ "data-disabled": dataAttr(isDisabled),
110
+ "data-invalid": dataAttr(isInvalid),
111
+ "aria-invalid": ariaAttr(state.context.invalid),
112
+ }),
113
+
114
+ inputProps: normalize.input({
115
+ ...parts.input.attrs,
116
+ name: state.context.name,
117
+ form: state.context.form,
118
+ id: dom.getInputId(state.context),
119
+ role: "spinbutton",
120
+ defaultValue: state.context.formattedValue,
121
+ pattern: state.context.pattern,
122
+ inputMode: state.context.inputMode,
123
+ "aria-invalid": ariaAttr(isInvalid),
124
+ "data-invalid": dataAttr(isInvalid),
125
+ disabled: isDisabled,
126
+ "data-disabled": dataAttr(isDisabled),
127
+ readOnly: !!state.context.readOnly,
128
+ autoComplete: "off",
129
+ autoCorrect: "off",
130
+ spellCheck: "false",
131
+ type: "text",
132
+ "aria-roledescription": "numberfield",
133
+ "aria-valuemin": state.context.min,
134
+ "aria-valuemax": state.context.max,
135
+ "aria-valuenow": isNaN(state.context.valueAsNumber) ? undefined : state.context.valueAsNumber,
136
+ "aria-valuetext": state.context.valueText,
137
+ onFocus() {
138
+ send("FOCUS")
139
+ },
140
+ onBlur() {
141
+ send("BLUR")
142
+ },
143
+ onChange(event) {
144
+ send({ type: "CHANGE", target: event.currentTarget, hint: "set" })
145
+ },
146
+ onKeyDown(event) {
147
+ const evt = getNativeEvent(event)
148
+ // TODO: This blocks non-ascii characters
149
+ if (evt.isComposing) return
150
+
151
+ if (!utils.isValidNumericEvent(state.context, event)) {
152
+ event.preventDefault()
153
+ }
154
+
155
+ const step = getEventStep(event) * state.context.step
156
+
157
+ const keyMap: EventKeyMap = {
158
+ ArrowUp() {
159
+ send({ type: "ARROW_UP", step })
160
+ },
161
+ ArrowDown() {
162
+ send({ type: "ARROW_DOWN", step })
163
+ },
164
+ Home() {
165
+ send("HOME")
166
+ },
167
+ End() {
168
+ send("END")
169
+ },
170
+ }
171
+
172
+ const exec = keyMap[event.key]
173
+
174
+ if (exec) {
175
+ exec(event)
176
+ event.preventDefault()
177
+ }
178
+ },
179
+ }),
180
+
181
+ decrementTriggerProps: normalize.button({
182
+ ...parts.decrementTrigger.attrs,
183
+ id: dom.getDecrementTriggerId(state.context),
184
+ disabled: isDecrementDisabled,
185
+ "data-disabled": dataAttr(isDecrementDisabled),
186
+ "aria-label": translations.decrementLabel,
187
+ type: "button",
188
+ tabIndex: -1,
189
+ "aria-controls": dom.getInputId(state.context),
190
+ onPointerDown(event) {
191
+ if (isDecrementDisabled) return
192
+ send(isLeftClick(event) ? { type: "PRESS_DOWN", hint: "decrement" } : { type: "FOCUS" })
193
+ event.preventDefault()
194
+ },
195
+ onPointerUp() {
196
+ send({ type: "PRESS_UP", hint: "decrement" })
197
+ },
198
+ onPointerLeave() {
199
+ if (isDecrementDisabled) return
200
+ send({ type: "PRESS_UP", hint: "decrement" })
201
+ },
202
+ }),
203
+
204
+ incrementTriggerProps: normalize.button({
205
+ ...parts.incrementTrigger.attrs,
206
+ id: dom.getIncrementTriggerId(state.context),
207
+ disabled: isIncrementDisabled,
208
+ "data-disabled": dataAttr(isIncrementDisabled),
209
+ "aria-label": translations.incrementLabel,
210
+ type: "button",
211
+ tabIndex: -1,
212
+ "aria-controls": dom.getInputId(state.context),
213
+ onPointerDown(event) {
214
+ if (isIncrementDisabled) return
215
+ send(isLeftClick(event) ? { type: "PRESS_DOWN", hint: "increment" } : { type: "FOCUS" })
216
+ event.preventDefault()
217
+ },
218
+ onPointerUp() {
219
+ send({ type: "PRESS_UP", hint: "increment" })
220
+ },
221
+ onPointerLeave() {
222
+ send({ type: "PRESS_UP", hint: "increment" })
223
+ },
224
+ }),
225
+
226
+ scrubberProps: normalize.element({
227
+ ...parts.scrubber.attrs,
228
+ "data-disabled": dataAttr(isDisabled),
229
+ id: dom.getScrubberId(state.context),
230
+ role: "presentation",
231
+ onMouseDown(event) {
232
+ if (isDisabled) return
233
+
234
+ const evt = getNativeEvent(event)
235
+
236
+ const point = getEventPoint(evt)
237
+ point.x = point.x - roundToDevicePixel(7.5)
238
+ point.y = point.y - roundToDevicePixel(7.5)
239
+
240
+ send({ type: "PRESS_DOWN_SCRUBBER", point })
241
+ event.preventDefault()
242
+ },
243
+ style: {
244
+ cursor: isDisabled ? undefined : "ew-resize",
245
+ },
246
+ }),
247
+ }
248
+ }
@@ -0,0 +1,113 @@
1
+ import { createScope, isSafari, MAX_Z_INDEX } from "@zag-js/dom-query"
2
+ import { roundToDevicePixel, wrap } from "@zag-js/number-utils"
3
+ import type { MachineContext as Ctx } from "./number-input.types"
4
+
5
+ export const dom = createScope({
6
+ getRootId: (ctx: Ctx) => ctx.ids?.root ?? `number-input:${ctx.id}`,
7
+ getInputId: (ctx: Ctx) => ctx.ids?.input ?? `number-input:${ctx.id}:input`,
8
+ getIncrementTriggerId: (ctx: Ctx) => ctx.ids?.incrementTrigger ?? `number-input:${ctx.id}:inc`,
9
+ getDecrementTriggerId: (ctx: Ctx) => ctx.ids?.decrementTrigger ?? `number-input:${ctx.id}:dec`,
10
+ getScrubberId: (ctx: Ctx) => ctx.ids?.scrubber ?? `number-input:${ctx.id}:scrubber`,
11
+ getCursorId: (ctx: Ctx) => `number-input:${ctx.id}:cursor`,
12
+ getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `number-input:${ctx.id}:label`,
13
+
14
+ getInputEl: (ctx: Ctx) => dom.getById<HTMLInputElement>(ctx, dom.getInputId(ctx)),
15
+ getIncrementTriggerEl: (ctx: Ctx) => dom.getById<HTMLButtonElement>(ctx, dom.getIncrementTriggerId(ctx)),
16
+ getDecrementTriggerEl: (ctx: Ctx) => dom.getById<HTMLButtonElement>(ctx, dom.getDecrementTriggerId(ctx)),
17
+ getScrubberEl: (ctx: Ctx) => dom.getById(ctx, dom.getScrubberId(ctx)),
18
+ getCursorEl: (ctx: Ctx) => dom.getDoc(ctx).getElementById(dom.getCursorId(ctx)),
19
+
20
+ getPressedTriggerEl: (ctx: Ctx, hint = ctx.hint) => {
21
+ let btnEl: HTMLButtonElement | null = null
22
+ if (hint === "increment") {
23
+ btnEl = dom.getIncrementTriggerEl(ctx)
24
+ }
25
+ if (hint === "decrement") {
26
+ btnEl = dom.getDecrementTriggerEl(ctx)
27
+ }
28
+ return btnEl
29
+ },
30
+
31
+ setupVirtualCursor(ctx: Ctx) {
32
+ if (isSafari()) return
33
+ dom.createVirtualCursor(ctx)
34
+ return () => {
35
+ dom.getCursorEl(ctx)?.remove()
36
+ }
37
+ },
38
+
39
+ preventTextSelection(ctx: Ctx) {
40
+ const doc = dom.getDoc(ctx)
41
+ const html = doc.documentElement
42
+ const body = doc.body
43
+
44
+ body.style.pointerEvents = "none"
45
+ html.style.userSelect = "none"
46
+ html.style.cursor = "ew-resize"
47
+
48
+ return () => {
49
+ body.style.pointerEvents = ""
50
+ html.style.userSelect = ""
51
+ html.style.cursor = ""
52
+ if (!html.style.length) {
53
+ html.removeAttribute("style")
54
+ }
55
+ if (!body.style.length) {
56
+ body.removeAttribute("style")
57
+ }
58
+ }
59
+ },
60
+
61
+ getMousementValue(ctx: Ctx, event: MouseEvent) {
62
+ const x = roundToDevicePixel(event.movementX)
63
+ const y = roundToDevicePixel(event.movementY)
64
+
65
+ let hint = x > 0 ? "increment" : x < 0 ? "decrement" : null
66
+
67
+ if (ctx.isRtl && hint === "increment") hint = "decrement"
68
+ if (ctx.isRtl && hint === "decrement") hint = "increment"
69
+
70
+ const point = {
71
+ x: ctx.scrubberCursorPoint!.x + x,
72
+ y: ctx.scrubberCursorPoint!.y + y,
73
+ }
74
+
75
+ const win = dom.getWin(ctx)
76
+ const width = win.innerWidth
77
+ const half = roundToDevicePixel(7.5)
78
+ point.x = wrap(point.x + half, width) - half
79
+
80
+ return { hint, point }
81
+ },
82
+
83
+ createVirtualCursor(ctx: Ctx) {
84
+ const doc = dom.getDoc(ctx)
85
+ const el = doc.createElement("div")
86
+ el.className = "scrubber--cursor"
87
+ el.id = dom.getCursorId(ctx)
88
+
89
+ Object.assign(el.style, {
90
+ width: "15px",
91
+ height: "15px",
92
+ position: "fixed",
93
+ pointerEvents: "none",
94
+ left: "0px",
95
+ top: "0px",
96
+ zIndex: MAX_Z_INDEX,
97
+ transform: ctx.scrubberCursorPoint
98
+ ? `translate3d(${ctx.scrubberCursorPoint.x}px, ${ctx.scrubberCursorPoint.y}px, 0px)`
99
+ : undefined,
100
+ willChange: "transform",
101
+ })
102
+
103
+ el.innerHTML = `
104
+ <svg width="46" height="15" style="left: -15.5px; position: absolute; top: 0; filter: drop-shadow(rgba(0, 0, 0, 0.4) 0px 1px 1.1px);">
105
+ <g transform="translate(2 3)">
106
+ <path fill-rule="evenodd" d="M 15 4.5L 15 2L 11.5 5.5L 15 9L 15 6.5L 31 6.5L 31 9L 34.5 5.5L 31 2L 31 4.5Z" style="stroke-width: 2px; stroke: white;"></path>
107
+ <path fill-rule="evenodd" d="M 15 4.5L 15 2L 11.5 5.5L 15 9L 15 6.5L 31 6.5L 31 9L 34.5 5.5L 31 2L 31 4.5Z"></path>
108
+ </g>
109
+ </svg>`
110
+
111
+ doc.body.appendChild(el)
112
+ },
113
+ })
@@ -0,0 +1,396 @@
1
+ import { choose, createMachine, guards } from "@zag-js/core"
2
+ import { addDomEvent, requestPointerLock } from "@zag-js/dom-event"
3
+ import { isSafari, raf } from "@zag-js/dom-query"
4
+ import { dispatchInputValueEvent } from "@zag-js/form-utils"
5
+ import { observeAttributes } from "@zag-js/mutation-observer"
6
+ import { isAtMax, isAtMin, isWithinRange, valueOf } from "@zag-js/number-utils"
7
+ import { callAll, compact } from "@zag-js/utils"
8
+ import { dom } from "./number-input.dom"
9
+ import type { MachineContext, MachineState, UserDefinedContext } from "./number-input.types"
10
+ import { utils } from "./number-input.utils"
11
+
12
+ const { not, and } = guards
13
+
14
+ export function machine(userContext: UserDefinedContext) {
15
+ const ctx = compact(userContext)
16
+ return createMachine<MachineContext, MachineState>(
17
+ {
18
+ id: "number-input",
19
+ initial: "idle",
20
+ context: {
21
+ dir: "ltr",
22
+ focusInputOnChange: true,
23
+ clampValueOnBlur: true,
24
+ allowOverflow: false,
25
+ inputMode: "decimal",
26
+ pattern: "[0-9]*(.[0-9]+)?",
27
+ hint: null,
28
+ value: "",
29
+ step: 1,
30
+ min: Number.MIN_SAFE_INTEGER,
31
+ max: Number.MAX_SAFE_INTEGER,
32
+ scrubberCursorPoint: null,
33
+ invalid: false,
34
+ spinOnPress: true,
35
+ ...ctx,
36
+ translations: {
37
+ incrementLabel: "increment value",
38
+ decrementLabel: "decrease value",
39
+ ...ctx.translations,
40
+ },
41
+ },
42
+
43
+ computed: {
44
+ isRtl: (ctx) => ctx.dir === "rtl",
45
+ valueAsNumber: (ctx) => valueOf(ctx.value),
46
+ isAtMin: (ctx) => isAtMin(ctx.value, ctx),
47
+ isAtMax: (ctx) => isAtMax(ctx.value, ctx),
48
+ isOutOfRange: (ctx) => !isWithinRange(ctx.value, ctx),
49
+ isValueEmpty: (ctx) => ctx.value === "",
50
+ canIncrement: (ctx) => ctx.allowOverflow || !ctx.isAtMax,
51
+ canDecrement: (ctx) => ctx.allowOverflow || !ctx.isAtMin,
52
+ valueText: (ctx) => ctx.translations.valueText?.(ctx.value),
53
+ formattedValue: (ctx) => ctx.format?.(ctx.value).toString() ?? ctx.value,
54
+ },
55
+
56
+ watch: {
57
+ value: ["invokeOnChange", "dispatchChangeEvent"],
58
+ isOutOfRange: ["invokeOnInvalid"],
59
+ scrubberCursorPoint: ["setVirtualCursorPosition"],
60
+ },
61
+
62
+ entry: ["syncInputValue"],
63
+
64
+ on: {
65
+ SET_VALUE: [
66
+ {
67
+ guard: "clampOnBlur",
68
+ actions: ["setValue", "clampValue", "setHintToSet"],
69
+ },
70
+ {
71
+ actions: ["setValue", "setHintToSet"],
72
+ },
73
+ ],
74
+ CLEAR_VALUE: {
75
+ actions: ["clearValue"],
76
+ },
77
+ INCREMENT: {
78
+ actions: ["increment"],
79
+ },
80
+ DECREMENT: {
81
+ actions: ["decrement"],
82
+ },
83
+ },
84
+
85
+ states: {
86
+ idle: {
87
+ exit: "invokeOnFocus",
88
+ on: {
89
+ PRESS_DOWN: {
90
+ target: "before:spin",
91
+ actions: ["focusInput", "setHint"],
92
+ },
93
+ PRESS_DOWN_SCRUBBER: {
94
+ target: "scrubbing",
95
+ actions: ["focusInput", "setHint", "setCursorPoint"],
96
+ },
97
+ FOCUS: "focused",
98
+ },
99
+ },
100
+
101
+ focused: {
102
+ tags: "focus",
103
+ entry: "focusInput",
104
+ activities: "attachWheelListener",
105
+ on: {
106
+ PRESS_DOWN: {
107
+ target: "before:spin",
108
+ actions: ["focusInput", "setHint"],
109
+ },
110
+ PRESS_DOWN_SCRUBBER: {
111
+ target: "scrubbing",
112
+ actions: ["focusInput", "setHint", "setCursorPoint"],
113
+ },
114
+ ARROW_UP: {
115
+ actions: "increment",
116
+ },
117
+ ARROW_DOWN: {
118
+ actions: "decrement",
119
+ },
120
+ HOME: {
121
+ actions: "setToMin",
122
+ },
123
+ END: {
124
+ actions: "setToMax",
125
+ },
126
+ CHANGE: {
127
+ actions: ["setValue", "setHint"],
128
+ },
129
+ BLUR: [
130
+ {
131
+ guard: "isInvalidExponential",
132
+ target: "idle",
133
+ actions: ["clearValue", "clearHint", "invokeOnBlur"],
134
+ },
135
+ {
136
+ guard: and("clampOnBlur", not("isInRange"), not("isEmptyValue")),
137
+ target: "idle",
138
+ actions: ["clampValue", "clearHint", "invokeOnBlur"],
139
+ },
140
+ {
141
+ target: "idle",
142
+ actions: ["roundValue", "invokeOnBlur"],
143
+ },
144
+ ],
145
+ },
146
+ },
147
+
148
+ "before:spin": {
149
+ tags: "focus",
150
+ activities: "trackButtonDisabled",
151
+ entry: choose([
152
+ { guard: "isIncrementHint", actions: "increment" },
153
+ { guard: "isDecrementHint", actions: "decrement" },
154
+ ]),
155
+ after: {
156
+ CHANGE_DELAY: {
157
+ target: "spinning",
158
+ guard: and("isInRange", "spinOnPress"),
159
+ },
160
+ },
161
+ on: {
162
+ PRESS_UP: {
163
+ target: "focused",
164
+ actions: "clearHint",
165
+ },
166
+ },
167
+ },
168
+
169
+ spinning: {
170
+ tags: "focus",
171
+ activities: "trackButtonDisabled",
172
+ every: [
173
+ {
174
+ delay: "CHANGE_INTERVAL",
175
+ guard: and(not("isAtMin"), "isIncrementHint"),
176
+ actions: "increment",
177
+ },
178
+ {
179
+ delay: "CHANGE_INTERVAL",
180
+ guard: and(not("isAtMax"), "isDecrementHint"),
181
+ actions: "decrement",
182
+ },
183
+ ],
184
+ on: {
185
+ PRESS_UP: {
186
+ target: "focused",
187
+ actions: "clearHint",
188
+ },
189
+ },
190
+ },
191
+
192
+ scrubbing: {
193
+ tags: "focus",
194
+ exit: "clearCursorPoint",
195
+ activities: ["activatePointerLock", "trackMousemove", "setupVirtualCursor", "preventTextSelection"],
196
+ on: {
197
+ POINTER_UP_SCRUBBER: "focused",
198
+ POINTER_MOVE_SCRUBBER: [
199
+ {
200
+ guard: "isIncrementHint",
201
+ actions: ["increment", "setCursorPoint"],
202
+ },
203
+ {
204
+ guard: "isDecrementHint",
205
+ actions: ["decrement", "setCursorPoint"],
206
+ },
207
+ ],
208
+ },
209
+ },
210
+ },
211
+ },
212
+ {
213
+ delays: {
214
+ CHANGE_INTERVAL: 50,
215
+ CHANGE_DELAY: 300,
216
+ },
217
+
218
+ guards: {
219
+ clampOnBlur: (ctx) => !!ctx.clampValueOnBlur,
220
+ isAtMin: (ctx) => ctx.isAtMin,
221
+ spinOnPress: (ctx) => !!ctx.spinOnPress,
222
+ isAtMax: (ctx) => ctx.isAtMax,
223
+ isInRange: (ctx) => !ctx.isOutOfRange,
224
+ isDecrementHint: (ctx, evt) => (evt.hint ?? ctx.hint) === "decrement",
225
+ isEmptyValue: (ctx) => ctx.isValueEmpty,
226
+ isIncrementHint: (ctx, evt) => (evt.hint ?? ctx.hint) === "increment",
227
+ isInvalidExponential: (ctx) => ctx.value.toString().startsWith("e"),
228
+ },
229
+
230
+ activities: {
231
+ setupVirtualCursor(ctx) {
232
+ return dom.setupVirtualCursor(ctx)
233
+ },
234
+ preventTextSelection(ctx) {
235
+ return dom.preventTextSelection(ctx)
236
+ },
237
+ trackButtonDisabled(ctx, _evt, { send }) {
238
+ const btn = dom.getPressedTriggerEl(ctx, ctx.hint)
239
+ return observeAttributes(btn, ["disabled"], () => {
240
+ send("PRESS_UP")
241
+ })
242
+ },
243
+ attachWheelListener(ctx, _evt, { send }) {
244
+ const input = dom.getInputEl(ctx)
245
+ if (!input) return
246
+
247
+ function onWheel(event: WheelEvent) {
248
+ const isInputFocused = dom.getDoc(ctx).activeElement === input
249
+ if (!ctx.allowMouseWheel || !isInputFocused) return
250
+ event.preventDefault()
251
+
252
+ const dir = Math.sign(event.deltaY) * -1
253
+ if (dir === 1) {
254
+ send("INCREMENT")
255
+ } else if (dir === -1) {
256
+ send("DECREMENT")
257
+ }
258
+ }
259
+ return addDomEvent(input, "wheel", onWheel, { passive: false })
260
+ },
261
+ activatePointerLock(ctx) {
262
+ if (isSafari()) return
263
+ return requestPointerLock(dom.getDoc(ctx))
264
+ },
265
+ trackMousemove(ctx, _evt, { send }) {
266
+ const doc = dom.getDoc(ctx)
267
+
268
+ function onMousemove(event: MouseEvent) {
269
+ if (!ctx.scrubberCursorPoint) return
270
+ const value = dom.getMousementValue(ctx, event)
271
+ if (!value.hint) return
272
+ send({
273
+ type: "POINTER_MOVE_SCRUBBER",
274
+ hint: value.hint,
275
+ point: value.point,
276
+ })
277
+ }
278
+
279
+ function onMouseup() {
280
+ send("POINTER_UP_SCRUBBER")
281
+ }
282
+
283
+ return callAll(
284
+ addDomEvent(doc, "mousemove", onMousemove, false),
285
+ addDomEvent(doc, "mouseup", onMouseup, false),
286
+ )
287
+ },
288
+ },
289
+
290
+ actions: {
291
+ focusInput(ctx) {
292
+ if (!ctx.focusInputOnChange) return
293
+ const input = dom.getInputEl(ctx)
294
+ raf(() => input?.focus())
295
+ },
296
+ increment(ctx, evt) {
297
+ ctx.value = utils.increment(ctx, evt.step)
298
+ },
299
+ decrement(ctx, evt) {
300
+ ctx.value = utils.decrement(ctx, evt.step)
301
+ },
302
+ clampValue(ctx) {
303
+ ctx.value = utils.clamp(ctx)
304
+ },
305
+ roundValue(ctx) {
306
+ if (ctx.value !== "") {
307
+ ctx.value = utils.round(ctx)
308
+ }
309
+ },
310
+ setValue(ctx, evt) {
311
+ const value = evt.target?.value ?? evt.value
312
+ ctx.value = utils.sanitize(ctx, utils.parse(ctx, value.toString()))
313
+ },
314
+ clearValue(ctx) {
315
+ ctx.value = ""
316
+ },
317
+ setToMax(ctx) {
318
+ ctx.value = ctx.max.toString()
319
+ },
320
+ setToMin(ctx) {
321
+ ctx.value = ctx.min.toString()
322
+ },
323
+ setHint(ctx, evt) {
324
+ ctx.hint = evt.hint
325
+ },
326
+ clearHint(ctx) {
327
+ ctx.hint = null
328
+ },
329
+ setHintToSet(ctx) {
330
+ ctx.hint = "set"
331
+ },
332
+ invokeOnChange(ctx) {
333
+ ctx.onChange?.({
334
+ value: ctx.value,
335
+ valueAsNumber: ctx.valueAsNumber,
336
+ })
337
+ },
338
+ invokeOnFocus(ctx, evt) {
339
+ let srcElement: HTMLElement | null = null
340
+
341
+ if (evt.type === "PRESS_DOWN") {
342
+ srcElement = dom.getPressedTriggerEl(ctx, evt.hint)
343
+ } else if (evt.type === "FOCUS") {
344
+ srcElement = dom.getInputEl(ctx)
345
+ } else if (evt.type === "PRESS_DOWN_SCRUBBER") {
346
+ srcElement = dom.getScrubberEl(ctx)
347
+ }
348
+
349
+ ctx.onFocus?.({
350
+ value: ctx.value,
351
+ valueAsNumber: ctx.valueAsNumber,
352
+ srcElement,
353
+ })
354
+ },
355
+ invokeOnBlur(ctx) {
356
+ ctx.onBlur?.({
357
+ value: ctx.value,
358
+ valueAsNumber: ctx.valueAsNumber,
359
+ })
360
+ },
361
+ invokeOnInvalid(ctx) {
362
+ if (!ctx.isOutOfRange) return
363
+ const reason = ctx.valueAsNumber > ctx.max ? "rangeOverflow" : "rangeUnderflow"
364
+ ctx.onInvalid?.({
365
+ reason,
366
+ value: ctx.formattedValue,
367
+ valueAsNumber: ctx.valueAsNumber,
368
+ })
369
+ },
370
+ // sync input value, in event it was set from form libraries via `ref`, `bind:this`, etc.
371
+ syncInputValue(ctx) {
372
+ const input = dom.getInputEl(ctx)
373
+ if (!input || input.value == ctx.value) return
374
+ const value = utils.parse(ctx, input.value)
375
+ ctx.value = utils.sanitize(ctx, value)
376
+ },
377
+ setCursorPoint(ctx, evt) {
378
+ ctx.scrubberCursorPoint = evt.point
379
+ },
380
+ clearCursorPoint(ctx) {
381
+ ctx.scrubberCursorPoint = null
382
+ },
383
+ setVirtualCursorPosition(ctx) {
384
+ const cursor = dom.getCursorEl(ctx)
385
+ if (!cursor || !ctx.scrubberCursorPoint) return
386
+ const { x, y } = ctx.scrubberCursorPoint
387
+ cursor.style.transform = `translate3d(${x}px, ${y}px, 0px)`
388
+ },
389
+ dispatchChangeEvent(ctx) {
390
+ const inputEl = dom.getInputEl(ctx)
391
+ dispatchInputValueEvent(inputEl, { value: ctx.formattedValue })
392
+ },
393
+ },
394
+ },
395
+ )
396
+ }
@@ -0,0 +1,234 @@
1
+ import type { StateMachine as S } from "@zag-js/core"
2
+ import type { CommonProperties, Context, DirectionProperty, RequiredBy } from "@zag-js/types"
3
+
4
+ type ValidityState = "rangeUnderflow" | "rangeOverflow"
5
+
6
+ type ElementIds = Partial<{
7
+ root: string
8
+ label: string
9
+ input: string
10
+ incrementTrigger: string
11
+ decrementTrigger: string
12
+ scrubber: string
13
+ }>
14
+
15
+ type IntlTranslations = {
16
+ /**
17
+ * Function that returns the human-readable value.
18
+ * It is used to set the `aria-valuetext` property of the input
19
+ */
20
+ valueText?: (value: string) => string
21
+ /**
22
+ * The label foe the increment button
23
+ */
24
+ incrementLabel: string
25
+ /**
26
+ * The label for the decrement button
27
+ */
28
+ decrementLabel: string
29
+ }
30
+
31
+ type Value = {
32
+ value: string
33
+ valueAsNumber: number
34
+ }
35
+
36
+ type PublicContext = DirectionProperty &
37
+ CommonProperties & {
38
+ /**
39
+ * The ids of the elements in the number input. Useful for composition.
40
+ */
41
+ ids?: ElementIds
42
+ /**
43
+ * The name attribute of the number input. Useful for form submission.
44
+ */
45
+ name?: string
46
+ /**
47
+ * The associate form of the input element.
48
+ */
49
+ form?: string
50
+ /**
51
+ * Whether the number input is disabled.
52
+ */
53
+ disabled?: boolean
54
+ /**
55
+ * Whether the number input is readonly
56
+ */
57
+ readOnly?: boolean
58
+ /**
59
+ * Whether the number input value is invalid.
60
+ */
61
+ invalid?: boolean
62
+ /**
63
+ * The pattern used to check the <input> element's value against
64
+ *
65
+ * @default
66
+ * "[0-9]*(.[0-9]+)?"
67
+ */
68
+ pattern: string
69
+ /**
70
+ * The value of the input
71
+ */
72
+ value: string
73
+ /**
74
+ * The minimum value of the number input
75
+ */
76
+ min: number
77
+ /**
78
+ * The maximum value of the number input
79
+ */
80
+ max: number
81
+ /**
82
+ * The amount to increment or decrement the value by
83
+ */
84
+ step: number
85
+ /**
86
+ * Whether to allow mouse wheel to change the value
87
+ */
88
+ allowMouseWheel?: boolean
89
+ /**
90
+ * Whether to allow the value overflow the min/max range
91
+ * @default true
92
+ */
93
+ allowOverflow: boolean
94
+ /**
95
+ * Whether the pressed key should be allowed in the input.
96
+ * The default behavior is to allow DOM floating point characters defined by /^[Ee0-9+\-.]$/
97
+ */
98
+ validateCharacter?: (char: string) => boolean
99
+ /**
100
+ * Whether to clamp the value when the input loses focus (blur)
101
+ * @default true
102
+ */
103
+ clampValueOnBlur: boolean
104
+ /**
105
+ * Whether to focus input when the value changes
106
+ * @default true
107
+ */
108
+ focusInputOnChange: boolean
109
+ /**
110
+ * Specifies the localized strings that identifies the accessibility elements and their states
111
+ */
112
+ translations: IntlTranslations
113
+ /**
114
+ * If using a custom display format, this converts the custom format to a format `parseFloat` understands.
115
+ */
116
+ parse?: (value: string) => string
117
+ /**
118
+ * If using a custom display format, this converts the default format to the custom format.
119
+ */
120
+ format?: (value: string) => string | number
121
+ /**
122
+ * Hints at the type of data that might be entered by the user. It also determines
123
+ * the type of keyboard shown to the user on mobile devices
124
+ * @default "decimal"
125
+ */
126
+ inputMode: "text" | "tel" | "numeric" | "decimal"
127
+ /**
128
+ * Function invoked when the value changes
129
+ */
130
+ onChange?: (details: Value) => void
131
+ /**
132
+ * Function invoked when the value overflows or underflows the min/max range
133
+ */
134
+ onInvalid?: (details: Value & { reason: ValidityState }) => void
135
+ /**
136
+ * Function invoked when the number input is focused
137
+ */
138
+ onFocus?: (details: Value & { srcElement: HTMLElement | null }) => void
139
+ /**
140
+ * The value of the input when it is blurred
141
+ */
142
+ onBlur?: (details: Value) => void
143
+ /**
144
+ * The minimum number of fraction digits to use. Possible values are from 0 to 20
145
+ */
146
+ minFractionDigits?: number
147
+ /**
148
+ * The maximum number of fraction digits to use. Possible values are from 0 to 20;
149
+ */
150
+ maxFractionDigits?: number
151
+ /**
152
+ * Whether to spin the value when the increment/decrement button is pressed
153
+ */
154
+ spinOnPress?: boolean
155
+ }
156
+
157
+ export type UserDefinedContext = RequiredBy<PublicContext, "id">
158
+
159
+ type ComputedContext = Readonly<{
160
+ /**
161
+ * @computed
162
+ * The value of the input as a number
163
+ */
164
+ valueAsNumber: number
165
+ /**
166
+ * @computed
167
+ * Whether the value is at the min
168
+ */
169
+ isAtMin: boolean
170
+ /**
171
+ * @computed
172
+ * Whether the value is at the max
173
+ */
174
+ isAtMax: boolean
175
+ /**
176
+ * @computed
177
+ * Whether the value is out of the min/max range
178
+ */
179
+ isOutOfRange: boolean
180
+ /**
181
+ * @computed
182
+ * Whether the value is empty
183
+ */
184
+ isValueEmpty: boolean
185
+ /**
186
+ * @computed
187
+ * Whether the increment button is enabled
188
+ */
189
+ canIncrement: boolean
190
+ /**
191
+ * @computed
192
+ * Whether the decrement button is enabled
193
+ */
194
+ canDecrement: boolean
195
+ /**
196
+ * @computed
197
+ * The `aria-valuetext` attribute of the input
198
+ */
199
+ valueText: string | undefined
200
+ /**
201
+ * @computed
202
+ * The formatted value of the input
203
+ */
204
+ formattedValue: string
205
+ /**
206
+ * @computed
207
+ * Whether the writing direction is RTL
208
+ */
209
+ isRtl: boolean
210
+ }>
211
+
212
+ type PrivateContext = Context<{
213
+ /**
214
+ * @internal
215
+ * The hint that determines if we're incrementing or decrementing
216
+ */
217
+ hint: "increment" | "decrement" | "set" | null
218
+ /**
219
+ * @internal
220
+ * The scrubber cursor position
221
+ */
222
+ scrubberCursorPoint: { x: number; y: number } | null
223
+ }>
224
+
225
+ export type MachineContext = PublicContext & PrivateContext & ComputedContext
226
+
227
+ export type MachineState = {
228
+ value: "idle" | "focused" | "spinning" | "before:spin" | "scrubbing"
229
+ tags: "focus"
230
+ }
231
+
232
+ export type State = S.State<MachineContext, MachineState>
233
+
234
+ export type Send = S.Send<S.AnyEventObject>
@@ -0,0 +1,42 @@
1
+ import { isModifiedEvent } from "@zag-js/dom-event"
2
+ import { clamp, decrement, formatDecimal, increment } from "@zag-js/number-utils"
3
+ import type { JSX } from "@zag-js/types"
4
+ import type { MachineContext as Ctx } from "./number-input.types"
5
+
6
+ export const utils = {
7
+ isValidNumericEvent: (ctx: Ctx, event: JSX.KeyboardEvent) => {
8
+ if (event.key == null) return true
9
+ const isModifier = isModifiedEvent(event)
10
+ const isSingleKey = event.key.length === 1
11
+ if (isModifier || !isSingleKey) return true
12
+ return ctx.validateCharacter?.(event.key) ?? utils.isFloatingPoint(event.key)
13
+ },
14
+ isFloatingPoint: (v: string) => /^[0-9+\-.]$/.test(v),
15
+ sanitize: (ctx: Ctx, value: string) => {
16
+ return value
17
+ .split("")
18
+ .filter(ctx.validateCharacter ?? utils.isFloatingPoint)
19
+ .join("")
20
+ },
21
+ increment: (ctx: Ctx, step?: number) => {
22
+ const value = increment(ctx.value, step ?? ctx.step)
23
+ return formatDecimal(clamp(value, ctx), ctx)
24
+ },
25
+ decrement: (ctx: Ctx, step?: number) => {
26
+ const value = decrement(ctx.value, step ?? ctx.step)
27
+ return formatDecimal(clamp(value, ctx), ctx)
28
+ },
29
+ clamp: (ctx: Ctx) => {
30
+ return formatDecimal(clamp(ctx.value, ctx), ctx)
31
+ },
32
+ parse: (ctx: Ctx, value: string) => {
33
+ return ctx.parse?.(value) ?? value
34
+ },
35
+ format: (ctx: Ctx, value: string | number) => {
36
+ const _val = value.toString()
37
+ return ctx.format?.(_val) ?? _val
38
+ },
39
+ round: (ctx: Ctx) => {
40
+ return formatDecimal(ctx.value, ctx)
41
+ },
42
+ }