@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.
- package/dist/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- 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
|
+
}
|