@a4ui/core 0.34.0 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/elements.css +119 -0
- package/dist/elements.iife.js +2 -2
- package/dist/elements.js +115 -114
- package/dist/full.css +119 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8175 -5443
- 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/RingProgress.d.ts +2 -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/AudioWaveform.tsx +1 -3
- 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 +286 -0
- package/src/ui/GanttChart.tsx +9 -1
- 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/OnboardingChecklist.tsx +1 -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 +281 -0
- package/src/ui/RingProgress.tsx +3 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +307 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Nested AND/OR rule builder — renders a QueryGroup tree recursively.
|
|
2
|
+
import { Plus, Trash2 } 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 { Button } from './Button'
|
|
8
|
+
import { Input } from './Input'
|
|
9
|
+
import { Select } from './Select'
|
|
10
|
+
|
|
11
|
+
/** A field the query builder can filter on. */
|
|
12
|
+
export interface QueryField {
|
|
13
|
+
/** Value stored in a rule's `field`; must be unique across `fields`. */
|
|
14
|
+
name: string
|
|
15
|
+
/** Label shown in the field `<Select>`. */
|
|
16
|
+
label: string
|
|
17
|
+
/** Determines which operators are offered and how the value is entered. Defaults to `'text'`. */
|
|
18
|
+
type?: 'text' | 'number' | 'select'
|
|
19
|
+
/** Options for the value `<Select>` when `type` is `'select'`. */
|
|
20
|
+
options?: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A single leaf condition: `field operator value`. */
|
|
24
|
+
export type QueryRule = { field: string; operator: string; value: string }
|
|
25
|
+
|
|
26
|
+
/** A group of rules and/or nested groups, combined with `and`/`or`. */
|
|
27
|
+
export type QueryGroup = { combinator: 'and' | 'or'; rules: (QueryRule | QueryGroup)[] }
|
|
28
|
+
|
|
29
|
+
export interface QueryBuilderProps {
|
|
30
|
+
/** Fields available to filter on; drives the per-field operator and value-input set. */
|
|
31
|
+
fields: QueryField[]
|
|
32
|
+
/** Controlled tree. Omit to manage state internally, seeded with one empty rule. */
|
|
33
|
+
value?: QueryGroup
|
|
34
|
+
/** Called with the full, updated tree on every edit. */
|
|
35
|
+
onChange?: (group: QueryGroup) => void
|
|
36
|
+
class?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const OPERATORS: Record<NonNullable<QueryField['type']>, string[]> = {
|
|
40
|
+
text: ['is', 'contains', 'starts with'],
|
|
41
|
+
number: ['=', '>', '<', 'between'],
|
|
42
|
+
select: ['is', 'is not'],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isGroup(node: QueryRule | QueryGroup): node is QueryGroup {
|
|
46
|
+
return 'combinator' in node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function emptyRule(fields: QueryField[]): QueryRule {
|
|
50
|
+
const field = fields[0]
|
|
51
|
+
const type = field?.type ?? 'text'
|
|
52
|
+
return { field: field?.name ?? '', operator: OPERATORS[type][0], value: '' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emptyGroup(fields: QueryField[]): QueryGroup {
|
|
56
|
+
return { combinator: 'and', rules: [emptyRule(fields)] }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Nested AND/OR rule builder: each group has a combinator toggle plus "+ Rule"
|
|
61
|
+
* / "+ Group" buttons, and rules/groups can be removed individually. Groups
|
|
62
|
+
* nest recursively, indented with a left border. Controlled via `value` +
|
|
63
|
+
* `onChange`, or uncontrolled (seeded with one empty rule) when `value` is
|
|
64
|
+
* omitted.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```tsx
|
|
68
|
+
* const [query, setQuery] = createSignal<QueryGroup>({
|
|
69
|
+
* combinator: 'and',
|
|
70
|
+
* rules: [{ field: 'status', operator: 'is', value: 'open' }],
|
|
71
|
+
* })
|
|
72
|
+
* <QueryBuilder
|
|
73
|
+
* fields={[
|
|
74
|
+
* { name: 'status', label: 'Status', type: 'select', options: ['open', 'closed'] },
|
|
75
|
+
* { name: 'age', label: 'Age', type: 'number' },
|
|
76
|
+
* ]}
|
|
77
|
+
* value={query()}
|
|
78
|
+
* onChange={setQuery}
|
|
79
|
+
* />
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function QueryBuilder(props: QueryBuilderProps): JSX.Element {
|
|
83
|
+
const [internal, setInternal] = createSignal<QueryGroup>(props.value ?? emptyGroup(props.fields))
|
|
84
|
+
|
|
85
|
+
const group = createMemo(() => props.value ?? internal())
|
|
86
|
+
|
|
87
|
+
const emit = (next: QueryGroup): void => {
|
|
88
|
+
if (props.value === undefined) setInternal(next)
|
|
89
|
+
props.onChange?.(next)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fieldByName = (name: string): QueryField | undefined => props.fields.find((f) => f.name === name)
|
|
93
|
+
|
|
94
|
+
const operatorsFor = (fieldName: string): string[] => OPERATORS[fieldByName(fieldName)?.type ?? 'text']
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<GroupView
|
|
98
|
+
group={group()}
|
|
99
|
+
fields={props.fields}
|
|
100
|
+
operatorsFor={operatorsFor}
|
|
101
|
+
fieldByName={fieldByName}
|
|
102
|
+
onChange={emit}
|
|
103
|
+
depth={0}
|
|
104
|
+
onRemove={undefined}
|
|
105
|
+
/>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface GroupViewProps {
|
|
110
|
+
group: QueryGroup
|
|
111
|
+
fields: QueryField[]
|
|
112
|
+
operatorsFor: (fieldName: string) => string[]
|
|
113
|
+
fieldByName: (name: string) => QueryField | undefined
|
|
114
|
+
onChange: (next: QueryGroup) => void
|
|
115
|
+
depth: number
|
|
116
|
+
/** Absent for the root group, which cannot remove itself. */
|
|
117
|
+
onRemove: (() => void) | undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function GroupView(props: GroupViewProps): JSX.Element {
|
|
121
|
+
const updateNode = (index: number, next: QueryRule | QueryGroup): void => {
|
|
122
|
+
const rules = props.group.rules.slice()
|
|
123
|
+
rules[index] = next
|
|
124
|
+
props.onChange({ ...props.group, rules })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const removeNode = (index: number): void => {
|
|
128
|
+
const rules = props.group.rules.slice()
|
|
129
|
+
rules.splice(index, 1)
|
|
130
|
+
props.onChange({ ...props.group, rules })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const addRule = (): void => {
|
|
134
|
+
props.onChange({ ...props.group, rules: [...props.group.rules, emptyRule(props.fields)] })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const addGroup = (): void => {
|
|
138
|
+
props.onChange({ ...props.group, rules: [...props.group.rules, emptyGroup(props.fields)] })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const setCombinator = (combinator: 'and' | 'or'): void => {
|
|
142
|
+
props.onChange({ ...props.group, combinator })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div class={cn('flex flex-col gap-2 rounded-md p-2', props.depth > 0 && 'border-l-2 border-border pl-3')}>
|
|
147
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
148
|
+
<div
|
|
149
|
+
class="inline-flex overflow-hidden rounded-md border border-input"
|
|
150
|
+
role="group"
|
|
151
|
+
aria-label="Combinator"
|
|
152
|
+
>
|
|
153
|
+
<For each={['and', 'or'] as const}>
|
|
154
|
+
{(c) => (
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
class={cn(
|
|
158
|
+
'px-2 py-1 text-xs font-semibold uppercase transition-colors',
|
|
159
|
+
props.group.combinator === c
|
|
160
|
+
? 'bg-primary text-primary-foreground'
|
|
161
|
+
: 'bg-background text-muted-foreground hover:bg-muted',
|
|
162
|
+
)}
|
|
163
|
+
aria-pressed={props.group.combinator === c}
|
|
164
|
+
onClick={() => setCombinator(c)}
|
|
165
|
+
>
|
|
166
|
+
{c}
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</For>
|
|
170
|
+
</div>
|
|
171
|
+
<Button variant="outline" onClick={addRule}>
|
|
172
|
+
<Plus class="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
|
173
|
+
Rule
|
|
174
|
+
</Button>
|
|
175
|
+
<Button variant="outline" onClick={addGroup}>
|
|
176
|
+
<Plus class="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
|
177
|
+
Group
|
|
178
|
+
</Button>
|
|
179
|
+
<Show when={props.onRemove}>
|
|
180
|
+
{(remove) => (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
class="ml-auto rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
184
|
+
aria-label="Remove group"
|
|
185
|
+
onClick={() => remove()()}
|
|
186
|
+
>
|
|
187
|
+
<Trash2 class="h-3.5 w-3.5" aria-hidden="true" />
|
|
188
|
+
</button>
|
|
189
|
+
)}
|
|
190
|
+
</Show>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<For each={props.group.rules}>
|
|
194
|
+
{(node, index) => (
|
|
195
|
+
<Show
|
|
196
|
+
when={isGroup(node)}
|
|
197
|
+
fallback={
|
|
198
|
+
<RuleRow
|
|
199
|
+
rule={node as QueryRule}
|
|
200
|
+
fields={props.fields}
|
|
201
|
+
operatorsFor={props.operatorsFor}
|
|
202
|
+
fieldByName={props.fieldByName}
|
|
203
|
+
onChange={(next) => updateNode(index(), next)}
|
|
204
|
+
onRemove={() => removeNode(index())}
|
|
205
|
+
/>
|
|
206
|
+
}
|
|
207
|
+
>
|
|
208
|
+
<GroupView
|
|
209
|
+
group={node as QueryGroup}
|
|
210
|
+
fields={props.fields}
|
|
211
|
+
operatorsFor={props.operatorsFor}
|
|
212
|
+
fieldByName={props.fieldByName}
|
|
213
|
+
onChange={(next) => updateNode(index(), next)}
|
|
214
|
+
depth={props.depth + 1}
|
|
215
|
+
onRemove={() => removeNode(index())}
|
|
216
|
+
/>
|
|
217
|
+
</Show>
|
|
218
|
+
)}
|
|
219
|
+
</For>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface RuleRowProps {
|
|
225
|
+
rule: QueryRule
|
|
226
|
+
fields: QueryField[]
|
|
227
|
+
operatorsFor: (fieldName: string) => string[]
|
|
228
|
+
fieldByName: (name: string) => QueryField | undefined
|
|
229
|
+
onChange: (next: QueryRule) => void
|
|
230
|
+
onRemove: () => void
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function RuleRow(props: RuleRowProps): JSX.Element {
|
|
234
|
+
const field = createMemo(() => props.fieldByName(props.rule.field))
|
|
235
|
+
|
|
236
|
+
const setField = (name: string): void => {
|
|
237
|
+
const operators = props.operatorsFor(name)
|
|
238
|
+
props.onChange({ field: name, operator: operators[0], value: '' })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const setOperator = (operator: string): void => props.onChange({ ...props.rule, operator })
|
|
242
|
+
const setValue = (value: string): void => props.onChange({ ...props.rule, value })
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
246
|
+
<Select class="w-40" value={props.rule.field} onChange={setField} aria-label="Field">
|
|
247
|
+
<For each={props.fields}>{(f) => <option value={f.name}>{f.label}</option>}</For>
|
|
248
|
+
</Select>
|
|
249
|
+
<Select class="w-32" value={props.rule.operator} onChange={setOperator} aria-label="Operator">
|
|
250
|
+
<For each={props.operatorsFor(props.rule.field)}>{(op) => <option value={op}>{op}</option>}</For>
|
|
251
|
+
</Select>
|
|
252
|
+
<Show
|
|
253
|
+
when={field()?.type === 'select'}
|
|
254
|
+
fallback={
|
|
255
|
+
<Input
|
|
256
|
+
class="w-40"
|
|
257
|
+
type={field()?.type === 'number' ? 'number' : 'text'}
|
|
258
|
+
value={props.rule.value}
|
|
259
|
+
onInput={setValue}
|
|
260
|
+
aria-label="Value"
|
|
261
|
+
/>
|
|
262
|
+
}
|
|
263
|
+
>
|
|
264
|
+
<Select class="w-40" value={props.rule.value} onChange={setValue} aria-label="Value">
|
|
265
|
+
<option value="" disabled>
|
|
266
|
+
Select…
|
|
267
|
+
</option>
|
|
268
|
+
<For each={field()?.options ?? []}>{(opt) => <option value={opt}>{opt}</option>}</For>
|
|
269
|
+
</Select>
|
|
270
|
+
</Show>
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
class="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
274
|
+
aria-label="Remove rule"
|
|
275
|
+
onClick={props.onRemove}
|
|
276
|
+
>
|
|
277
|
+
<Trash2 class="h-3.5 w-3.5" aria-hidden="true" />
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
)
|
|
281
|
+
}
|
package/src/ui/RingProgress.tsx
CHANGED
|
@@ -12,6 +12,8 @@ export interface RingProgressProps {
|
|
|
12
12
|
thickness?: number
|
|
13
13
|
/** Content centered over the ring. Defaults to the rounded percent. */
|
|
14
14
|
label?: JSX.Element
|
|
15
|
+
/** Accessible name for the `role="progressbar"` element. */
|
|
16
|
+
'aria-label'?: string
|
|
15
17
|
class?: string
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -37,6 +39,7 @@ export function RingProgress(props: RingProgressProps): JSX.Element {
|
|
|
37
39
|
return (
|
|
38
40
|
<div
|
|
39
41
|
role="progressbar"
|
|
42
|
+
aria-label={props['aria-label']}
|
|
40
43
|
aria-valuenow={clamped()}
|
|
41
44
|
aria-valuemin={0}
|
|
42
45
|
aria-valuemax={100}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Canvas-based signature capture. DPR-aware sizing (crisp strokes on retina),
|
|
2
|
+
// pointer events (mouse + touch + pen in one listener set), and a stroke stack
|
|
3
|
+
// so Undo can redraw from scratch instead of trying to "erase" a curve.
|
|
4
|
+
import { Eraser, Undo2 } from 'lucide-solid'
|
|
5
|
+
import { createSignal, onCleanup, onMount, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
interface Point {
|
|
10
|
+
x: number
|
|
11
|
+
y: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Stroke = Point[]
|
|
15
|
+
|
|
16
|
+
export interface SignaturePadProps {
|
|
17
|
+
/** Called with a PNG data URL after each completed stroke, or `null` once the pad is empty (cleared/undone to nothing). */
|
|
18
|
+
onChange?: (dataUrl: string | null) => void
|
|
19
|
+
/** Canvas height in CSS pixels. @default 200 */
|
|
20
|
+
height?: number
|
|
21
|
+
class?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A signature capture pad: draw with mouse, pen, or touch on a DPR-aware
|
|
26
|
+
* `<canvas>`, with Clear and Undo controls. Strokes are kept as point lists so
|
|
27
|
+
* Undo can fully redraw the remaining ones rather than trying to erase pixels.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <SignaturePad height={220} onChange={(dataUrl) => setSignature(dataUrl)} />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function SignaturePad(props: SignaturePadProps): JSX.Element {
|
|
35
|
+
let canvas!: HTMLCanvasElement
|
|
36
|
+
let container!: HTMLDivElement
|
|
37
|
+
let ctx: CanvasRenderingContext2D | null = null
|
|
38
|
+
|
|
39
|
+
const strokes: Stroke[] = []
|
|
40
|
+
let current: Stroke | null = null
|
|
41
|
+
let drawing = false
|
|
42
|
+
|
|
43
|
+
const [hasInk, setHasInk] = createSignal(false)
|
|
44
|
+
|
|
45
|
+
const height = () => props.height ?? 200
|
|
46
|
+
|
|
47
|
+
const resize = (): void => {
|
|
48
|
+
if (!canvas || !container) return
|
|
49
|
+
const dpr = window.devicePixelRatio || 1
|
|
50
|
+
const width = container.clientWidth
|
|
51
|
+
const h = height()
|
|
52
|
+
canvas.width = Math.max(1, Math.round(width * dpr))
|
|
53
|
+
canvas.height = Math.max(1, Math.round(h * dpr))
|
|
54
|
+
canvas.style.width = `${width}px`
|
|
55
|
+
canvas.style.height = `${h}px`
|
|
56
|
+
ctx = canvas.getContext('2d')
|
|
57
|
+
if (ctx) ctx.scale(dpr, dpr)
|
|
58
|
+
redraw()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const strokeColor = (): string => {
|
|
62
|
+
// Resolved at draw time so it stays correct across theme/light-dark toggles.
|
|
63
|
+
const root = getComputedStyle(document.documentElement)
|
|
64
|
+
const hsl = root.getPropertyValue('--foreground').trim()
|
|
65
|
+
return hsl ? `hsl(${hsl})` : '#000'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const drawStroke = (context: CanvasRenderingContext2D, stroke: Stroke): void => {
|
|
69
|
+
if (stroke.length < 2) {
|
|
70
|
+
// A single tap/click: draw a dot so it's not silently lost.
|
|
71
|
+
if (stroke.length === 1) {
|
|
72
|
+
context.beginPath()
|
|
73
|
+
context.arc(stroke[0].x, stroke[0].y, 1.25, 0, Math.PI * 2)
|
|
74
|
+
context.fillStyle = strokeColor()
|
|
75
|
+
context.fill()
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
context.lineJoin = 'round'
|
|
80
|
+
context.lineCap = 'round'
|
|
81
|
+
context.lineWidth = 2.25
|
|
82
|
+
context.strokeStyle = strokeColor()
|
|
83
|
+
context.beginPath()
|
|
84
|
+
context.moveTo(stroke[0].x, stroke[0].y)
|
|
85
|
+
for (let i = 1; i < stroke.length; i++) context.lineTo(stroke[i].x, stroke[i].y)
|
|
86
|
+
context.stroke()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const clearCanvas = (context: CanvasRenderingContext2D): void => {
|
|
90
|
+
const dpr = window.devicePixelRatio || 1
|
|
91
|
+
context.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const redraw = (): void => {
|
|
95
|
+
if (!ctx) return
|
|
96
|
+
clearCanvas(ctx)
|
|
97
|
+
for (const stroke of strokes) drawStroke(ctx, stroke)
|
|
98
|
+
setHasInk(strokes.length > 0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const emitChange = (): void => {
|
|
102
|
+
props.onChange?.(strokes.length > 0 ? canvas.toDataURL('image/png') : null)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pointFromEvent = (event: PointerEvent): Point => {
|
|
106
|
+
const rect = canvas.getBoundingClientRect()
|
|
107
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
111
|
+
if (!ctx) return
|
|
112
|
+
event.preventDefault()
|
|
113
|
+
drawing = true
|
|
114
|
+
current = [pointFromEvent(event)]
|
|
115
|
+
canvas.setPointerCapture(event.pointerId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
119
|
+
if (!drawing || !current || !ctx) return
|
|
120
|
+
const point = pointFromEvent(event)
|
|
121
|
+
const prev = current[current.length - 1]
|
|
122
|
+
current.push(point)
|
|
123
|
+
ctx.lineJoin = 'round'
|
|
124
|
+
ctx.lineCap = 'round'
|
|
125
|
+
ctx.lineWidth = 2.25
|
|
126
|
+
ctx.strokeStyle = strokeColor()
|
|
127
|
+
ctx.beginPath()
|
|
128
|
+
ctx.moveTo(prev.x, prev.y)
|
|
129
|
+
ctx.lineTo(point.x, point.y)
|
|
130
|
+
ctx.stroke()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const endStroke = (): void => {
|
|
134
|
+
if (!drawing) return
|
|
135
|
+
drawing = false
|
|
136
|
+
if (current && current.length > 0) {
|
|
137
|
+
strokes.push(current)
|
|
138
|
+
setHasInk(true)
|
|
139
|
+
emitChange()
|
|
140
|
+
}
|
|
141
|
+
current = null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const onPointerUp = (event: PointerEvent): void => {
|
|
145
|
+
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId)
|
|
146
|
+
endStroke()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const handleClear = (): void => {
|
|
150
|
+
strokes.length = 0
|
|
151
|
+
current = null
|
|
152
|
+
drawing = false
|
|
153
|
+
redraw()
|
|
154
|
+
emitChange()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const handleUndo = (): void => {
|
|
158
|
+
strokes.pop()
|
|
159
|
+
redraw()
|
|
160
|
+
emitChange()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
onMount(() => {
|
|
164
|
+
resize()
|
|
165
|
+
window.addEventListener('resize', resize)
|
|
166
|
+
canvas.addEventListener('pointerdown', onPointerDown)
|
|
167
|
+
canvas.addEventListener('pointermove', onPointerMove)
|
|
168
|
+
canvas.addEventListener('pointerup', onPointerUp)
|
|
169
|
+
canvas.addEventListener('pointercancel', onPointerUp)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
onCleanup(() => {
|
|
173
|
+
window.removeEventListener('resize', resize)
|
|
174
|
+
canvas.removeEventListener('pointerdown', onPointerDown)
|
|
175
|
+
canvas.removeEventListener('pointermove', onPointerMove)
|
|
176
|
+
canvas.removeEventListener('pointerup', onPointerUp)
|
|
177
|
+
canvas.removeEventListener('pointercancel', onPointerUp)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div class={cn('flex flex-col gap-2', props.class)} role="group" aria-label="Signature pad">
|
|
182
|
+
<div
|
|
183
|
+
ref={container}
|
|
184
|
+
class="relative w-full touch-none overflow-hidden rounded-md border border-input bg-background"
|
|
185
|
+
style={{ height: `${height()}px` }}
|
|
186
|
+
>
|
|
187
|
+
<canvas
|
|
188
|
+
ref={canvas}
|
|
189
|
+
class="block h-full w-full cursor-crosshair touch-none"
|
|
190
|
+
aria-label="Draw your signature here"
|
|
191
|
+
/>
|
|
192
|
+
{!hasInk() && (
|
|
193
|
+
<div class="pointer-events-none absolute inset-x-4 bottom-6 flex flex-col items-center gap-1">
|
|
194
|
+
<span class="w-full border-b border-dashed border-border" />
|
|
195
|
+
<span class="text-xs text-muted-foreground">Sign here</span>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
<div class="flex items-center justify-end gap-2">
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={handleUndo}
|
|
203
|
+
disabled={strokes.length === 0}
|
|
204
|
+
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
205
|
+
>
|
|
206
|
+
<Undo2 class="h-3.5 w-3.5" />
|
|
207
|
+
Undo
|
|
208
|
+
</button>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
onClick={handleClear}
|
|
212
|
+
disabled={!hasInk()}
|
|
213
|
+
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
214
|
+
>
|
|
215
|
+
<Eraser class="h-3.5 w-3.5" />
|
|
216
|
+
Clear
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|