@a4ui/core 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }