@zag-js/pin-input 1.37.0 → 1.38.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.
@@ -123,12 +123,16 @@ function connect(service, normalize) {
123
123
  getInputProps(props) {
124
124
  const { index } = props;
125
125
  const inputType = prop("type") === "numeric" ? "tel" : "text";
126
+ const valueLength = computed("valueLength");
127
+ const tabbableIndex = focusedIndex !== -1 ? focusedIndex : Math.min(computed("filledValueLength"), valueLength - 1);
126
128
  return normalize.input({
127
129
  ...import_pin_input.parts.input.attrs,
128
130
  dir: prop("dir"),
129
131
  disabled,
132
+ tabIndex: index === tabbableIndex ? 0 : -1,
130
133
  "data-disabled": (0, import_dom_query.dataAttr)(disabled),
131
134
  "data-complete": (0, import_dom_query.dataAttr)(complete),
135
+ "data-filled": (0, import_dom_query.dataAttr)(context.get("value")[index] !== ""),
132
136
  id: dom.getInputId(scope, index.toString()),
133
137
  "data-index": index,
134
138
  "data-ownedby": dom.getRootId(scope),
@@ -136,6 +140,7 @@ function connect(service, normalize) {
136
140
  inputMode: prop("otp") || prop("type") === "numeric" ? "numeric" : "text",
137
141
  "aria-invalid": (0, import_dom_query.ariaAttr)(invalid),
138
142
  "data-invalid": (0, import_dom_query.dataAttr)(invalid),
143
+ enterKeyHint: index === valueLength - 1 ? "done" : "next",
139
144
  type: prop("mask") ? "password" : inputType,
140
145
  defaultValue: context.get("value")[index] || "",
141
146
  readOnly,
@@ -143,8 +148,10 @@ function connect(service, normalize) {
143
148
  autoComplete: prop("otp") ? "one-time-code" : "off",
144
149
  placeholder: focusedIndex === index ? "" : prop("placeholder"),
145
150
  onPaste(event) {
146
- const pastedValue = event.clipboardData?.getData("text/plain");
151
+ let pastedValue = event.clipboardData?.getData("text/plain");
147
152
  if (!pastedValue) return;
153
+ const transformer = prop("sanitizeValue");
154
+ if (transformer) pastedValue = transformer(pastedValue);
148
155
  const isValid = (0, import_pin_input2.isValidValue)(pastedValue, prop("type"), prop("pattern"));
149
156
  if (!isValid) {
150
157
  send({ type: "VALUE.INVALID", value: pastedValue });
@@ -185,6 +192,10 @@ function connect(service, normalize) {
185
192
  send({ type: "INPUT.BACKSPACE" });
186
193
  return;
187
194
  }
195
+ if (evt.inputType === "deleteByCut") {
196
+ send({ type: "INPUT.DELETE" });
197
+ return;
198
+ }
188
199
  if (value === computed("focusedValue")) return;
189
200
  send({ type: "INPUT.CHANGE", value, index });
190
201
  },
@@ -192,6 +203,11 @@ function connect(service, normalize) {
192
203
  if (event.defaultPrevented) return;
193
204
  if ((0, import_dom_query.isComposingEvent)(event)) return;
194
205
  if ((0, import_dom_query.isModifierKey)(event)) return;
206
+ if (event.key.length === 1 && computed("focusedValue") === event.key) {
207
+ event.preventDefault();
208
+ send({ type: "INPUT.ADVANCE" });
209
+ return;
210
+ }
195
211
  const keyMap = {
196
212
  Backspace() {
197
213
  send({ type: "INPUT.BACKSPACE" });
@@ -207,6 +223,12 @@ function connect(service, normalize) {
207
223
  },
208
224
  Enter() {
209
225
  send({ type: "INPUT.ENTER" });
226
+ },
227
+ Home() {
228
+ send({ type: "INPUT.HOME" });
229
+ },
230
+ End() {
231
+ send({ type: "INPUT.END" });
210
232
  }
211
233
  };
212
234
  const exec = keyMap[(0, import_dom_query.getEventKey)(event, {
@@ -99,12 +99,16 @@ function connect(service, normalize) {
99
99
  getInputProps(props) {
100
100
  const { index } = props;
101
101
  const inputType = prop("type") === "numeric" ? "tel" : "text";
102
+ const valueLength = computed("valueLength");
103
+ const tabbableIndex = focusedIndex !== -1 ? focusedIndex : Math.min(computed("filledValueLength"), valueLength - 1);
102
104
  return normalize.input({
103
105
  ...parts.input.attrs,
104
106
  dir: prop("dir"),
105
107
  disabled,
108
+ tabIndex: index === tabbableIndex ? 0 : -1,
106
109
  "data-disabled": dataAttr(disabled),
107
110
  "data-complete": dataAttr(complete),
111
+ "data-filled": dataAttr(context.get("value")[index] !== ""),
108
112
  id: dom.getInputId(scope, index.toString()),
109
113
  "data-index": index,
110
114
  "data-ownedby": dom.getRootId(scope),
@@ -112,6 +116,7 @@ function connect(service, normalize) {
112
116
  inputMode: prop("otp") || prop("type") === "numeric" ? "numeric" : "text",
113
117
  "aria-invalid": ariaAttr(invalid),
114
118
  "data-invalid": dataAttr(invalid),
119
+ enterKeyHint: index === valueLength - 1 ? "done" : "next",
115
120
  type: prop("mask") ? "password" : inputType,
116
121
  defaultValue: context.get("value")[index] || "",
117
122
  readOnly,
@@ -119,8 +124,10 @@ function connect(service, normalize) {
119
124
  autoComplete: prop("otp") ? "one-time-code" : "off",
120
125
  placeholder: focusedIndex === index ? "" : prop("placeholder"),
121
126
  onPaste(event) {
122
- const pastedValue = event.clipboardData?.getData("text/plain");
127
+ let pastedValue = event.clipboardData?.getData("text/plain");
123
128
  if (!pastedValue) return;
129
+ const transformer = prop("sanitizeValue");
130
+ if (transformer) pastedValue = transformer(pastedValue);
124
131
  const isValid = isValidValue(pastedValue, prop("type"), prop("pattern"));
125
132
  if (!isValid) {
126
133
  send({ type: "VALUE.INVALID", value: pastedValue });
@@ -161,6 +168,10 @@ function connect(service, normalize) {
161
168
  send({ type: "INPUT.BACKSPACE" });
162
169
  return;
163
170
  }
171
+ if (evt.inputType === "deleteByCut") {
172
+ send({ type: "INPUT.DELETE" });
173
+ return;
174
+ }
164
175
  if (value === computed("focusedValue")) return;
165
176
  send({ type: "INPUT.CHANGE", value, index });
166
177
  },
@@ -168,6 +179,11 @@ function connect(service, normalize) {
168
179
  if (event.defaultPrevented) return;
169
180
  if (isComposingEvent(event)) return;
170
181
  if (isModifierKey(event)) return;
182
+ if (event.key.length === 1 && computed("focusedValue") === event.key) {
183
+ event.preventDefault();
184
+ send({ type: "INPUT.ADVANCE" });
185
+ return;
186
+ }
171
187
  const keyMap = {
172
188
  Backspace() {
173
189
  send({ type: "INPUT.BACKSPACE" });
@@ -183,6 +199,12 @@ function connect(service, normalize) {
183
199
  },
184
200
  Enter() {
185
201
  send({ type: "INPUT.ENTER" });
202
+ },
203
+ Home() {
204
+ send({ type: "INPUT.HOME" });
205
+ },
206
+ End() {
207
+ send({ type: "INPUT.END" });
186
208
  }
187
209
  };
188
210
  const exec = keyMap[getEventKey(event, {
@@ -98,7 +98,7 @@ var machine = createMachine({
98
98
  action(["syncInputElements", "dispatchInputEvent"]);
99
99
  });
100
100
  track([() => computed("isValueComplete")], () => {
101
- action(["invokeOnComplete", "blurFocusedInputIfNeeded"]);
101
+ action(["invokeOnComplete", "blurFocusedInputIfNeeded", "autoSubmitIfNeeded"]);
102
102
  });
103
103
  },
104
104
  on: {
@@ -125,13 +125,16 @@ var machine = createMachine({
125
125
  focused: {
126
126
  on: {
127
127
  "INPUT.CHANGE": {
128
- actions: ["setFocusedValue", "syncInputValue", "setNextFocusedIndex"]
128
+ actions: ["setFocusedValue", "syncInputValue", "advanceFocusedIndex"]
129
+ },
130
+ "INPUT.ADVANCE": {
131
+ actions: ["advanceFocusedIndex"]
129
132
  },
130
133
  "INPUT.PASTE": {
131
134
  actions: ["setPastedValue", "setLastValueFocusIndex"]
132
135
  },
133
136
  "INPUT.FOCUS": {
134
- actions: ["setFocusedIndex"]
137
+ actions: ["setFocusedIndex", "focusInput"]
135
138
  },
136
139
  "INPUT.BLUR": {
137
140
  target: "idle",
@@ -147,10 +150,16 @@ var machine = createMachine({
147
150
  "INPUT.ARROW_RIGHT": {
148
151
  actions: ["setNextFocusedIndex"]
149
152
  },
153
+ "INPUT.HOME": {
154
+ actions: ["setFocusIndexToFirst"]
155
+ },
156
+ "INPUT.END": {
157
+ actions: ["setFocusIndexToLast"]
158
+ },
150
159
  "INPUT.BACKSPACE": [
151
160
  {
152
161
  guard: "hasValue",
153
- actions: ["clearFocusedValue"]
162
+ actions: ["clearFocusedValue", "setPrevFocusedIndex"]
154
163
  },
155
164
  {
156
165
  actions: ["setPrevFocusedIndex", "clearFocusedValue"]
@@ -186,7 +195,9 @@ var machine = createMachine({
186
195
  focusInput({ context, scope }) {
187
196
  const focusedIndex = context.get("focusedIndex");
188
197
  if (focusedIndex === -1) return;
189
- dom.getInputElAtIndex(scope, focusedIndex)?.focus({ preventScroll: true });
198
+ queueMicrotask(() => {
199
+ dom.getInputElAtIndex(scope, focusedIndex)?.focus({ preventScroll: true });
200
+ });
190
201
  },
191
202
  selectInputIfNeeded({ context, prop, scope }) {
192
203
  const focusedIndex = context.get("focusedIndex");
@@ -211,8 +222,9 @@ var machine = createMachine({
211
222
  clearFocusedIndex({ context }) {
212
223
  context.set("focusedIndex", -1);
213
224
  },
214
- setFocusedIndex({ context, event }) {
215
- context.set("focusedIndex", event.index);
225
+ setFocusedIndex({ context, event, computed }) {
226
+ const maxIndex = Math.min(computed("filledValueLength"), computed("valueLength") - 1);
227
+ context.set("focusedIndex", Math.min(event.index, maxIndex));
216
228
  },
217
229
  setValue({ context, event }) {
218
230
  const value = fill(event.value, context.get("count"));
@@ -270,14 +282,25 @@ var machine = createMachine({
270
282
  clearFocusedValue({ context, computed }) {
271
283
  const focusedIndex = context.get("focusedIndex");
272
284
  if (focusedIndex === -1) return;
273
- context.set("value", (0, import_utils.setValueAtIndex)(computed("_value"), focusedIndex, ""));
285
+ const value = [...computed("_value")];
286
+ value.splice(focusedIndex, 1);
287
+ value.push("");
288
+ context.set("value", value);
274
289
  },
275
290
  setFocusIndexToFirst({ context }) {
276
291
  context.set("focusedIndex", 0);
277
292
  },
278
- setNextFocusedIndex({ context, computed }) {
293
+ setFocusIndexToLast({ context, computed }) {
294
+ context.set("focusedIndex", Math.max(computed("filledValueLength") - 1, 0));
295
+ },
296
+ advanceFocusedIndex({ context, computed }) {
279
297
  context.set("focusedIndex", Math.min(context.get("focusedIndex") + 1, computed("valueLength") - 1));
280
298
  },
299
+ setNextFocusedIndex({ context, computed }) {
300
+ const nextIndex = context.get("focusedIndex") + 1;
301
+ const maxIndex = Math.min(computed("filledValueLength"), computed("valueLength") - 1);
302
+ context.set("focusedIndex", Math.min(nextIndex, maxIndex));
303
+ },
281
304
  setPrevFocusedIndex({ context }) {
282
305
  context.set("focusedIndex", Math.max(context.get("focusedIndex") - 1, 0));
283
306
  },
@@ -286,8 +309,8 @@ var machine = createMachine({
286
309
  context.set("focusedIndex", Math.min(computed("filledValueLength"), computed("valueLength") - 1));
287
310
  });
288
311
  },
289
- blurFocusedInputIfNeeded({ context, prop, scope }) {
290
- if (!prop("blurOnComplete")) return;
312
+ blurFocusedInputIfNeeded({ context, computed, prop, scope }) {
313
+ if (!prop("blurOnComplete") || !computed("isValueComplete")) return;
291
314
  (0, import_dom_query.raf)(() => {
292
315
  dom.getInputElAtIndex(scope, context.get("focusedIndex"))?.blur();
293
316
  });
@@ -296,6 +319,11 @@ var machine = createMachine({
296
319
  if (!prop("name") || !computed("isValueComplete")) return;
297
320
  const inputEl = dom.getHiddenInputEl(scope);
298
321
  inputEl?.form?.requestSubmit();
322
+ },
323
+ autoSubmitIfNeeded({ computed, prop, scope }) {
324
+ if (!prop("autoSubmit") || !computed("isValueComplete")) return;
325
+ const inputEl = dom.getHiddenInputEl(scope);
326
+ inputEl?.form?.requestSubmit();
299
327
  }
300
328
  }
301
329
  }
@@ -64,7 +64,7 @@ var machine = createMachine({
64
64
  action(["syncInputElements", "dispatchInputEvent"]);
65
65
  });
66
66
  track([() => computed("isValueComplete")], () => {
67
- action(["invokeOnComplete", "blurFocusedInputIfNeeded"]);
67
+ action(["invokeOnComplete", "blurFocusedInputIfNeeded", "autoSubmitIfNeeded"]);
68
68
  });
69
69
  },
70
70
  on: {
@@ -91,13 +91,16 @@ var machine = createMachine({
91
91
  focused: {
92
92
  on: {
93
93
  "INPUT.CHANGE": {
94
- actions: ["setFocusedValue", "syncInputValue", "setNextFocusedIndex"]
94
+ actions: ["setFocusedValue", "syncInputValue", "advanceFocusedIndex"]
95
+ },
96
+ "INPUT.ADVANCE": {
97
+ actions: ["advanceFocusedIndex"]
95
98
  },
96
99
  "INPUT.PASTE": {
97
100
  actions: ["setPastedValue", "setLastValueFocusIndex"]
98
101
  },
99
102
  "INPUT.FOCUS": {
100
- actions: ["setFocusedIndex"]
103
+ actions: ["setFocusedIndex", "focusInput"]
101
104
  },
102
105
  "INPUT.BLUR": {
103
106
  target: "idle",
@@ -113,10 +116,16 @@ var machine = createMachine({
113
116
  "INPUT.ARROW_RIGHT": {
114
117
  actions: ["setNextFocusedIndex"]
115
118
  },
119
+ "INPUT.HOME": {
120
+ actions: ["setFocusIndexToFirst"]
121
+ },
122
+ "INPUT.END": {
123
+ actions: ["setFocusIndexToLast"]
124
+ },
116
125
  "INPUT.BACKSPACE": [
117
126
  {
118
127
  guard: "hasValue",
119
- actions: ["clearFocusedValue"]
128
+ actions: ["clearFocusedValue", "setPrevFocusedIndex"]
120
129
  },
121
130
  {
122
131
  actions: ["setPrevFocusedIndex", "clearFocusedValue"]
@@ -152,7 +161,9 @@ var machine = createMachine({
152
161
  focusInput({ context, scope }) {
153
162
  const focusedIndex = context.get("focusedIndex");
154
163
  if (focusedIndex === -1) return;
155
- dom.getInputElAtIndex(scope, focusedIndex)?.focus({ preventScroll: true });
164
+ queueMicrotask(() => {
165
+ dom.getInputElAtIndex(scope, focusedIndex)?.focus({ preventScroll: true });
166
+ });
156
167
  },
157
168
  selectInputIfNeeded({ context, prop, scope }) {
158
169
  const focusedIndex = context.get("focusedIndex");
@@ -177,8 +188,9 @@ var machine = createMachine({
177
188
  clearFocusedIndex({ context }) {
178
189
  context.set("focusedIndex", -1);
179
190
  },
180
- setFocusedIndex({ context, event }) {
181
- context.set("focusedIndex", event.index);
191
+ setFocusedIndex({ context, event, computed }) {
192
+ const maxIndex = Math.min(computed("filledValueLength"), computed("valueLength") - 1);
193
+ context.set("focusedIndex", Math.min(event.index, maxIndex));
182
194
  },
183
195
  setValue({ context, event }) {
184
196
  const value = fill(event.value, context.get("count"));
@@ -236,14 +248,25 @@ var machine = createMachine({
236
248
  clearFocusedValue({ context, computed }) {
237
249
  const focusedIndex = context.get("focusedIndex");
238
250
  if (focusedIndex === -1) return;
239
- context.set("value", setValueAtIndex(computed("_value"), focusedIndex, ""));
251
+ const value = [...computed("_value")];
252
+ value.splice(focusedIndex, 1);
253
+ value.push("");
254
+ context.set("value", value);
240
255
  },
241
256
  setFocusIndexToFirst({ context }) {
242
257
  context.set("focusedIndex", 0);
243
258
  },
244
- setNextFocusedIndex({ context, computed }) {
259
+ setFocusIndexToLast({ context, computed }) {
260
+ context.set("focusedIndex", Math.max(computed("filledValueLength") - 1, 0));
261
+ },
262
+ advanceFocusedIndex({ context, computed }) {
245
263
  context.set("focusedIndex", Math.min(context.get("focusedIndex") + 1, computed("valueLength") - 1));
246
264
  },
265
+ setNextFocusedIndex({ context, computed }) {
266
+ const nextIndex = context.get("focusedIndex") + 1;
267
+ const maxIndex = Math.min(computed("filledValueLength"), computed("valueLength") - 1);
268
+ context.set("focusedIndex", Math.min(nextIndex, maxIndex));
269
+ },
247
270
  setPrevFocusedIndex({ context }) {
248
271
  context.set("focusedIndex", Math.max(context.get("focusedIndex") - 1, 0));
249
272
  },
@@ -252,8 +275,8 @@ var machine = createMachine({
252
275
  context.set("focusedIndex", Math.min(computed("filledValueLength"), computed("valueLength") - 1));
253
276
  });
254
277
  },
255
- blurFocusedInputIfNeeded({ context, prop, scope }) {
256
- if (!prop("blurOnComplete")) return;
278
+ blurFocusedInputIfNeeded({ context, computed, prop, scope }) {
279
+ if (!prop("blurOnComplete") || !computed("isValueComplete")) return;
257
280
  raf(() => {
258
281
  dom.getInputElAtIndex(scope, context.get("focusedIndex"))?.blur();
259
282
  });
@@ -262,6 +285,11 @@ var machine = createMachine({
262
285
  if (!prop("name") || !computed("isValueComplete")) return;
263
286
  const inputEl = dom.getHiddenInputEl(scope);
264
287
  inputEl?.form?.requestSubmit();
288
+ },
289
+ autoSubmitIfNeeded({ computed, prop, scope }) {
290
+ if (!prop("autoSubmit") || !computed("isValueComplete")) return;
291
+ const inputEl = dom.getHiddenInputEl(scope);
292
+ inputEl?.form?.requestSubmit();
265
293
  }
266
294
  }
267
295
  }
@@ -28,6 +28,7 @@ var import_types = require("@zag-js/types");
28
28
  var import_utils = require("@zag-js/utils");
29
29
  var props = (0, import_types.createProps)()([
30
30
  "autoFocus",
31
+ "autoSubmit",
31
32
  "blurOnComplete",
32
33
  "count",
33
34
  "defaultValue",
@@ -44,6 +45,7 @@ var props = (0, import_types.createProps)()([
44
45
  "onValueComplete",
45
46
  "onValueInvalid",
46
47
  "otp",
48
+ "sanitizeValue",
47
49
  "pattern",
48
50
  "placeholder",
49
51
  "readOnly",
@@ -3,6 +3,7 @@ import { createProps } from "@zag-js/types";
3
3
  import { createSplitProps } from "@zag-js/utils";
4
4
  var props = createProps()([
5
5
  "autoFocus",
6
+ "autoSubmit",
6
7
  "blurOnComplete",
7
8
  "count",
8
9
  "defaultValue",
@@ -19,6 +20,7 @@ var props = createProps()([
19
20
  "onValueComplete",
20
21
  "onValueInvalid",
21
22
  "otp",
23
+ "sanitizeValue",
22
24
  "pattern",
23
25
  "placeholder",
24
26
  "readOnly",
@@ -96,6 +96,10 @@ interface PinInputProps extends DirectionProperty, CommonProperties {
96
96
  * If `true`, the input's value will be masked just like `type=password`
97
97
  */
98
98
  mask?: boolean | undefined;
99
+ /**
100
+ * Whether to auto-submit the owning form when all inputs are filled.
101
+ */
102
+ autoSubmit?: boolean | undefined;
99
103
  /**
100
104
  * Whether to blur the input when the value is complete
101
105
  */
@@ -104,6 +108,12 @@ interface PinInputProps extends DirectionProperty, CommonProperties {
104
108
  * Whether to select input value when input is focused
105
109
  */
106
110
  selectOnFocus?: boolean | undefined;
111
+ /**
112
+ * Function to sanitize pasted values before validation.
113
+ * Useful for stripping dashes, spaces, or other formatting.
114
+ * @example (value) => value.replace(/-/g, "")
115
+ */
116
+ sanitizeValue?: ((value: string) => string) | undefined;
107
117
  /**
108
118
  * Specifies the localized strings that identifies the accessibility elements and their states
109
119
  */
@@ -96,6 +96,10 @@ interface PinInputProps extends DirectionProperty, CommonProperties {
96
96
  * If `true`, the input's value will be masked just like `type=password`
97
97
  */
98
98
  mask?: boolean | undefined;
99
+ /**
100
+ * Whether to auto-submit the owning form when all inputs are filled.
101
+ */
102
+ autoSubmit?: boolean | undefined;
99
103
  /**
100
104
  * Whether to blur the input when the value is complete
101
105
  */
@@ -104,6 +108,12 @@ interface PinInputProps extends DirectionProperty, CommonProperties {
104
108
  * Whether to select input value when input is focused
105
109
  */
106
110
  selectOnFocus?: boolean | undefined;
111
+ /**
112
+ * Function to sanitize pasted values before validation.
113
+ * Useful for stripping dashes, spaces, or other formatting.
114
+ * @example (value) => value.replace(/-/g, "")
115
+ */
116
+ sanitizeValue?: ((value: string) => string) | undefined;
107
117
  /**
108
118
  * Specifies the localized strings that identifies the accessibility elements and their states
109
119
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zag-js/pin-input",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "Core logic for the pin-input widget implemented as a state machine",
5
5
  "keywords": [
6
6
  "js",
@@ -26,11 +26,11 @@
26
26
  "url": "https://github.com/chakra-ui/zag/issues"
27
27
  },
28
28
  "dependencies": {
29
- "@zag-js/anatomy": "1.37.0",
30
- "@zag-js/dom-query": "1.37.0",
31
- "@zag-js/utils": "1.37.0",
32
- "@zag-js/core": "1.37.0",
33
- "@zag-js/types": "1.37.0"
29
+ "@zag-js/anatomy": "1.38.0",
30
+ "@zag-js/dom-query": "1.38.0",
31
+ "@zag-js/utils": "1.38.0",
32
+ "@zag-js/core": "1.38.0",
33
+ "@zag-js/types": "1.38.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "clean-package": "2.2.0"