Skip to content

Commit beebe51

Browse files
committed
♿️ Improve keyboard accessibility of OTPInput
1 parent 12c1fd9 commit beebe51

File tree

9 files changed

+505
-154
lines changed

9 files changed

+505
-154
lines changed

src/components/Input/input.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type InputProps = {
3939
pattern?: string
4040
required?: boolean
4141
autofocus?: boolean
42-
autocomplete?: 'on' | 'off'
42+
autocomplete?: 'on' | 'off' | 'one-time-code'
4343
className?: string
4444
labelClassName?: string
4545
[key: string]: any

src/components/OTPInput/OTPInput.astro

Lines changed: 136 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import styles from './otpinput.module.scss'
88
interface Props extends OTPInputProps {}
99
1010
const {
11-
name,
11+
name = 'otp',
1212
disabled,
1313
length = 6,
1414
groupLength = 0,
@@ -24,7 +24,7 @@ const classes = [
2424
className
2525
]
2626
27-
const inputPlaceholders = Array.from({ length }, (_, i) => i + 1)
27+
const inputs = Array.from({ length }, (_, i) => i + 1)
2828
.reduce<(number | string)[]>((acc, num, i) =>
2929
groupLength > 0 && i % groupLength === 0 && i !== 0
3030
? [...acc, separator, num]
@@ -35,59 +35,157 @@ const inputPlaceholders = Array.from({ length }, (_, i) => i + 1)
3535
<div class:list={classes}>
3636
{label && (
3737
<label
38-
for={name}
38+
for={`${name}-0`}
3939
class={styles.label}
4040
set:html={label}
4141
/>
4242
)}
4343

44-
<div class={styles['input-wrapper']}>
45-
<Input
46-
name={name || 'otp'}
47-
data-id="w-input-otp"
48-
disabled={disabled}
49-
maxlength={length}
50-
required={true}
51-
{...rest}
52-
/>
53-
54-
<div class={styles.placeholders}>
55-
{inputPlaceholders.map((placeholder, index) => (
56-
<div
57-
class={typeof placeholder === 'string' ? styles.separator : styles.placeholder}
58-
data-active={index === 0 ? true : undefined}
59-
data-separator={typeof placeholder === 'string' ? true : undefined}
60-
>
61-
{typeof placeholder === 'string' ? placeholder : ''}
62-
</div>
63-
))}
64-
</div>
44+
<div class={styles['input-wrapper']} data-length={length}>
45+
{inputs.map((input, index) =>
46+
typeof input === 'string' ? (
47+
<div class={styles.separator}>{input}</div>
48+
) : (
49+
<Input
50+
id={`${name}-${index}`}
51+
class={styles.input}
52+
type="text"
53+
maxlength="1"
54+
disabled={disabled}
55+
inputmode="numeric"
56+
autocomplete="one-time-code"
57+
data-id="w-input-otp"
58+
data-index={input}
59+
aria-label={`OTP digit ${input + 1}`}
60+
{...rest}
61+
/>
62+
)
63+
)}
6564
</div>
6665

67-
{subText && (
68-
<div class={styles.subtext} set:html={subText} />
69-
)}
66+
{subText && <div class={styles.subtext}>{subText}</div>}
7067
</div>
7168

7269
<script>
7370
import { on } from '../../utils/DOMUtils'
71+
import { dispatch } from '../../utils/event'
72+
73+
const focus = (direction: 'next' | 'prev', wrapper: HTMLElement | null, clear?: boolean) => {
74+
const index = Number(wrapper?.dataset.active)
75+
const nextIndex = direction === 'next' ? index + 1 : index - 1
76+
77+
const input = wrapper?.querySelector(`[data-index="${nextIndex}"]`)
78+
79+
if (input instanceof HTMLInputElement) {
80+
input.focus()
81+
82+
if (clear) {
83+
input.value = ''
84+
}
85+
}
86+
}
7487

7588
const addEventListeners = () => {
76-
on('[data-id="w-input-otp"]', 'input', (event: Event) => {
89+
on('[data-id="w-input-otp"]', 'keydown', (event: KeyboardEvent) => {
7790
const target = event.target as HTMLInputElement
78-
const value = target.value
79-
const placeholders = Array.from(target.nextElementSibling!.children)
80-
.filter(child => !(child as HTMLDivElement).dataset.separator)
8191

92+
if (event.key === 'Backspace' || event.key === 'Delete') {
93+
if (!target.value) {
94+
focus('prev', target.parentElement, true)
95+
}
96+
}
97+
98+
if (event.key === 'ArrowLeft') {
99+
focus('prev', target.parentElement)
100+
}
101+
102+
if (event.key === 'ArrowRight') {
103+
focus('next', target.parentElement)
104+
}
105+
}, true)
106+
107+
on('[data-id="w-input-otp"]', 'input', (event: Event) => {
108+
const target = event.target
109+
110+
if (!(target instanceof HTMLInputElement)) {
111+
return
112+
}
113+
114+
const index = Number(target.dataset.index)
115+
const emptyIndex = Array.from(target.parentElement?.querySelectorAll('input') || [])
116+
.findIndex(element => !element.value) + 1
117+
118+
if (emptyIndex !== 0 && emptyIndex < index) {
119+
const emptyElement = target.parentElement?.querySelector(`[data-index="${emptyIndex}"]`)
120+
const nextFocusElement = target.parentElement?.querySelector(`[data-index="${emptyIndex + 1}"]`)
121+
122+
if (emptyElement instanceof HTMLInputElement) {
123+
emptyElement.value = target.value
124+
}
125+
126+
if (nextFocusElement instanceof HTMLInputElement) {
127+
nextFocusElement.focus()
128+
}
129+
130+
target.value = ''
131+
132+
return
133+
}
134+
135+
if (target.value) {
136+
focus('next', target.parentElement)
137+
}
138+
}, true)
139+
140+
on('[data-id="w-input-otp"]', 'keyup', (event: Event) => {
141+
const target = event.target
142+
const container = target instanceof HTMLInputElement ? target.parentElement : null
143+
144+
if (container) {
145+
const value = Array.from(container.querySelectorAll('input') || [])
146+
.map(input => input.value)
147+
.join('')
148+
149+
dispatch('otpOnChange', value)
150+
}
151+
}, true)
152+
153+
on('[data-id="w-input-otp"]', 'paste', (event: ClipboardEvent) => {
154+
event.preventDefault()
155+
156+
const target = event.target
157+
const container = target instanceof HTMLInputElement ? target.parentElement : null
158+
159+
if (container) {
160+
const inputLength = Number(container.dataset.length)
161+
const paste = event.clipboardData?.getData('text') ?? ''
162+
const nextIndex = Math.min(paste.length + 1, inputLength)
163+
const focusInput = container.querySelector(`[data-index="${nextIndex}"]`)
164+
165+
if (focusInput instanceof HTMLInputElement) {
166+
focusInput.focus()
167+
}
168+
169+
paste.split('').slice(0, inputLength).forEach((char, i) => {
170+
const input = container.querySelector(`[data-index="${i + 1}"]`)
171+
172+
if (input instanceof HTMLInputElement) {
173+
input.value = char
174+
}
175+
})
176+
}
177+
}, true)
178+
179+
on('[data-id="w-input-otp"]', 'focus', (event: Event) => {
180+
const target = event.target
82181

83-
placeholders.forEach((placeholder, index) => {
84-
const placeholderElement = placeholder as HTMLDivElement
182+
if (target instanceof HTMLInputElement) {
183+
if (target.parentElement) {
184+
target.parentElement.dataset.active = target.dataset.index
185+
}
85186

86-
placeholderElement.innerText = value[index] || ''
87-
placeholderElement.dataset.active = value.length === index
88-
? 'true'
89-
: 'false'
90-
})
187+
setTimeout(() => target.select(), 0)
188+
}
91189
}, true)
92190
}
93191

0 commit comments

Comments
 (0)