@zag-js/pin-input 0.9.2 → 0.10.1
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.
- package/package.json +11 -10
- package/src/index.ts +4 -0
- package/src/pin-input.anatomy.ts +4 -0
- package/src/pin-input.connect.ts +174 -0
- package/src/pin-input.dom.ts +21 -0
- package/src/pin-input.machine.ts +284 -0
- package/src/pin-input.types.ts +139 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zag-js/pin-input",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Core logic for the pin-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/pin-input",
|
|
18
18
|
"sideEffects": false,
|
|
19
19
|
"files": [
|
|
20
|
-
"dist
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
21
22
|
],
|
|
22
23
|
"publishConfig": {
|
|
23
24
|
"access": "public"
|
|
@@ -26,14 +27,14 @@
|
|
|
26
27
|
"url": "https://github.com/chakra-ui/zag/issues"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@zag-js/anatomy": "0.
|
|
30
|
-
"@zag-js/dom-query": "0.
|
|
31
|
-
"@zag-js/dom-event": "0.
|
|
32
|
-
"@zag-js/form-utils": "0.
|
|
33
|
-
"@zag-js/visually-hidden": "0.
|
|
34
|
-
"@zag-js/utils": "0.
|
|
35
|
-
"@zag-js/core": "0.
|
|
36
|
-
"@zag-js/types": "0.
|
|
30
|
+
"@zag-js/anatomy": "0.10.1",
|
|
31
|
+
"@zag-js/dom-query": "0.10.1",
|
|
32
|
+
"@zag-js/dom-event": "0.10.1",
|
|
33
|
+
"@zag-js/form-utils": "0.10.1",
|
|
34
|
+
"@zag-js/visually-hidden": "0.10.1",
|
|
35
|
+
"@zag-js/utils": "0.10.1",
|
|
36
|
+
"@zag-js/core": "0.10.1",
|
|
37
|
+
"@zag-js/types": "0.10.1"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"clean-package": "2.2.0"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { EventKeyMap, getEventKey, getNativeEvent, isModifiedEvent } from "@zag-js/dom-event"
|
|
2
|
+
import { ariaAttr, dataAttr } from "@zag-js/dom-query"
|
|
3
|
+
import type { NormalizeProps, PropTypes } from "@zag-js/types"
|
|
4
|
+
import { invariant } from "@zag-js/utils"
|
|
5
|
+
import { visuallyHiddenStyle } from "@zag-js/visually-hidden"
|
|
6
|
+
import { parts } from "./pin-input.anatomy"
|
|
7
|
+
import { dom } from "./pin-input.dom"
|
|
8
|
+
import type { Send, State } from "./pin-input.types"
|
|
9
|
+
|
|
10
|
+
export function connect<T extends PropTypes>(state: State, send: Send, normalize: NormalizeProps<T>) {
|
|
11
|
+
const isValueComplete = state.context.isValueComplete
|
|
12
|
+
const isInvalid = state.context.invalid
|
|
13
|
+
const focusedIndex = state.context.focusedIndex
|
|
14
|
+
const translations = state.context.translations
|
|
15
|
+
|
|
16
|
+
function focus() {
|
|
17
|
+
dom.getFirstInputEl(state.context)?.focus()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
/**
|
|
22
|
+
* The value of the input as an array of strings.
|
|
23
|
+
*/
|
|
24
|
+
value: state.context.value,
|
|
25
|
+
/**
|
|
26
|
+
* The value of the input as a string.
|
|
27
|
+
*/
|
|
28
|
+
valueAsString: state.context.valueAsString,
|
|
29
|
+
/**
|
|
30
|
+
* Whether all inputs are filled.
|
|
31
|
+
*/
|
|
32
|
+
isValueComplete: isValueComplete,
|
|
33
|
+
/**
|
|
34
|
+
* Function to set the value of the inputs.
|
|
35
|
+
*/
|
|
36
|
+
setValue(value: string[]) {
|
|
37
|
+
if (!Array.isArray(value)) {
|
|
38
|
+
invariant("[pin-input/setValue] value must be an array")
|
|
39
|
+
}
|
|
40
|
+
send({ type: "SET_VALUE", value })
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* Function to clear the value of the inputs.
|
|
44
|
+
*/
|
|
45
|
+
clearValue() {
|
|
46
|
+
send({ type: "CLEAR_VALUE" })
|
|
47
|
+
},
|
|
48
|
+
/**
|
|
49
|
+
* Function to set the value of the input at a specific index.
|
|
50
|
+
*/
|
|
51
|
+
setValueAtIndex(index: number, value: string) {
|
|
52
|
+
send({ type: "SET_VALUE", value, index })
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* Function to focus the pin-input. This will focus the first input.
|
|
56
|
+
*/
|
|
57
|
+
focus,
|
|
58
|
+
|
|
59
|
+
rootProps: normalize.element({
|
|
60
|
+
dir: state.context.dir,
|
|
61
|
+
...parts.root.attrs,
|
|
62
|
+
id: dom.getRootId(state.context),
|
|
63
|
+
"data-invalid": dataAttr(isInvalid),
|
|
64
|
+
"data-disabled": dataAttr(state.context.disabled),
|
|
65
|
+
"data-complete": dataAttr(isValueComplete),
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
labelProps: normalize.label({
|
|
69
|
+
...parts.label.attrs,
|
|
70
|
+
htmlFor: dom.getHiddenInputId(state.context),
|
|
71
|
+
id: dom.getLabelId(state.context),
|
|
72
|
+
"data-invalid": dataAttr(isInvalid),
|
|
73
|
+
"data-disabled": dataAttr(state.context.disabled),
|
|
74
|
+
"data-complete": dataAttr(isValueComplete),
|
|
75
|
+
onClick: (event) => {
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
focus()
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
hiddenInputProps: normalize.input({
|
|
82
|
+
...parts.hiddenInput.attrs,
|
|
83
|
+
"aria-hidden": true,
|
|
84
|
+
type: "text",
|
|
85
|
+
tabIndex: -1,
|
|
86
|
+
id: dom.getHiddenInputId(state.context),
|
|
87
|
+
name: state.context.name,
|
|
88
|
+
form: state.context.form,
|
|
89
|
+
style: visuallyHiddenStyle,
|
|
90
|
+
maxLength: state.context.valueLength,
|
|
91
|
+
defaultValue: state.context.valueAsString,
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
controlProps: normalize.element({
|
|
95
|
+
...parts.control.attrs,
|
|
96
|
+
id: dom.getControlId(state.context),
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
getInputProps({ index }: { index: number }) {
|
|
100
|
+
const inputType = state.context.type === "numeric" ? "tel" : "text"
|
|
101
|
+
return normalize.input({
|
|
102
|
+
...parts.input.attrs,
|
|
103
|
+
disabled: state.context.disabled,
|
|
104
|
+
"data-disabled": dataAttr(state.context.disabled),
|
|
105
|
+
"data-complete": dataAttr(isValueComplete),
|
|
106
|
+
id: dom.getInputId(state.context, index.toString()),
|
|
107
|
+
"data-ownedby": dom.getRootId(state.context),
|
|
108
|
+
"aria-label": translations.inputLabel(index, state.context.valueLength),
|
|
109
|
+
inputMode: state.context.otp || state.context.type === "numeric" ? "numeric" : "text",
|
|
110
|
+
"aria-invalid": ariaAttr(isInvalid),
|
|
111
|
+
"data-invalid": dataAttr(isInvalid),
|
|
112
|
+
type: state.context.mask ? "password" : inputType,
|
|
113
|
+
defaultValue: state.context.value[index] || "",
|
|
114
|
+
autoCapitalize: "none",
|
|
115
|
+
autoComplete: state.context.otp ? "one-time-code" : "off",
|
|
116
|
+
placeholder: focusedIndex === index ? "" : state.context.placeholder,
|
|
117
|
+
onChange(event) {
|
|
118
|
+
const evt = getNativeEvent(event)
|
|
119
|
+
const { value } = event.currentTarget
|
|
120
|
+
if (evt.inputType === "insertFromPaste" || value.length > 2) {
|
|
121
|
+
send({ type: "PASTE", value })
|
|
122
|
+
event.preventDefault()
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (evt.inputType === "deleteContentBackward") {
|
|
127
|
+
send("BACKSPACE")
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
send({ type: "INPUT", value, index })
|
|
131
|
+
},
|
|
132
|
+
onKeyDown(event) {
|
|
133
|
+
const evt = getNativeEvent(event)
|
|
134
|
+
if (evt.isComposing || isModifiedEvent(evt)) return
|
|
135
|
+
|
|
136
|
+
const keyMap: EventKeyMap = {
|
|
137
|
+
Backspace() {
|
|
138
|
+
send("BACKSPACE")
|
|
139
|
+
},
|
|
140
|
+
Delete() {
|
|
141
|
+
send("DELETE")
|
|
142
|
+
},
|
|
143
|
+
ArrowLeft() {
|
|
144
|
+
send("ARROW_LEFT")
|
|
145
|
+
},
|
|
146
|
+
ArrowRight() {
|
|
147
|
+
send("ARROW_RIGHT")
|
|
148
|
+
},
|
|
149
|
+
Enter() {
|
|
150
|
+
send("ENTER")
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const key = getEventKey(event, { dir: state.context.dir })
|
|
155
|
+
const exec = keyMap[key]
|
|
156
|
+
|
|
157
|
+
if (exec) {
|
|
158
|
+
exec(event)
|
|
159
|
+
event.preventDefault()
|
|
160
|
+
} else {
|
|
161
|
+
if (key === "Tab") return
|
|
162
|
+
send({ type: "KEY_DOWN", value: key, preventDefault: () => event.preventDefault() })
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
onFocus() {
|
|
166
|
+
send({ type: "FOCUS", index })
|
|
167
|
+
},
|
|
168
|
+
onBlur() {
|
|
169
|
+
send({ type: "BLUR", index })
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createScope, queryAll } from "@zag-js/dom-query"
|
|
2
|
+
import type { MachineContext as Ctx } from "./pin-input.types"
|
|
3
|
+
|
|
4
|
+
export const dom = createScope({
|
|
5
|
+
getRootId: (ctx: Ctx) => ctx.ids?.root ?? `pin-input:${ctx.id}`,
|
|
6
|
+
getInputId: (ctx: Ctx, id: string) => ctx.ids?.input?.(id) ?? `pin-input:${ctx.id}:${id}`,
|
|
7
|
+
getHiddenInputId: (ctx: Ctx) => ctx.ids?.hiddenInput ?? `pin-input:${ctx.id}:hidden`,
|
|
8
|
+
getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `pin-input:${ctx.id}:label`,
|
|
9
|
+
getControlId: (ctx: Ctx) => ctx.ids?.control ?? `pin-input:${ctx.id}:control`,
|
|
10
|
+
|
|
11
|
+
getRootEl: (ctx: Ctx) => dom.getById(ctx, dom.getRootId(ctx)),
|
|
12
|
+
getElements: (ctx: Ctx) => {
|
|
13
|
+
const ownerId = CSS.escape(dom.getRootId(ctx))
|
|
14
|
+
const selector = `input[data-ownedby=${ownerId}]`
|
|
15
|
+
return queryAll<HTMLInputElement>(dom.getRootEl(ctx), selector)
|
|
16
|
+
},
|
|
17
|
+
getInputEl: (ctx: Ctx, id: string) => dom.getById<HTMLInputElement>(ctx, dom.getInputId(ctx, id)),
|
|
18
|
+
getFocusedInputEl: (ctx: Ctx) => dom.getElements(ctx)[ctx.focusedIndex],
|
|
19
|
+
getFirstInputEl: (ctx: Ctx) => dom.getElements(ctx)[0],
|
|
20
|
+
getHiddenInputEl: (ctx: Ctx) => dom.getById<HTMLInputElement>(ctx, dom.getHiddenInputId(ctx)),
|
|
21
|
+
})
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createMachine, guards } from "@zag-js/core"
|
|
2
|
+
import { raf } from "@zag-js/dom-query"
|
|
3
|
+
import { dispatchInputValueEvent } from "@zag-js/form-utils"
|
|
4
|
+
import { compact } from "@zag-js/utils"
|
|
5
|
+
import { dom } from "./pin-input.dom"
|
|
6
|
+
import type { MachineContext, MachineState, UserDefinedContext } from "./pin-input.types"
|
|
7
|
+
|
|
8
|
+
const { and, not } = guards
|
|
9
|
+
|
|
10
|
+
export function machine(userContext: UserDefinedContext) {
|
|
11
|
+
const ctx = compact(userContext)
|
|
12
|
+
return createMachine<MachineContext, MachineState>(
|
|
13
|
+
{
|
|
14
|
+
id: "pin-input",
|
|
15
|
+
initial: ctx.autoFocus ? "focused" : "idle",
|
|
16
|
+
context: {
|
|
17
|
+
value: [],
|
|
18
|
+
focusedIndex: -1,
|
|
19
|
+
placeholder: "○",
|
|
20
|
+
otp: false,
|
|
21
|
+
type: "numeric",
|
|
22
|
+
...ctx,
|
|
23
|
+
translations: {
|
|
24
|
+
inputLabel: (index, length) => `pin code ${index + 1} of ${length}`,
|
|
25
|
+
...ctx.translations,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
computed: {
|
|
30
|
+
valueLength: (ctx) => ctx.value.length,
|
|
31
|
+
filledValueLength: (ctx) => ctx.value.filter((v) => v?.trim() !== "").length,
|
|
32
|
+
isValueComplete: (ctx) => ctx.valueLength === ctx.filledValueLength,
|
|
33
|
+
valueAsString: (ctx) => ctx.value.join(""),
|
|
34
|
+
focusedValue: (ctx) => ctx.value[ctx.focusedIndex],
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
watch: {
|
|
38
|
+
focusedIndex: ["focusInput", "setInputSelection"],
|
|
39
|
+
value: ["dispatchInputEvent", "syncInputElements"],
|
|
40
|
+
isValueComplete: ["invokeOnComplete", "blurFocusedInputIfNeeded"],
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
entry: ctx.autoFocus ? ["setupValue", "setFocusIndexToFirst"] : ["setupValue"],
|
|
44
|
+
|
|
45
|
+
on: {
|
|
46
|
+
SET_VALUE: [
|
|
47
|
+
{
|
|
48
|
+
guard: "hasIndex",
|
|
49
|
+
actions: ["setValueAtIndex", "invokeOnChange"],
|
|
50
|
+
},
|
|
51
|
+
{ actions: ["setValue", "invokeOnChange"] },
|
|
52
|
+
],
|
|
53
|
+
CLEAR_VALUE: [
|
|
54
|
+
{
|
|
55
|
+
guard: "isDisabled",
|
|
56
|
+
actions: ["clearValue", "invokeOnChange"],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
actions: ["clearValue", "invokeOnChange", "setFocusIndexToFirst"],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
states: {
|
|
65
|
+
idle: {
|
|
66
|
+
on: {
|
|
67
|
+
FOCUS: {
|
|
68
|
+
target: "focused",
|
|
69
|
+
actions: "setFocusedIndex",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
focused: {
|
|
74
|
+
on: {
|
|
75
|
+
INPUT: [
|
|
76
|
+
{
|
|
77
|
+
guard: and("isFinalValue", "isValidValue"),
|
|
78
|
+
actions: ["setFocusedValue", "invokeOnChange", "syncInputValue"],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
guard: "isValidValue",
|
|
82
|
+
actions: ["setFocusedValue", "invokeOnChange", "setNextFocusedIndex", "syncInputValue"],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
PASTE: [
|
|
86
|
+
{
|
|
87
|
+
guard: "isValidValue",
|
|
88
|
+
actions: ["setPastedValue", "invokeOnChange", "setLastValueFocusIndex"],
|
|
89
|
+
},
|
|
90
|
+
{ actions: ["resetFocusedValue", "invokeOnChange"] },
|
|
91
|
+
],
|
|
92
|
+
BLUR: {
|
|
93
|
+
target: "idle",
|
|
94
|
+
actions: "clearFocusedIndex",
|
|
95
|
+
},
|
|
96
|
+
DELETE: {
|
|
97
|
+
guard: "hasValue",
|
|
98
|
+
actions: ["clearFocusedValue", "invokeOnChange"],
|
|
99
|
+
},
|
|
100
|
+
ARROW_LEFT: {
|
|
101
|
+
actions: "setPrevFocusedIndex",
|
|
102
|
+
},
|
|
103
|
+
ARROW_RIGHT: {
|
|
104
|
+
actions: "setNextFocusedIndex",
|
|
105
|
+
},
|
|
106
|
+
BACKSPACE: [
|
|
107
|
+
{
|
|
108
|
+
guard: "hasValue",
|
|
109
|
+
actions: ["clearFocusedValue", "invokeOnChange"],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
actions: ["setPrevFocusedIndex", "clearFocusedValue", "invokeOnChange"],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
ENTER: {
|
|
116
|
+
guard: "isValueComplete",
|
|
117
|
+
actions: "requestFormSubmit",
|
|
118
|
+
},
|
|
119
|
+
KEY_DOWN: {
|
|
120
|
+
guard: not("isValidValue"),
|
|
121
|
+
actions: ["preventDefault", "invokeOnInvalid"],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
guards: {
|
|
129
|
+
autoFocus: (ctx) => !!ctx.autoFocus,
|
|
130
|
+
isValueEmpty: (_ctx, evt) => evt.value === "",
|
|
131
|
+
hasValue: (ctx) => ctx.value[ctx.focusedIndex] !== "",
|
|
132
|
+
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,
|
|
145
|
+
hasIndex: (_ctx, evt) => evt.index !== undefined,
|
|
146
|
+
isDisabled: (ctx) => !!ctx.disabled,
|
|
147
|
+
},
|
|
148
|
+
actions: {
|
|
149
|
+
setupValue: (ctx) => {
|
|
150
|
+
if (ctx.value.length) return
|
|
151
|
+
const inputs = dom.getElements(ctx)
|
|
152
|
+
const emptyValues = Array.from<string>({ length: inputs.length }).fill("")
|
|
153
|
+
assign(ctx, emptyValues)
|
|
154
|
+
},
|
|
155
|
+
focusInput: (ctx) => {
|
|
156
|
+
raf(() => {
|
|
157
|
+
if (ctx.focusedIndex === -1) return
|
|
158
|
+
dom.getFocusedInputEl(ctx)?.focus()
|
|
159
|
+
})
|
|
160
|
+
},
|
|
161
|
+
setInputSelection: (ctx) => {
|
|
162
|
+
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
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
invokeOnComplete: (ctx) => {
|
|
171
|
+
if (!ctx.isValueComplete) return
|
|
172
|
+
ctx.onComplete?.({ value: Array.from(ctx.value), valueAsString: ctx.valueAsString })
|
|
173
|
+
},
|
|
174
|
+
invokeOnChange: (ctx) => {
|
|
175
|
+
ctx.onChange?.({ value: Array.from(ctx.value) })
|
|
176
|
+
},
|
|
177
|
+
dispatchInputEvent: (ctx) => {
|
|
178
|
+
const inputEl = dom.getHiddenInputEl(ctx)
|
|
179
|
+
dispatchInputValueEvent(inputEl, { value: ctx.valueAsString })
|
|
180
|
+
},
|
|
181
|
+
invokeOnInvalid: (ctx, evt) => {
|
|
182
|
+
ctx.onInvalid?.({ value: evt.value, index: ctx.focusedIndex })
|
|
183
|
+
},
|
|
184
|
+
clearFocusedIndex: (ctx) => {
|
|
185
|
+
ctx.focusedIndex = -1
|
|
186
|
+
},
|
|
187
|
+
setValue: (ctx, evt) => {
|
|
188
|
+
assign(ctx, evt.value)
|
|
189
|
+
},
|
|
190
|
+
setFocusedIndex: (ctx, evt) => {
|
|
191
|
+
ctx.focusedIndex = evt.index
|
|
192
|
+
},
|
|
193
|
+
setFocusedValue: (ctx, evt) => {
|
|
194
|
+
ctx.value[ctx.focusedIndex] = getNextValue(ctx.focusedValue, evt.value)
|
|
195
|
+
},
|
|
196
|
+
syncInputValue(ctx, evt) {
|
|
197
|
+
const input = dom.getInputEl(ctx, evt.index.toString())
|
|
198
|
+
if (!input) return
|
|
199
|
+
input.value = ctx.value[evt.index]
|
|
200
|
+
},
|
|
201
|
+
syncInputElements(ctx) {
|
|
202
|
+
const inputs = dom.getElements(ctx)
|
|
203
|
+
inputs.forEach((input, index) => {
|
|
204
|
+
input.value = ctx.value[index]
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
setPastedValue(ctx, evt) {
|
|
208
|
+
raf(() => {
|
|
209
|
+
const startIndex = ctx.focusedValue ? 1 : 0
|
|
210
|
+
const value = evt.value.substring(startIndex, startIndex + ctx.valueLength)
|
|
211
|
+
assign(ctx, value)
|
|
212
|
+
})
|
|
213
|
+
},
|
|
214
|
+
setValueAtIndex: (ctx, evt) => {
|
|
215
|
+
ctx.value[evt.index] = getNextValue(ctx.focusedValue, evt.value)
|
|
216
|
+
},
|
|
217
|
+
clearValue: (ctx) => {
|
|
218
|
+
const nextValue = Array.from<string>({ length: ctx.valueLength }).fill("")
|
|
219
|
+
assign(ctx, nextValue)
|
|
220
|
+
},
|
|
221
|
+
clearFocusedValue: (ctx) => {
|
|
222
|
+
ctx.value[ctx.focusedIndex] = ""
|
|
223
|
+
},
|
|
224
|
+
resetFocusedValue: (ctx) => {
|
|
225
|
+
const input = dom.getFocusedInputEl(ctx)
|
|
226
|
+
input.value = ctx.focusedValue
|
|
227
|
+
},
|
|
228
|
+
setFocusIndexToFirst: (ctx) => {
|
|
229
|
+
ctx.focusedIndex = 0
|
|
230
|
+
},
|
|
231
|
+
setNextFocusedIndex: (ctx) => {
|
|
232
|
+
ctx.focusedIndex = Math.min(ctx.focusedIndex + 1, ctx.valueLength - 1)
|
|
233
|
+
},
|
|
234
|
+
setPrevFocusedIndex: (ctx) => {
|
|
235
|
+
ctx.focusedIndex = Math.max(ctx.focusedIndex - 1, 0)
|
|
236
|
+
},
|
|
237
|
+
setLastValueFocusIndex: (ctx) => {
|
|
238
|
+
raf(() => {
|
|
239
|
+
ctx.focusedIndex = Math.min(ctx.filledValueLength, ctx.valueLength - 1)
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
preventDefault(_, evt) {
|
|
243
|
+
evt.preventDefault()
|
|
244
|
+
},
|
|
245
|
+
blurFocusedInputIfNeeded(ctx) {
|
|
246
|
+
if (!ctx.blurOnComplete) return
|
|
247
|
+
raf(() => {
|
|
248
|
+
dom.getFocusedInputEl(ctx)?.blur()
|
|
249
|
+
})
|
|
250
|
+
},
|
|
251
|
+
requestFormSubmit(ctx) {
|
|
252
|
+
if (!ctx.name || !ctx.isValueComplete) return
|
|
253
|
+
const input = dom.getHiddenInputEl(ctx)
|
|
254
|
+
input?.form?.requestSubmit()
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
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
|
+
function assign(ctx: MachineContext, value: string | string[]) {
|
|
273
|
+
const arr = Array.isArray(value) ? value : value.split("").filter(Boolean)
|
|
274
|
+
arr.forEach((value, index) => {
|
|
275
|
+
ctx.value[index] = value
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getNextValue(current: string, next: string) {
|
|
280
|
+
let nextValue = next
|
|
281
|
+
if (current[0] === next[0]) nextValue = next[1]
|
|
282
|
+
else if (current[0] === next[1]) nextValue = next[0]
|
|
283
|
+
return nextValue
|
|
284
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { StateMachine as S } from "@zag-js/core"
|
|
2
|
+
import type { CommonProperties, Context, DirectionProperty, RequiredBy } from "@zag-js/types"
|
|
3
|
+
|
|
4
|
+
type IntlTranslations = {
|
|
5
|
+
inputLabel: (index: number, length: number) => string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type ElementIds = Partial<{
|
|
9
|
+
root: string
|
|
10
|
+
hiddenInput: string
|
|
11
|
+
label: string
|
|
12
|
+
control: string
|
|
13
|
+
input(id: string): string
|
|
14
|
+
}>
|
|
15
|
+
|
|
16
|
+
type PublicContext = DirectionProperty &
|
|
17
|
+
CommonProperties & {
|
|
18
|
+
/**
|
|
19
|
+
* The name of the input element. Useful for form submission.
|
|
20
|
+
*/
|
|
21
|
+
name?: string
|
|
22
|
+
/**
|
|
23
|
+
* The associate form of the underlying input element.
|
|
24
|
+
*/
|
|
25
|
+
form?: string
|
|
26
|
+
/**
|
|
27
|
+
* The regular expression that the user-entered input value is checked against.
|
|
28
|
+
*/
|
|
29
|
+
pattern?: string
|
|
30
|
+
/**
|
|
31
|
+
* The ids of the elements in the pin input. Useful for composition.
|
|
32
|
+
*/
|
|
33
|
+
ids?: ElementIds
|
|
34
|
+
/**
|
|
35
|
+
* Whether the inputs are disabled
|
|
36
|
+
*/
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* The placeholder text for the input
|
|
40
|
+
*/
|
|
41
|
+
placeholder?: string
|
|
42
|
+
/**
|
|
43
|
+
* Whether to auto-focus the first input.
|
|
44
|
+
*/
|
|
45
|
+
autoFocus?: boolean
|
|
46
|
+
/**
|
|
47
|
+
* Whether the pin input is in the invalid state
|
|
48
|
+
*/
|
|
49
|
+
invalid?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* If `true`, the pin input component signals to its fields that they should
|
|
52
|
+
* use `autocomplete="one-time-code"`.
|
|
53
|
+
*/
|
|
54
|
+
otp?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* The value of the the pin input.
|
|
57
|
+
*/
|
|
58
|
+
value: string[]
|
|
59
|
+
/**
|
|
60
|
+
* The type of value the pin-input should allow
|
|
61
|
+
*/
|
|
62
|
+
type?: "alphanumeric" | "numeric" | "alphabetic"
|
|
63
|
+
/**
|
|
64
|
+
* Function called when all inputs have valid values
|
|
65
|
+
*/
|
|
66
|
+
onComplete?: (details: { value: string[]; valueAsString: string }) => void
|
|
67
|
+
/**
|
|
68
|
+
* Function called on input change
|
|
69
|
+
*/
|
|
70
|
+
onChange?: (details: { value: string[] }) => void
|
|
71
|
+
/**
|
|
72
|
+
* Function called when an invalid value is entered
|
|
73
|
+
*/
|
|
74
|
+
onInvalid?: (details: { value: string; index: number }) => void
|
|
75
|
+
/**
|
|
76
|
+
* If `true`, the input's value will be masked just like `type=password`
|
|
77
|
+
*/
|
|
78
|
+
mask?: boolean
|
|
79
|
+
/**
|
|
80
|
+
* Whether to blur the input when the value is complete
|
|
81
|
+
*/
|
|
82
|
+
blurOnComplete?: boolean
|
|
83
|
+
/**
|
|
84
|
+
* Whether to select input value when input is focused
|
|
85
|
+
*/
|
|
86
|
+
selectOnFocus?: boolean
|
|
87
|
+
/**
|
|
88
|
+
* Specifies the localized strings that identifies the accessibility elements and their states
|
|
89
|
+
*/
|
|
90
|
+
translations: IntlTranslations
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type UserDefinedContext = RequiredBy<PublicContext, "id">
|
|
94
|
+
|
|
95
|
+
type ComputedContext = Readonly<{
|
|
96
|
+
/**
|
|
97
|
+
* @computed
|
|
98
|
+
* The number of inputs
|
|
99
|
+
*/
|
|
100
|
+
valueLength: number
|
|
101
|
+
/**
|
|
102
|
+
* @computed
|
|
103
|
+
* The number of inputs that are not empty
|
|
104
|
+
*/
|
|
105
|
+
filledValueLength: number
|
|
106
|
+
/**
|
|
107
|
+
* @computed
|
|
108
|
+
* Whether all input values are valid
|
|
109
|
+
*/
|
|
110
|
+
isValueComplete: boolean
|
|
111
|
+
/**
|
|
112
|
+
* @computed
|
|
113
|
+
* The string representation of the input values
|
|
114
|
+
*/
|
|
115
|
+
valueAsString: string
|
|
116
|
+
/**
|
|
117
|
+
* @computed
|
|
118
|
+
* The value at focused index
|
|
119
|
+
*/
|
|
120
|
+
focusedValue: string
|
|
121
|
+
}>
|
|
122
|
+
|
|
123
|
+
type PrivateContext = Context<{
|
|
124
|
+
/**
|
|
125
|
+
* @internal
|
|
126
|
+
* The index of the input field that has focus
|
|
127
|
+
*/
|
|
128
|
+
focusedIndex: number
|
|
129
|
+
}>
|
|
130
|
+
|
|
131
|
+
export type MachineContext = PublicContext & PrivateContext & ComputedContext
|
|
132
|
+
|
|
133
|
+
export type MachineState = {
|
|
134
|
+
value: "idle" | "focused"
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type State = S.State<MachineContext, MachineState>
|
|
138
|
+
|
|
139
|
+
export type Send = S.Send<S.AnyEventObject>
|