@a4ui/core 0.34.0 → 0.35.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.
- package/dist/elements.css +116 -0
- package/dist/full.css +116 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8276 -5549
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/package.json +1 -1
- package/src/index.ts +36 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Multiplayer presence: a stacked avatar group (same overlap idiom as
|
|
2
|
+
// AvatarGroup) of active users with an online dot, plus optional data-driven
|
|
3
|
+
// remote cursors positioned within the caller's own relative canvas wrapper.
|
|
4
|
+
// Distinct from the decorative FollowingPointer, which follows the LOCAL
|
|
5
|
+
// pointer — these render OTHER users' server-driven positions.
|
|
6
|
+
import type { JSX } from 'solid-js'
|
|
7
|
+
import { For, Show } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { Avatar } from './Avatar'
|
|
11
|
+
|
|
12
|
+
/** A user currently present/active in a collaborative session. */
|
|
13
|
+
export interface PresenceUser {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
avatar?: string
|
|
17
|
+
/** Accent for this user's remote cursor label. Falls back to the primary token. */
|
|
18
|
+
color?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A remote user's pointer position, as fractions of the shared canvas. */
|
|
22
|
+
export interface RemoteCursor {
|
|
23
|
+
userId: string
|
|
24
|
+
/** 0..1 fraction of the container's width. */
|
|
25
|
+
x: number
|
|
26
|
+
/** 0..1 fraction of the container's height. */
|
|
27
|
+
y: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PresenceAvatarsProps {
|
|
31
|
+
users: PresenceUser[]
|
|
32
|
+
/** Max avatars shown before collapsing the rest into `+N`. @default 4 */
|
|
33
|
+
max?: number
|
|
34
|
+
/**
|
|
35
|
+
* Remote cursors to render. Positioned absolutely against the nearest
|
|
36
|
+
* `relative` ancestor, so the caller wraps its canvas/surface with
|
|
37
|
+
* `class="relative"` for the cursors to anchor to — this component itself
|
|
38
|
+
* does not create that positioning context.
|
|
39
|
+
*/
|
|
40
|
+
cursors?: RemoteCursor[]
|
|
41
|
+
class?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function initials(name: string): string {
|
|
45
|
+
return name
|
|
46
|
+
.split(' ')
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.slice(0, 2)
|
|
49
|
+
.map((part) => part[0]?.toUpperCase())
|
|
50
|
+
.join('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stacked avatar group of users active in a collaborative session, each with
|
|
55
|
+
* a small online dot and a name tooltip; overflow past `max` (default 4)
|
|
56
|
+
* collapses into a trailing `+N` chip, mirroring {@link AvatarGroup}. When
|
|
57
|
+
* `cursors` is given, also renders labeled remote cursors — a small arrow
|
|
58
|
+
* plus a name chip tinted with the user's `color` — positioned at `x`/`y`
|
|
59
|
+
* fractions of the nearest `relative` ancestor and animated smoothly as
|
|
60
|
+
* positions update.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* <div class="relative h-64 w-full overflow-hidden rounded-lg border">
|
|
65
|
+
* <PresenceAvatars
|
|
66
|
+
* class="absolute right-3 top-3"
|
|
67
|
+
* users={[
|
|
68
|
+
* { id: 'u1', name: 'Ada Lovelace' },
|
|
69
|
+
* { id: 'u2', name: 'Grace Hopper', color: 'hsl(280 80% 60%)' },
|
|
70
|
+
* ]}
|
|
71
|
+
* cursors={[{ userId: 'u2', x: 0.42, y: 0.6 }]}
|
|
72
|
+
* />
|
|
73
|
+
* </div>
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function PresenceAvatars(props: PresenceAvatarsProps): JSX.Element {
|
|
77
|
+
const max = () => props.max ?? 4
|
|
78
|
+
const shown = () => props.users.slice(0, max())
|
|
79
|
+
const overflow = () => props.users.length - shown().length
|
|
80
|
+
const cursorUser = (userId: string) => props.users.find((user) => user.id === userId)
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<div class={cn('flex items-center', props.class)}>
|
|
85
|
+
<For each={shown()}>
|
|
86
|
+
{(user, index) => (
|
|
87
|
+
<div
|
|
88
|
+
class={cn('relative rounded-full ring-2 ring-background', index() > 0 && '-ml-2')}
|
|
89
|
+
title={user.name}
|
|
90
|
+
>
|
|
91
|
+
<Avatar src={user.avatar} alt={user.name} fallback={initials(user.name)} />
|
|
92
|
+
<span
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-background"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</For>
|
|
99
|
+
<Show when={overflow() > 0}>
|
|
100
|
+
<div class="-ml-2 rounded-full ring-2 ring-background">
|
|
101
|
+
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
|
102
|
+
+{overflow()}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
</Show>
|
|
106
|
+
</div>
|
|
107
|
+
<Show when={props.cursors && props.cursors.length > 0}>
|
|
108
|
+
<div class="pointer-events-none absolute inset-0 z-50 overflow-hidden">
|
|
109
|
+
<For each={props.cursors}>
|
|
110
|
+
{(cursor) => {
|
|
111
|
+
const user = () => cursorUser(cursor.userId)
|
|
112
|
+
const color = () => user()?.color ?? 'hsl(var(--primary))'
|
|
113
|
+
return (
|
|
114
|
+
<Show when={user()}>
|
|
115
|
+
<div
|
|
116
|
+
class="absolute flex -translate-x-0.5 -translate-y-0.5 items-center transition-[left,top] duration-150 ease-out will-change-[left,top]"
|
|
117
|
+
style={{ left: `${cursor.x * 100}%`, top: `${cursor.y * 100}%` }}
|
|
118
|
+
>
|
|
119
|
+
<svg width="18" height="18" viewBox="0 0 20 20" class="drop-shadow">
|
|
120
|
+
<path
|
|
121
|
+
d="M2 2 L18 9 L10 11 L8 18 Z"
|
|
122
|
+
fill={color()}
|
|
123
|
+
stroke="hsl(var(--background))"
|
|
124
|
+
stroke-width="1"
|
|
125
|
+
/>
|
|
126
|
+
</svg>
|
|
127
|
+
<span
|
|
128
|
+
class="ml-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium text-white shadow"
|
|
129
|
+
style={{ 'background-color': color() }}
|
|
130
|
+
>
|
|
131
|
+
{user()!.name}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
</Show>
|
|
135
|
+
)
|
|
136
|
+
}}
|
|
137
|
+
</For>
|
|
138
|
+
</div>
|
|
139
|
+
</Show>
|
|
140
|
+
</>
|
|
141
|
+
)
|
|
142
|
+
}
|