@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.
@@ -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
+ }