@zag-js/pin-input 0.21.0 → 0.23.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,11 +1,12 @@
1
1
  import { getEventKey, getNativeEvent, isModifiedEvent, type EventKeyMap } from "@zag-js/dom-event"
2
- import { ariaAttr, dataAttr } from "@zag-js/dom-query"
2
+ import { ariaAttr, dataAttr, getBeforeInputValue } from "@zag-js/dom-query"
3
3
  import type { NormalizeProps, PropTypes } from "@zag-js/types"
4
4
  import { invariant } from "@zag-js/utils"
5
5
  import { visuallyHiddenStyle } from "@zag-js/visually-hidden"
6
6
  import { parts } from "./pin-input.anatomy"
7
7
  import { dom } from "./pin-input.dom"
8
8
  import type { MachineApi, Send, State } from "./pin-input.types"
9
+ import { isValidValue } from "./pin-input.utils"
9
10
 
10
11
  export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>): MachineApi<T> {
11
12
  const isValueComplete = state.context.isValueComplete
@@ -22,19 +23,19 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
22
23
  valueAsString: state.context.valueAsString,
23
24
  isValueComplete: isValueComplete,
24
25
 
25
- setValue(value: string[]) {
26
+ setValue(value) {
26
27
  if (!Array.isArray(value)) {
27
28
  invariant("[pin-input/setValue] value must be an array")
28
29
  }
29
- send({ type: "SET_VALUE", value })
30
+ send({ type: "VALUE.SET", value })
30
31
  },
31
32
 
32
33
  clearValue() {
33
- send({ type: "CLEAR_VALUE" })
34
+ send({ type: "VALUE.CLEAR" })
34
35
  },
35
36
 
36
- setValueAtIndex(index: number, value: string) {
37
- send({ type: "SET_VALUE", value, index })
37
+ setValueAtIndex(index, value) {
38
+ send({ type: "VALUE.SET", value, index })
38
39
  },
39
40
 
40
41
  focus,
@@ -50,12 +51,13 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
50
51
 
51
52
  labelProps: normalize.label({
52
53
  ...parts.label.attrs,
54
+ dir: state.context.dir,
53
55
  htmlFor: dom.getHiddenInputId(state.context),
54
56
  id: dom.getLabelId(state.context),
55
57
  "data-invalid": dataAttr(isInvalid),
56
58
  "data-disabled": dataAttr(state.context.disabled),
57
59
  "data-complete": dataAttr(isValueComplete),
58
- onClick: (event) => {
60
+ onClick(event) {
59
61
  event.preventDefault()
60
62
  focus()
61
63
  },
@@ -75,13 +77,16 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
75
77
 
76
78
  controlProps: normalize.element({
77
79
  ...parts.control.attrs,
80
+ dir: state.context.dir,
78
81
  id: dom.getControlId(state.context),
79
82
  }),
80
83
 
81
- getInputProps({ index }: { index: number }) {
84
+ getInputProps(props) {
85
+ const { index } = props
82
86
  const inputType = state.context.type === "numeric" ? "tel" : "text"
83
87
  return normalize.input({
84
88
  ...parts.input.attrs,
89
+ dir: state.context.dir,
85
90
  disabled: state.context.disabled,
86
91
  "data-disabled": dataAttr(state.context.disabled),
87
92
  "data-complete": dataAttr(isValueComplete),
@@ -96,59 +101,69 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
96
101
  autoCapitalize: "none",
97
102
  autoComplete: state.context.otp ? "one-time-code" : "off",
98
103
  placeholder: focusedIndex === index ? "" : state.context.placeholder,
104
+ onBeforeInput(event) {
105
+ try {
106
+ const value = getBeforeInputValue(event)
107
+ const isValid = isValidValue(state.context, value)
108
+ if (!isValid) {
109
+ send({ type: "VALUE.INVALID", value })
110
+ event.preventDefault()
111
+ }
112
+ } catch {
113
+ // noop
114
+ }
115
+ },
99
116
  onChange(event) {
100
117
  const evt = getNativeEvent(event)
101
118
  const { value } = event.currentTarget
119
+
102
120
  if (evt.inputType === "insertFromPaste" || value.length > 2) {
103
- send({ type: "PASTE", value })
121
+ send({ type: "INPUT.PASTE", value })
104
122
  event.preventDefault()
105
123
  return
106
124
  }
107
125
 
108
126
  if (evt.inputType === "deleteContentBackward") {
109
- send("BACKSPACE")
127
+ send("INPUT.BACKSPACE")
110
128
  return
111
129
  }
112
- send({ type: "INPUT", value, index })
130
+
131
+ send({ type: "INPUT.CHANGE", value, index })
113
132
  },
114
133
  onKeyDown(event) {
115
134
  const evt = getNativeEvent(event)
116
- if (evt.isComposing || isModifiedEvent(evt)) return
135
+ if (isModifiedEvent(evt)) return
117
136
 
118
137
  const keyMap: EventKeyMap = {
119
138
  Backspace() {
120
- send("BACKSPACE")
139
+ send("INPUT.BACKSPACE")
121
140
  },
122
141
  Delete() {
123
- send("DELETE")
142
+ send("INPUT.DELETE")
124
143
  },
125
144
  ArrowLeft() {
126
- send("ARROW_LEFT")
145
+ send("INPUT.ARROW_LEFT")
127
146
  },
128
147
  ArrowRight() {
129
- send("ARROW_RIGHT")
148
+ send("INPUT.ARROW_RIGHT")
130
149
  },
131
150
  Enter() {
132
- send("ENTER")
151
+ send("INPUT.ENTER")
133
152
  },
134
153
  }
135
154
 
136
- const key = getEventKey(event, { dir: state.context.dir })
137
- const exec = keyMap[key]
155
+ const exec = keyMap[getEventKey(event, state.context)]
138
156
 
139
157
  if (exec) {
140
158
  exec(event)
141
159
  event.preventDefault()
142
- } else {
143
- if (key === "Tab") return
144
- send({ type: "KEY_DOWN", value: key, preventDefault: () => event.preventDefault() })
145
160
  }
146
161
  },
147
162
  onFocus() {
148
- send({ type: "FOCUS", index })
163
+ send({ type: "INPUT.FOCUS", index })
149
164
  },
150
165
  onBlur() {
151
- send({ type: "BLUR", index })
166
+ send({ type: "INPUT.BLUR", index })
152
167
  },
153
168
  })
154
169
  },
@@ -1,18 +1,16 @@
1
- import { createMachine, guards } from "@zag-js/core"
1
+ import { choose, createMachine } from "@zag-js/core"
2
2
  import { raf } from "@zag-js/dom-query"
3
3
  import { dispatchInputValueEvent } from "@zag-js/form-utils"
4
4
  import { compact, isEqual } from "@zag-js/utils"
5
5
  import { dom } from "./pin-input.dom"
6
6
  import type { MachineContext, MachineState, UserDefinedContext } from "./pin-input.types"
7
7
 
8
- const { and, not } = guards
9
-
10
8
  export function machine(userContext: UserDefinedContext) {
11
9
  const ctx = compact(userContext)
12
10
  return createMachine<MachineContext, MachineState>(
13
11
  {
14
12
  id: "pin-input",
15
- initial: ctx.autoFocus ? "focused" : "idle",
13
+ initial: "idle",
16
14
  context: {
17
15
  value: [],
18
16
  focusedIndex: -1,
@@ -31,40 +29,40 @@ export function machine(userContext: UserDefinedContext) {
31
29
  filledValueLength: (ctx) => ctx.value.filter((v) => v?.trim() !== "").length,
32
30
  isValueComplete: (ctx) => ctx.valueLength === ctx.filledValueLength,
33
31
  valueAsString: (ctx) => ctx.value.join(""),
34
- focusedValue: (ctx) => ctx.value[ctx.focusedIndex],
32
+ focusedValue: (ctx) => ctx.value[ctx.focusedIndex] || "",
35
33
  },
36
34
 
35
+ entry: choose([
36
+ {
37
+ guard: "autoFocus",
38
+ actions: ["setupValue", "setFocusIndexToFirst"],
39
+ },
40
+ { actions: ["setupValue"] },
41
+ ]),
42
+
37
43
  watch: {
38
44
  focusedIndex: ["focusInput", "selectInputIfNeeded"],
39
45
  value: ["syncInputElements"],
40
46
  isValueComplete: ["invokeOnComplete", "blurFocusedInputIfNeeded"],
41
47
  },
42
48
 
43
- entry: ctx.autoFocus ? ["setupValue", "setFocusIndexToFirst"] : ["setupValue"],
44
-
45
49
  on: {
46
- SET_VALUE: [
50
+ "VALUE.SET": [
47
51
  {
48
52
  guard: "hasIndex",
49
53
  actions: ["setValueAtIndex"],
50
54
  },
51
55
  { actions: ["setValue"] },
52
56
  ],
53
- CLEAR_VALUE: [
54
- {
55
- guard: "isDisabled",
56
- actions: ["clearValue"],
57
- },
58
- {
59
- actions: ["clearValue", "setFocusIndexToFirst"],
60
- },
61
- ],
57
+ "VALUE.CLEAR": {
58
+ actions: ["clearValue", "setFocusIndexToFirst"],
59
+ },
62
60
  },
63
61
 
64
62
  states: {
65
63
  idle: {
66
64
  on: {
67
- FOCUS: {
65
+ "INPUT.FOCUS": {
68
66
  target: "focused",
69
67
  actions: "setFocusedIndex",
70
68
  },
@@ -72,38 +70,33 @@ export function machine(userContext: UserDefinedContext) {
72
70
  },
73
71
  focused: {
74
72
  on: {
75
- INPUT: [
73
+ "INPUT.CHANGE": [
76
74
  {
77
- guard: and("isFinalValue", "isValidValue"),
75
+ guard: "isFinalValue",
78
76
  actions: ["setFocusedValue", "syncInputValue"],
79
77
  },
80
78
  {
81
- guard: "isValidValue",
82
79
  actions: ["setFocusedValue", "setNextFocusedIndex", "syncInputValue"],
83
80
  },
84
81
  ],
85
- PASTE: [
86
- {
87
- guard: "isValidValue",
88
- actions: ["setPastedValue", "setLastValueFocusIndex"],
89
- },
90
- { actions: ["revertInputValue"] },
91
- ],
92
- BLUR: {
82
+ "INPUT.PASTE": {
83
+ actions: ["setPastedValue", "setLastValueFocusIndex"],
84
+ },
85
+ "INPUT.BLUR": {
93
86
  target: "idle",
94
87
  actions: "clearFocusedIndex",
95
88
  },
96
- DELETE: {
89
+ "INPUT.DELETE": {
97
90
  guard: "hasValue",
98
- actions: ["clearFocusedValue"],
91
+ actions: "clearFocusedValue",
99
92
  },
100
- ARROW_LEFT: {
93
+ "INPUT.ARROW_LEFT": {
101
94
  actions: "setPrevFocusedIndex",
102
95
  },
103
- ARROW_RIGHT: {
96
+ "INPUT.ARROW_RIGHT": {
104
97
  actions: "setNextFocusedIndex",
105
98
  },
106
- BACKSPACE: [
99
+ "INPUT.BACKSPACE": [
107
100
  {
108
101
  guard: "hasValue",
109
102
  actions: ["clearFocusedValue"],
@@ -112,13 +105,12 @@ export function machine(userContext: UserDefinedContext) {
112
105
  actions: ["setPrevFocusedIndex", "clearFocusedValue"],
113
106
  },
114
107
  ],
115
- ENTER: {
108
+ "INPUT.ENTER": {
116
109
  guard: "isValueComplete",
117
110
  actions: "requestFormSubmit",
118
111
  },
119
- KEY_DOWN: {
120
- guard: not("isValidValue"),
121
- actions: ["preventDefault", "invokeOnInvalid"],
112
+ "VALUE.INVALID": {
113
+ actions: "invokeOnInvalid",
122
114
  },
123
115
  },
124
116
  },
@@ -130,41 +122,27 @@ export function machine(userContext: UserDefinedContext) {
130
122
  isValueEmpty: (_ctx, evt) => evt.value === "",
131
123
  hasValue: (ctx) => ctx.value[ctx.focusedIndex] !== "",
132
124
  isValueComplete: (ctx) => ctx.isValueComplete,
133
- isValidValue(ctx, evt) {
134
- if (!ctx.pattern) return isValidType(evt.value, ctx.type)
135
- const regex = new RegExp(ctx.pattern, "g")
136
- return regex.test(evt.value)
137
- },
138
- isFinalValue(ctx) {
139
- return (
140
- ctx.filledValueLength + 1 === ctx.valueLength &&
141
- ctx.value.findIndex((v) => v.trim() === "") === ctx.focusedIndex
142
- )
143
- },
144
- isLastInputFocused: (ctx) => ctx.focusedIndex === ctx.valueLength - 1,
125
+ isFinalValue: (ctx) =>
126
+ ctx.filledValueLength + 1 === ctx.valueLength &&
127
+ ctx.value.findIndex((v) => v.trim() === "") === ctx.focusedIndex,
145
128
  hasIndex: (_ctx, evt) => evt.index !== undefined,
146
129
  isDisabled: (ctx) => !!ctx.disabled,
147
130
  },
148
131
  actions: {
149
132
  setupValue(ctx) {
150
133
  if (ctx.value.length) return
151
- const inputs = dom.getInputEls(ctx)
152
- const emptyValues = Array.from<string>({ length: inputs.length }).fill("")
134
+ const inputEls = dom.getInputEls(ctx)
135
+ const emptyValues = Array.from<string>({ length: inputEls.length }).fill("")
153
136
  assignValue(ctx, emptyValues)
154
137
  },
155
138
  focusInput(ctx) {
156
- raf(() => {
157
- if (ctx.focusedIndex === -1) return
158
- dom.getFocusedInputEl(ctx)?.focus()
159
- })
139
+ if (ctx.focusedIndex === -1) return
140
+ dom.getFocusedInputEl(ctx)?.focus({ preventScroll: true })
160
141
  },
161
142
  selectInputIfNeeded(ctx) {
143
+ if (!ctx.selectOnFocus || ctx.focusedIndex === -1) return
162
144
  raf(() => {
163
- if (ctx.focusedIndex === -1) return
164
- const input = dom.getFocusedInputEl(ctx)
165
- const length = input.value.length
166
- input.selectionStart = ctx.selectOnFocus ? 0 : length
167
- input.selectionEnd = length
145
+ dom.getFocusedInputEl(ctx)?.select()
168
146
  })
169
147
  },
170
148
  invokeOnComplete(ctx) {
@@ -239,9 +217,6 @@ export function machine(userContext: UserDefinedContext) {
239
217
  ctx.focusedIndex = Math.min(ctx.filledValueLength, ctx.valueLength - 1)
240
218
  })
241
219
  },
242
- preventDefault(_, evt) {
243
- evt.preventDefault()
244
- },
245
220
  blurFocusedInputIfNeeded(ctx) {
246
221
  if (!ctx.blurOnComplete) return
247
222
  raf(() => {
@@ -258,17 +233,6 @@ export function machine(userContext: UserDefinedContext) {
258
233
  )
259
234
  }
260
235
 
261
- const REGEX = {
262
- numeric: /^[0-9]+$/,
263
- alphabetic: /^[A-Za-z]+$/,
264
- alphanumeric: /^[a-zA-Z0-9]+$/i,
265
- }
266
-
267
- function isValidType(value: string, type: MachineContext["type"]) {
268
- if (!type) return true
269
- return !!REGEX[type]?.test(value)
270
- }
271
-
272
236
  function assignValue(ctx: MachineContext, value: string | string[]) {
273
237
  const arr = Array.isArray(value) ? value : value.split("").filter(Boolean)
274
238
  arr.forEach((value, index) => {
@@ -280,7 +244,7 @@ function getNextValue(current: string, next: string) {
280
244
  let nextValue = next
281
245
  if (current[0] === next[0]) nextValue = next[1]
282
246
  else if (current[0] === next[1]) nextValue = next[0]
283
- return nextValue
247
+ return nextValue.split("")[nextValue.length - 1]
284
248
  }
285
249
 
286
250
  const invoke = {
@@ -0,0 +1,18 @@
1
+ import type { MachineContext } from "./pin-input.types"
2
+
3
+ const REGEX = {
4
+ numeric: /^[0-9]+$/,
5
+ alphabetic: /^[A-Za-z]+$/,
6
+ alphanumeric: /^[a-zA-Z0-9]+$/i,
7
+ }
8
+
9
+ export function isValidType(ctx: MachineContext, value: string) {
10
+ if (!ctx.type) return true
11
+ return !!REGEX[ctx.type]?.test(value)
12
+ }
13
+
14
+ export function isValidValue(ctx: MachineContext, value: string) {
15
+ if (!ctx.pattern) return isValidType(ctx, value)
16
+ const regex = new RegExp(ctx.pattern, "g")
17
+ return regex.test(value)
18
+ }