@a4ui/core 0.34.0 → 0.35.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.
Files changed (42) hide show
  1. package/dist/elements.css +119 -0
  2. package/dist/elements.iife.js +2 -2
  3. package/dist/elements.js +115 -114
  4. package/dist/full.css +119 -0
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +8175 -5443
  7. package/dist/ui/ActivityFeed.d.ts +49 -0
  8. package/dist/ui/AvailabilityPicker.d.ts +35 -0
  9. package/dist/ui/CodeEditor.d.ts +25 -0
  10. package/dist/ui/EmojiPicker.d.ts +18 -0
  11. package/dist/ui/EventScheduler.d.ts +41 -0
  12. package/dist/ui/InteractiveMap.d.ts +50 -0
  13. package/dist/ui/JsonViewer.d.ts +24 -0
  14. package/dist/ui/LocationPicker.d.ts +25 -0
  15. package/dist/ui/MaskedInput.d.ts +26 -0
  16. package/dist/ui/OtpInput.d.ts +29 -0
  17. package/dist/ui/PresenceAvatars.d.ts +54 -0
  18. package/dist/ui/QueryBuilder.d.ts +56 -0
  19. package/dist/ui/RingProgress.d.ts +2 -0
  20. package/dist/ui/SignaturePad.d.ts +19 -0
  21. package/dist/ui/SpreadsheetGrid.d.ts +27 -0
  22. package/package.json +1 -1
  23. package/src/index.ts +36 -1
  24. package/src/ui/ActivityFeed.tsx +178 -0
  25. package/src/ui/AudioWaveform.tsx +1 -3
  26. package/src/ui/AvailabilityPicker.tsx +163 -0
  27. package/src/ui/CodeEditor.tsx +277 -0
  28. package/src/ui/EmojiPicker.tsx +424 -0
  29. package/src/ui/EventScheduler.tsx +286 -0
  30. package/src/ui/GanttChart.tsx +9 -1
  31. package/src/ui/InteractiveMap.tsx +349 -0
  32. package/src/ui/JsonViewer.tsx +189 -0
  33. package/src/ui/LocationPicker.tsx +96 -0
  34. package/src/ui/MaskedInput.tsx +108 -0
  35. package/src/ui/OnboardingChecklist.tsx +1 -0
  36. package/src/ui/OtpInput.tsx +153 -0
  37. package/src/ui/PivotTable.tsx +0 -0
  38. package/src/ui/PresenceAvatars.tsx +142 -0
  39. package/src/ui/QueryBuilder.tsx +281 -0
  40. package/src/ui/RingProgress.tsx +3 -0
  41. package/src/ui/SignaturePad.tsx +221 -0
  42. package/src/ui/SpreadsheetGrid.tsx +307 -0
