@a4ui/core 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/elements.css +116 -0
- package/dist/full.css +116 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +8276 -5549
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/package.json +1 -1
- package/src/index.ts +36 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
|
@@ -0,0 +1,280 @@
|
|
|
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}>
|
|
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}>
|
|
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
|
+
/>
|
|
261
|
+
}
|
|
262
|
+
>
|
|
263
|
+
<Select class="w-40" value={props.rule.value} onChange={setValue}>
|
|
264
|
+
<option value="" disabled>
|
|
265
|
+
Select…
|
|
266
|
+
</option>
|
|
267
|
+
<For each={field()?.options ?? []}>{(opt) => <option value={opt}>{opt}</option>}</For>
|
|
268
|
+
</Select>
|
|
269
|
+
</Show>
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
class="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
273
|
+
aria-label="Remove rule"
|
|
274
|
+
onClick={props.onRemove}
|
|
275
|
+
>
|
|
276
|
+
<Trash2 class="h-3.5 w-3.5" aria-hidden="true" />
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
@@ -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
|
+
}
|