@a4ui/core 0.33.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.
Files changed (58) hide show
  1. package/dist/elements.css +299 -0
  2. package/dist/full.css +299 -0
  3. package/dist/index.d.ts +27 -1
  4. package/dist/index.js +9140 -5065
  5. package/dist/ui/ActivityFeed.d.ts +49 -0
  6. package/dist/ui/AudioWaveform.d.ts +22 -0
  7. package/dist/ui/AvailabilityPicker.d.ts +35 -0
  8. package/dist/ui/CodeEditor.d.ts +25 -0
  9. package/dist/ui/CouponField.d.ts +33 -0
  10. package/dist/ui/EmojiPicker.d.ts +18 -0
  11. package/dist/ui/EventScheduler.d.ts +41 -0
  12. package/dist/ui/FollowingPointer.d.ts +24 -0
  13. package/dist/ui/GanttChart.d.ts +37 -0
  14. package/dist/ui/InteractiveMap.d.ts +50 -0
  15. package/dist/ui/JsonViewer.d.ts +24 -0
  16. package/dist/ui/Kanban.d.ts +49 -0
  17. package/dist/ui/Lamp.d.ts +22 -0
  18. package/dist/ui/Lightbox.d.ts +41 -0
  19. package/dist/ui/LocationPicker.d.ts +25 -0
  20. package/dist/ui/MaskedInput.d.ts +26 -0
  21. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  22. package/dist/ui/OtpInput.d.ts +29 -0
  23. package/dist/ui/PivotTable.d.ts +37 -0
  24. package/dist/ui/PresenceAvatars.d.ts +54 -0
  25. package/dist/ui/QueryBuilder.d.ts +56 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/SignaturePad.d.ts +19 -0
  28. package/dist/ui/SpreadsheetGrid.d.ts +27 -0
  29. package/dist/ui/TreeTable.d.ts +52 -0
  30. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  31. package/package.json +1 -1
  32. package/src/index.ts +60 -1
  33. package/src/ui/ActivityFeed.tsx +178 -0
  34. package/src/ui/AudioWaveform.tsx +190 -0
  35. package/src/ui/AvailabilityPicker.tsx +163 -0
  36. package/src/ui/CodeEditor.tsx +277 -0
  37. package/src/ui/CouponField.tsx +120 -0
  38. package/src/ui/EmojiPicker.tsx +424 -0
  39. package/src/ui/EventScheduler.tsx +280 -0
  40. package/src/ui/FollowingPointer.tsx +112 -0
  41. package/src/ui/GanttChart.tsx +283 -0
  42. package/src/ui/InteractiveMap.tsx +349 -0
  43. package/src/ui/JsonViewer.tsx +189 -0
  44. package/src/ui/Kanban.tsx +250 -0
  45. package/src/ui/Lamp.tsx +88 -0
  46. package/src/ui/Lightbox.tsx +261 -0
  47. package/src/ui/LocationPicker.tsx +96 -0
  48. package/src/ui/MaskedInput.tsx +108 -0
  49. package/src/ui/OnboardingChecklist.tsx +163 -0
  50. package/src/ui/OtpInput.tsx +153 -0
  51. package/src/ui/PivotTable.tsx +0 -0
  52. package/src/ui/PresenceAvatars.tsx +142 -0
  53. package/src/ui/QueryBuilder.tsx +280 -0
  54. package/src/ui/SheetSnap.tsx +264 -0
  55. package/src/ui/SignaturePad.tsx +221 -0
  56. package/src/ui/SpreadsheetGrid.tsx +304 -0
  57. package/src/ui/TreeTable.tsx +172 -0
  58. package/src/ui/VideoPlayerShell.tsx +282 -0