@@ -0,0 +1,189 @@
1
+ // Collapsible JSON tree with type-tinted primitive values.
2
+ import { ChevronRight } from 'lucide-solid'
3
+ import type { JSX } from 'solid-js'
4
+ import { createMemo, createSignal, For, Show } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+ import { Input } from './Input'
8
+
9
+ export interface JsonViewerProps {
10
+ /** The value to render — object, array, or a primitive at the root. */
11
+ data: unknown
12
+ /** Expand every object/array node on first render. Defaults to `false`. */
13
+ defaultExpanded?: boolean
14
+ class?: string
15
+ }
16
+
17
+ type Entry = { key: string; value: unknown }
18
+
19
+ function isExpandable(value: unknown): value is Record<string, unknown> | unknown[] {
20
+ return typeof value === 'object' && value !== null
21
+ }
22
+
23
+ function entriesOf(value: Record<string, unknown> | unknown[]): Entry[] {
24
+ return Array.isArray(value)
25
+ ? value.map((v, i) => ({ key: String(i), value: v }))
26
+ : Object.entries(value).map(([key, v]) => ({ key, value: v }))
27
+ }
28
+
29
+ function summaryOf(value: Record<string, unknown> | unknown[]): string {
30
+ const count = entriesOf(value).length
31
+ return Array.isArray(value) ? `[${count}]` : `{${count}}`
32
+ }
33
+
34
+ /** Collects every expandable node's path so `defaultExpanded` can seed them all. */
35
+ function collectPaths(value: unknown, path: string, acc: Set<string>): void {
36
+ if (!isExpandable(value)) return
37
+ acc.add(path)
38
+ for (const { key, value: child } of entriesOf(value)) collectPaths(child, `${path}.${key}`, acc)
39
+ }
40
+
41
+ function valueClass(value: unknown): string {
42
+ if (value === null) return 'text-muted-foreground'
43
+ switch (typeof value) {
44
+ case 'string':
45
+ return 'text-accent'
46
+ case 'number':
47
+ return 'text-primary'
48
+ case 'boolean':
49
+ return 'text-secondary-foreground'
50
+ default:
51
+ return 'text-foreground'
52
+ }
53
+ }
54
+
55
+ function formatPrimitive(value: unknown): string {
56
+ return typeof value === 'string' ? `"${value}"` : String(value)
57
+ }
58
+
59
+ function matches(entry: Entry, query: string): boolean {
60
+ if (entry.key.toLowerCase().includes(query)) return true
61
+ if (isExpandable(entry.value)) return entriesOf(entry.value).some((e) => matches(e, query))
62
+ return formatPrimitive(entry.value).toLowerCase().includes(query)
63
+ }
64
+
65
+ /**
66
+ * Collapsible, indented JSON tree. Objects/arrays are expandable nodes (chevron
67
+ * + key + a `{…}` / `[…]` summary with child count); primitives render as
68
+ * `key: value` with the value tinted by type. Expanded state is tracked
69
+ * internally by path, seeded fully-open when `defaultExpanded` is set. An
70
+ * optional search box highlights keys/values that match.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * <JsonViewer
75
+ * defaultExpanded
76
+ * data={{ user: { id: 1, name: 'Ada', active: true, tags: ['admin', 'core'] } }}
77
+ * />
78
+ * ```
79
+ */
80
+ export function JsonViewer(props: JsonViewerProps): JSX.Element {
81
+ const [expanded, setExpanded] = createSignal<Set<string>>(seedExpanded())
82
+ const [query, setQuery] = createSignal('')
83
+
84
+ function seedExpanded(): Set<string> {
85
+ if (!props.defaultExpanded) return new Set()
86
+ const acc = new Set<string>()
87
+ collectPaths(props.data, '$', acc)
88
+ return acc
89
+ }
90
+
91
+ const toggle = (path: string) =>
92
+ setExpanded((prev) => {
93
+ const next = new Set(prev)
94
+ if (next.has(path)) next.delete(path)
95
+ else next.add(path)
96
+ return next
97
+ })
98
+
99
+ const normalizedQuery = createMemo(() => query().trim().toLowerCase())
100
+
101
+ const highlight = (text: string): JSX.Element => {
102
+ const q = normalizedQuery()
103
+ if (!q) return <>{text}</>
104
+ const i = text.toLowerCase().indexOf(q)
105
+ if (i === -1) return <>{text}</>
106
+ return (
107
+ <>
108
+ {text.slice(0, i)}
109
+ <mark class="bg-accent/30 text-inherit">{text.slice(i, i + q.length)}</mark>
110
+ {text.slice(i + q.length)}
111
+ </>
112
+ )
113
+ }
114
+
115
+ const Node = (nodeProps: { entry: Entry; path: string; depth: number }): JSX.Element => {
116
+ const value = () => nodeProps.entry.value
117
+ const path = () => nodeProps.path
118
+ const q = normalizedQuery()
119
+ const visible = createMemo(() => !q || matches(nodeProps.entry, q))
120
+
121
+ return (
122
+ <Show when={visible()}>
123
+ <li style={{ 'padding-left': `${nodeProps.depth * 1}rem` }}>
124
+ <Show
125
+ when={isExpandable(value())}
126
+ fallback={
127
+ <div class="flex items-baseline gap-1 py-0.5">
128
+ <span class="w-3.5 shrink-0" aria-hidden="true" />
129
+ <span class="text-foreground">{highlight(nodeProps.entry.key)}:</span>
130
+ <span class={valueClass(value())}>{highlight(formatPrimitive(value()))}</span>
131
+ </div>
132
+ }
133
+ >
134
+ {(() => {
135
+ const container = value() as Record<string, unknown> | unknown[]
136
+ const isOpen = () => expanded().has(path())
137
+ return (
138
+ <>
139
+ <div
140
+ class="flex items-baseline gap-1 py-0.5 cursor-pointer rounded hover:bg-muted"
141
+ onClick={() => toggle(path())}
142
+ >
143
+ <ChevronRight
144
+ class={cn(
145
+ 'h-3.5 w-3.5 shrink-0 self-center transition-transform duration-150',
146
+ isOpen() && 'rotate-90',
147
+ )}
148
+ aria-hidden="true"
149
+ />
150
+ <span class="text-foreground">{highlight(nodeProps.entry.key)}:</span>
151
+ <span class="text-muted-foreground">{summaryOf(container)}</span>
152
+ </div>
153
+ <Show when={isOpen()}>
154
+ <ul>
155
+ <For each={entriesOf(container)}>
156
+ {(child) => (
157
+ <Node entry={child} path={`${path()}.${child.key}`} depth={nodeProps.depth + 1} />
158
+ )}
159
+ </For>
160
+ </ul>
161
+ </Show>
162
+ </>
163
+ )
164
+ })()}
165
+ </Show>
166
+ </li>
167
+ </Show>
168
+ )
169
+ }
170
+
171
+ const rootEntries = createMemo<Entry[]>(() =>
172
+ isExpandable(props.data) ? entriesOf(props.data) : [{ key: '$', value: props.data }],
173
+ )
174
+
175
+ return (
176
+ <div class={cn('font-mono text-sm whitespace-pre', props.class)}>
177
+ <Input
178
+ value={query()}
179
+ onInput={setQuery}
180
+ placeholder="Search keys/values…"
181
+ class="mb-2 font-sans text-sm"
182
+ aria-label="Search JSON"
183
+ />
184
+ <ul>
185
+ <For each={rootEntries()}>{(entry) => <Node entry={entry} path={`$.${entry.key}`} depth={0} />}</For>
186
+ </ul>
187
+ </div>
188
+ )
189
+ }
@@ -0,0 +1,96 @@
1
+ // A form field pairing a free-text label with an InteractiveMap: click (or
2
+ // drag-release) on the map to drop the pin, or use the browser's geolocation
3
+ // to jump straight to the user's position. Controlled (`value`/`onChange`) or
4
+ // uncontrolled (internal signal, seeded once from `value`).
5
+ import { Crosshair } from 'lucide-solid'
6
+ import { createSignal, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { Button } from './Button'
10
+ import { InteractiveMap, type MapMarker } from './InteractiveMap'
11
+
12
+ export interface LocationValue {
13
+ lat: number
14
+ lng: number
15
+ label?: string
16
+ }
17
+
18
+ export interface LocationPickerProps {
19
+ /** Controlled value. Omit to let the component manage its own state. */
20
+ value?: LocationValue
21
+ /** Fires with the full value whenever the label, the pin, or geolocation changes it. */
22
+ onChange?: (v: LocationValue) => void
23
+ class?: string
24
+ }
25
+
26
+ const MAP_ZOOM = 12
27
+ const MAP_HEIGHT = 240
28
+
29
+ /**
30
+ * Text label + compact map field for picking a location: click or drag the
31
+ * map to drop the pin (no geocoding — the label is a free-text tag the caller
32
+ * attaches to the coordinates), or use the "use my location" button.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * const [place, setPlace] = createSignal<LocationValue>({ lat: 48.8584, lng: 2.2945, label: 'Eiffel Tower' })
37
+ * <LocationPicker value={place()} onChange={setPlace} />
38
+ * ```
39
+ */
40
+ export function LocationPicker(props: LocationPickerProps): JSX.Element {
41
+ const [internal, setInternal] = createSignal<LocationValue>(props.value ?? { lat: 0, lng: 0 })
42
+
43
+ // Controlled when `value` is passed (read fresh every time, like a normal
44
+ // controlled input); falls back to internal state otherwise.
45
+ const current = (): LocationValue => props.value ?? internal()
46
+
47
+ const emit = (next: LocationValue): void => {
48
+ setInternal(next)
49
+ props.onChange?.(next)
50
+ }
51
+
52
+ const setCoord = (coord: { lat: number; lng: number }): void => {
53
+ emit({ ...current(), lat: coord.lat, lng: coord.lng })
54
+ }
55
+
56
+ const setLabel = (label: string): void => {
57
+ emit({ ...current(), label })
58
+ }
59
+
60
+ const useMyLocation = (): void => {
61
+ if (typeof navigator === 'undefined' || !navigator.geolocation) return
62
+ navigator.geolocation.getCurrentPosition(
63
+ (position) => setCoord({ lat: position.coords.latitude, lng: position.coords.longitude }),
64
+ (error) => console.error('LocationPicker: geolocation failed', error),
65
+ )
66
+ }
67
+
68
+ const pin = (): MapMarker[] => {
69
+ const { lat, lng, label } = current()
70
+ return [{ lat, lng, label: label || undefined, id: 'picked' }]
71
+ }
72
+
73
+ return (
74
+ <div class={cn('flex flex-col gap-2', props.class)}>
75
+ <div class="flex gap-2">
76
+ <input
77
+ type="text"
78
+ value={current().label ?? ''}
79
+ onInput={(event) => setLabel(event.currentTarget.value)}
80
+ placeholder="Location label"
81
+ class="h-9 flex-1 rounded-md border border-border bg-input px-3 text-sm text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:outline-none"
82
+ />
83
+ <Button variant="outline" aria-label="Use my location" onClick={useMyLocation}>
84
+ <Crosshair class="h-4 w-4" />
85
+ </Button>
86
+ </div>
87
+ <InteractiveMap
88
+ center={{ lat: current().lat, lng: current().lng }}
89
+ zoom={MAP_ZOOM}
90
+ height={MAP_HEIGHT}
91
+ markers={pin()}
92
+ onClick={setCoord}
93
+ />
94
+ </div>
95
+ )
96
+ }
@@ -0,0 +1,108 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { createEffect, createSignal, splitProps } from 'solid-js'
3
+
4
+ import { cn } from '../lib/cn'
5
+
6
+ const INPUT_BASE =
7
+ 'w-full rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground px-3 py-2 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50'
8
+
9
+ export interface MaskedInputProps {
10
+ /** `#` = digit slot, `A` = letter slot, any other char is a literal, e.g. `'(###) ###-####'`. */
11
+ mask: string
12
+ /** Controlled raw value (just the entered #/A characters, no literals). */
13
+ value?: string
14
+ /** Called with `(raw, formatted)` on every accepted keystroke or paste. */
15
+ onInput?: (raw: string, formatted: string) => void
16
+ placeholder?: string
17
+ class?: string
18
+ 'aria-label'?: string
19
+ }
20
+
21
+ const matchesSlot = (mask: string, i: number, char: string): boolean => {
22
+ const slot = mask[i]
23
+ if (slot === '#') return /[0-9]/.test(char)
24
+ if (slot === 'A') return /[a-zA-Z]/.test(char)
25
+ return false
26
+ }
27
+
28
+ const isLiteral = (mask: string, i: number): boolean => mask[i] !== '#' && mask[i] !== 'A'
29
+
30
+ /** Build the formatted string for a mask given only the raw slot characters. */
31
+ const format = (mask: string, raw: string): string => {
32
+ let out = ''
33
+ let r = 0
34
+ for (let i = 0; i < mask.length && r < raw.length; i++) {
35
+ if (isLiteral(mask, i)) {
36
+ out += mask[i]
37
+ continue
38
+ }
39
+ out += raw[r]
40
+ r++
41
+ }
42
+ return out
43
+ }
44
+
45
+ /** Extract the raw #/A characters a formatted string carries, validating each against its slot. */
46
+ const extractRaw = (mask: string, formatted: string): string => {
47
+ let raw = ''
48
+ let m = 0
49
+ for (const char of formatted) {
50
+ while (m < mask.length && isLiteral(mask, m)) m++
51
+ if (m >= mask.length) break
52
+ if (matchesSlot(mask, m, char)) {
53
+ raw += char
54
+ m++
55
+ }
56
+ }
57
+ return raw
58
+ }
59
+
60
+ /**
61
+ * Format-as-you-type input over a real `<input>`. `mask` uses `#` for a digit
62
+ * slot and `A` for a letter slot; any other character is a literal inserted
63
+ * automatically as the user reaches it. Characters that don't fit the next
64
+ * slot are rejected, and pasted text is normalized by re-extracting only its
65
+ * valid #/A characters. Controlled (`value`) or uncontrolled.
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * const [phone, setPhone] = createSignal('')
70
+ * <MaskedInput mask="(###) ###-####" value={phone()} onInput={setPhone} placeholder="(555) 123-4567" />
71
+ * ```
72
+ */
73
+ export function MaskedInput(props: MaskedInputProps): JSX.Element {
74
+ const [local] = splitProps(props, ['mask', 'value', 'onInput', 'placeholder', 'class', 'aria-label'])
75
+
76
+ const maxRaw = () => local.mask.split('').filter((c) => c === '#' || c === 'A').length
77
+
78
+ const [raw, setRaw] = createSignal(extractRaw(local.mask, local.value ?? '').slice(0, maxRaw()))
79
+
80
+ createEffect(() => {
81
+ if (local.value !== undefined) setRaw(extractRaw(local.mask, local.value).slice(0, maxRaw()))
82
+ })
83
+
84
+ const emit = (nextRaw: string) => {
85
+ setRaw(nextRaw)
86
+ local.onInput?.(nextRaw, format(local.mask, nextRaw))
87
+ }
88
+
89
+ const handleInput = (ev: InputEvent & { currentTarget: HTMLInputElement }) => {
90
+ const typedFormatted = ev.currentTarget.value
91
+ // Re-derive raw from the full field value so pasted or IME text is
92
+ // normalized the same way as single keystrokes.
93
+ const nextRaw = extractRaw(local.mask, typedFormatted).slice(0, maxRaw())
94
+ emit(nextRaw)
95
+ }
96
+
97
+ return (
98
+ <input
99
+ class={cn(INPUT_BASE, local.class)}
100
+ type="text"
101
+ inputMode="text"
102
+ placeholder={local.placeholder ?? local.mask}
103
+ value={format(local.mask, raw())}
104
+ onInput={handleInput}
105
+ aria-label={local['aria-label']}
106
+ />
107
+ )
108
+ }
@@ -79,6 +79,7 @@ export function OnboardingChecklist(props: OnboardingChecklistProps): JSX.Elemen
79
79
  value={percent()}
80
80
  size={56}
81
81
  thickness={6}
82
+ aria-label={`${done()} of ${total()} steps complete`}
82
83
  label={
83
84
  <span class="text-xs">
84
85
  {done()}/{total()}
@@ -0,0 +1,153 @@
1
+ import type { JSX } from 'solid-js'
2
+ import { createEffect, createSignal, For, splitProps } from 'solid-js'
3
+
4
+ import { cn } from '../lib/cn'
5
+
6
+ const BOX_BASE =
7
+ 'h-11 w-10 rounded-md border border-border bg-card text-center text-lg font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50'
8
+
9
+ export interface OtpInputProps {
10
+ /** Number of single-character boxes. @default 6 */
11
+ length?: number
12
+ /** Controlled value; when provided, the group renders it and the parent owns state. */
13
+ value?: string
14
+ /** Called with the full joined value on every box change (both typing and deletion). */
15
+ onInput?: (value: string) => void
16
+ /** Called once with the full value when every box is filled. */
17
+ onComplete?: (value: string) => void
18
+ /** Restrict input to digits and switch the mobile keyboard to numeric. */
19
+ numeric?: boolean
20
+ class?: string
21
+ 'aria-label'?: string
22
+ }
23
+
24
+ /**
25
+ * Segmented one-time-passcode input: one `<input maxlength=1>` per character.
26
+ * Typing advances focus to the next box, Backspace on an empty box moves back
27
+ * and clears the previous one, arrow keys move focus, and pasting a string
28
+ * distributes its characters across boxes starting at the focused index.
29
+ * Works controlled (pass `value`) or uncontrolled.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * const [code, setCode] = createSignal('')
34
+ * <OtpInput length={6} value={code()} onInput={setCode} numeric onComplete={verify} />
35
+ * ```
36
+ */
37
+ export function OtpInput(props: OtpInputProps): JSX.Element {
38
+ const [local] = splitProps(props, [
39
+ 'length',
40
+ 'value',
41
+ 'onInput',
42
+ 'onComplete',
43
+ 'numeric',
44
+ 'class',
45
+ 'aria-label',
46
+ ])
47
+ const length = () => local.length ?? 6
48
+
49
+ const toChars = (v: string) => {
50
+ const chars = v.slice(0, length()).split('')
51
+ return Array.from({ length: length() }, (_, i) => chars[i] ?? '')
52
+ }
53
+
54
+ const [chars, setChars] = createSignal<string[]>(toChars(local.value ?? ''))
55
+ const inputs: (HTMLInputElement | undefined)[] = []
56
+
57
+ createEffect(() => {
58
+ if (local.value !== undefined) setChars(toChars(local.value))
59
+ })
60
+
61
+ const emit = (next: string[]) => {
62
+ setChars(next)
63
+ const joined = next.join('')
64
+ local.onInput?.(joined)
65
+ if (next.every((c) => c !== '')) local.onComplete?.(joined)
66
+ }
67
+
68
+ const isValidChar = (c: string) => (local.numeric ? /^[0-9]$/.test(c) : c.length === 1)
69
+
70
+ const handleInput = (index: number, raw: string) => {
71
+ // Take the last typed character in case the box already held one.
72
+ const typed = raw.slice(-1)
73
+ if (typed === '') {
74
+ const next = [...chars()]
75
+ next[index] = ''
76
+ emit(next)
77
+ return
78
+ }
79
+ if (!isValidChar(typed)) {
80
+ // Revert the DOM to the last known-good value for this box.
81
+ const el = inputs[index]
82
+ if (el) el.value = chars()[index] ?? ''
83
+ return
84
+ }
85
+ const next = [...chars()]
86
+ next[index] = typed
87
+ emit(next)
88
+ if (index < length() - 1) inputs[index + 1]?.focus()
89
+ }
90
+
91
+ const handleKeyDown = (index: number, ev: KeyboardEvent) => {
92
+ if (ev.key === 'Backspace') {
93
+ if (chars()[index] === '' && index > 0) {
94
+ ev.preventDefault()
95
+ const next = [...chars()]
96
+ next[index - 1] = ''
97
+ emit(next)
98
+ inputs[index - 1]?.focus()
99
+ }
100
+ return
101
+ }
102
+ if (ev.key === 'ArrowLeft' && index > 0) {
103
+ ev.preventDefault()
104
+ inputs[index - 1]?.focus()
105
+ return
106
+ }
107
+ if (ev.key === 'ArrowRight' && index < length() - 1) {
108
+ ev.preventDefault()
109
+ inputs[index + 1]?.focus()
110
+ }
111
+ }
112
+
113
+ const handlePaste = (index: number, ev: ClipboardEvent) => {
114
+ ev.preventDefault()
115
+ const pasted = ev.clipboardData?.getData('text') ?? ''
116
+ const valid = pasted.split('').filter(isValidChar)
117
+ if (valid.length === 0) return
118
+ const next = [...chars()]
119
+ let last = index
120
+ for (let i = 0; i < valid.length && index + i < length(); i++) {
121
+ next[index + i] = valid[i]
122
+ last = index + i
123
+ }
124
+ emit(next)
125
+ inputs[Math.min(last + 1, length() - 1)]?.focus()
126
+ }
127
+
128
+ return (
129
+ <div
130
+ role="group"
131
+ aria-label={local['aria-label'] ?? 'One-time passcode'}
132
+ class={cn('flex gap-2', local.class)}
133
+ >
134
+ <For each={chars()}>
135
+ {(char, index) => (
136
+ <input
137
+ ref={(el) => (inputs[index()] = el)}
138
+ class={BOX_BASE}
139
+ type="text"
140
+ inputMode={local.numeric ? 'numeric' : 'text'}
141
+ maxlength={1}
142
+ autocomplete="one-time-code"
143
+ value={char}
144
+ onInput={(ev) => handleInput(index(), ev.currentTarget.value)}
145
+ onKeyDown={(ev) => handleKeyDown(index(), ev)}
146
+ onPaste={(ev) => handlePaste(index(), ev)}
147
+ aria-label={`Digit ${index() + 1} of ${length()}`}
148
+ />
149
+ )}
150
+ </For>
151
+ </div>
152
+ )
153
+ }
Binary file