@@ -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,163 @@
1
+ // Card-shaped step list for onboarding/setup flows. Completion state is
2
+ // controlled by the caller (`steps[].done`) but each step also tracks its own
3
+ // expanded/collapsed description locally, following the Collapse idiom.
4
+ import { Check } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { createSignal, For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { Button } from './Button'
10
+ import { RingProgress } from './RingProgress'
11
+
12
+ /** A single step in an {@link OnboardingChecklist}. */
13
+ export interface OnboardingStep {
14
+ /** Stable identifier, used as the toggle key and `For` item key. */
15
+ id: string
16
+ title: JSX.Element
17
+ /** Optional detail shown when the step is expanded. */
18
+ description?: JSX.Element
19
+ /** Whether the step is complete. */
20
+ done?: boolean
21
+ /** Optional call-to-action rendered alongside the description. */
22
+ action?: { label: string; onClick: () => void }
23
+ }
24
+
25
+ export interface OnboardingChecklistProps {
26
+ steps: OnboardingStep[]
27
+ /** Called with the step id and its next `done` state when the indicator is toggled. */
28
+ onToggle?: (id: string, done: boolean) => void
29
+ /** Heading shown above the progress summary. Defaults to `'Getting started'`. */
30
+ title?: JSX.Element
31
+ class?: string
32
+ }
33
+
34
+ /**
35
+ * Card-shaped onboarding/setup checklist: a header with a completion ring and
36
+ * "{done} of {total} complete" summary, followed by expandable steps. Each
37
+ * step has a clickable check indicator (toggles `done` via `onToggle`), a
38
+ * title, and an optional description + CTA revealed on expand. Completion is
39
+ * controlled by the caller; expansion is local UI state (one step open at a time).
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * const [steps, setSteps] = createSignal<OnboardingStep[]>([
44
+ * { id: 'profile', title: 'Complete your profile', done: true },
45
+ * {
46
+ * id: 'invite',
47
+ * title: 'Invite a teammate',
48
+ * description: 'Collaborate faster by adding at least one teammate.',
49
+ * action: { label: 'Invite', onClick: () => openInviteDialog() },
50
+ * },
51
+ * ])
52
+ * <OnboardingChecklist
53
+ * steps={steps()}
54
+ * onToggle={(id, done) =>
55
+ * setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, done } : s)))
56
+ * }
57
+ * />
58
+ * ```
59
+ */
60
+ export function OnboardingChecklist(props: OnboardingChecklistProps): JSX.Element {
61
+ const [expanded, setExpanded] = createSignal<string | null>(null)
62
+
63
+ const total = () => props.steps.length
64
+ const done = () => props.steps.filter((step) => step.done).length
65
+ const percent = () => (total() === 0 ? 0 : (done() / total()) * 100)
66
+
67
+ const toggleDone = (step: OnboardingStep): void => {
68
+ props.onToggle?.(step.id, !step.done)
69
+ }
70
+
71
+ const toggleExpanded = (id: string): void => {
72
+ setExpanded((prev) => (prev === id ? null : id))
73
+ }
74
+
75
+ return (
76
+ <div class={cn('rounded-xl border border-border bg-card text-card-foreground', props.class)}>
77
+ <div class="flex items-center gap-4 border-b border-border p-4">
78
+ <RingProgress
79
+ value={percent()}
80
+ size={56}
81
+ thickness={6}
82
+ label={
83
+ <span class="text-xs">
84
+ {done()}/{total()}
85
+ </span>
86
+ }
87
+ />
88
+ <div>
89
+ <p class="font-semibold text-foreground">{props.title ?? 'Getting started'}</p>
90
+ <p class="text-sm text-muted-foreground">
91
+ {done()} of {total()} complete
92
+ </p>
93
+ </div>
94
+ </div>
95
+ <ul role="list" class="divide-y divide-border">
96
+ <For each={props.steps}>
97
+ {(step) => {
98
+ const isExpanded = () => expanded() === step.id
99
+ const hasDetail = () => step.description !== undefined || step.action !== undefined
100
+ return (
101
+ <li class="p-4">
102
+ <div class="flex items-start gap-3">
103
+ <button
104
+ type="button"
105
+ role="checkbox"
106
+ aria-checked={!!step.done}
107
+ aria-label={step.done ? 'Mark step as not done' : 'Mark step as done'}
108
+ onClick={() => toggleDone(step)}
109
+ class={cn(
110
+ 'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors',
111
+ step.done
112
+ ? 'border-emerald-500 bg-emerald-500 text-white'
113
+ : 'border-input bg-background text-transparent hover:border-emerald-500/60',
114
+ )}
115
+ >
116
+ <Check class="h-3.5 w-3.5" />
117
+ </button>
118
+ <button
119
+ type="button"
120
+ class="min-w-0 flex-1 text-left"
121
+ aria-expanded={hasDetail() ? isExpanded() : undefined}
122
+ disabled={!hasDetail()}
123
+ onClick={() => hasDetail() && toggleExpanded(step.id)}
124
+ >
125
+ <span
126
+ class={cn(
127
+ 'block text-sm font-medium',
128
+ step.done ? 'text-muted-foreground line-through' : 'text-foreground',
129
+ )}
130
+ >
131
+ {step.title}
132
+ </span>
133
+ </button>
134
+ </div>
135
+ <Show when={hasDetail()}>
136
+ <div
137
+ class={cn(
138
+ 'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
139
+ isExpanded() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
140
+ )}
141
+ >
142
+ <div class="overflow-hidden">
143
+ <div class="mt-2 pl-8 text-sm text-muted-foreground">
144
+ <Show when={step.description}>{step.description}</Show>
145
+ <Show when={step.action}>
146
+ {(action) => (
147
+ <Button variant="outline" class="mt-2" onClick={() => action().onClick()}>
148
+ {action().label}
149
+ </Button>
150
+ )}
151
+ </Show>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </Show>
156
+ </li>
157
+ )
158
+ }}
159
+ </For>
160
+ </ul>
161
+ </div>
162
+ )
163
+ }
@@ -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
@@ -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
+ }