@a4ui/core 0.33.0 → 0.34.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,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
+ }
Binary file
@@ -0,0 +1,264 @@
1
+ // Bottom sheet with drag-to-resize snap points (mobile filter/detail panels,
2
+ // map bottom sheets). Kobalte's Dialog (used by Drawer/Modal) has no notion of
3
+ // intermediate heights, so this rolls its own portal + backdrop + focus/Escape/
4
+ // body-scroll-lock (mirroring Drawer.tsx's overlay idiom) and its own raw
5
+ // pointerdown/move/up + pointer-capture drag loop (there's no shared drag
6
+ // primitive in the repo to reuse). The sheet's height is fixed to the tallest
7
+ // snap point; the *visible* portion is controlled by translating it down in
8
+ // pixels, snapped to one of `snapPoints` (fractions of `window.innerHeight`).
9
+ import { createEffect, createSignal, onCleanup, onMount, Show, type JSX } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+ import { animate, motionReduced } from '../lib/motion'
13
+ import { Portal } from './Portal'
14
+
15
+ export interface SheetSnapProps {
16
+ open: boolean
17
+ onOpenChange: (open: boolean) => void
18
+ /** Fractions of viewport height, ascending, e.g. `[0.4, 0.9]`. @default [0.5, 0.9] */
19
+ snapPoints?: number[]
20
+ /** Index into `snapPoints` to open at. @default 0 */
21
+ defaultSnap?: number
22
+ children: JSX.Element
23
+ class?: string
24
+ }
25
+
26
+ const DEFAULT_SNAPS = [0.5, 0.9]
27
+ const SPRING_STIFFNESS = 380
28
+ const SPRING_DAMPING = 38
29
+ /** Drag past the lowest snap point by this many px (with no help from velocity) dismisses. */
30
+ const DISMISS_DISTANCE_PX = 100
31
+ /** px/ms; a flick faster than this biases the resolved snap by one level, or dismisses near the bottom. */
32
+ const FLICK_VELOCITY = 0.5
33
+
34
+ const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max)
35
+
36
+ /**
37
+ * A draggable bottom sheet that rests at one of several height "snap points"
38
+ * (fractions of viewport height) instead of just open/closed. Drag the handle
39
+ * to resize with the finger; releasing snaps to the nearest point, biased by
40
+ * flick velocity (a fast swipe jumps a level), and a hard downward flick/drag
41
+ * past the lowest point dismisses. Falls back to an instant show/hide at
42
+ * `defaultSnap` — no drag physics — under reduced motion.
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * const [open, setOpen] = createSignal(false)
47
+ * <SheetSnap open={open()} onOpenChange={setOpen} snapPoints={[0.4, 0.9]}>
48
+ * <p>Sheet content…</p>
49
+ * </SheetSnap>
50
+ * ```
51
+ */
52
+ export function SheetSnap(props: SheetSnapProps): JSX.Element {
53
+ const snaps = () => (props.snapPoints?.length ? props.snapPoints : DEFAULT_SNAPS)
54
+ const defaultIndex = () => clamp(props.defaultSnap ?? 0, 0, snaps().length - 1)
55
+
56
+ const [mounted, setMounted] = createSignal(false)
57
+ const [translateY, setTranslateY] = createSignal(0)
58
+
59
+ let previouslyFocused: HTMLElement | null = null
60
+ let previousBodyOverflow = ''
61
+
62
+ // Plain (non-reactive) geometry — recomputed on open and on resize, read
63
+ // fresh inside the `translateY()`-dependent style expression below.
64
+ let maxHeightPx = 0
65
+ let translateYs: number[] = []
66
+ let snapIndex = 0
67
+
68
+ let controls: ReturnType<typeof animate> | undefined
69
+ let dragging = false
70
+ let dragStartClientY = 0
71
+ let dragStartTranslate = 0
72
+ let lastClientY = 0
73
+ let lastTime = 0
74
+ let velocity = 0 // px/ms, positive = moving down (toward closed)
75
+
76
+ const computeGeometry = () => {
77
+ const vh = window.innerHeight
78
+ const pixelHeights = snaps().map((f) => f * vh)
79
+ maxHeightPx = Math.max(...pixelHeights)
80
+ translateYs = pixelHeights.map((h) => maxHeightPx - h)
81
+ }
82
+
83
+ const lockScroll = () => {
84
+ previousBodyOverflow = document.body.style.overflow
85
+ document.body.style.overflow = 'hidden'
86
+ }
87
+ const unlockScroll = () => {
88
+ document.body.style.overflow = previousBodyOverflow
89
+ }
90
+
91
+ const animateTo = (target: number, onDone?: () => void) => {
92
+ controls?.stop()
93
+ if (motionReduced()) {
94
+ setTranslateY(target)
95
+ onDone?.()
96
+ return
97
+ }
98
+ controls = animate(translateY(), target, {
99
+ type: 'spring',
100
+ stiffness: SPRING_STIFFNESS,
101
+ damping: SPRING_DAMPING,
102
+ onUpdate: (v: number) => setTranslateY(v),
103
+ })
104
+ controls.finished.then(() => onDone?.()).catch(() => {})
105
+ }
106
+
107
+ // Mount/unmount + enter/exit animation, driven off the controlled `open` prop.
108
+ createEffect(() => {
109
+ if (props.open) {
110
+ if (mounted()) return
111
+ computeGeometry()
112
+ snapIndex = defaultIndex()
113
+ previouslyFocused = document.activeElement as HTMLElement | null
114
+ setTranslateY(motionReduced() ? translateYs[snapIndex] : maxHeightPx)
115
+ setMounted(true)
116
+ lockScroll()
117
+ } else if (mounted()) {
118
+ animateTo(maxHeightPx, () => {
119
+ setMounted(false)
120
+ unlockScroll()
121
+ previouslyFocused?.focus()
122
+ })
123
+ }
124
+ })
125
+
126
+ const resolveDragEnd = () => {
127
+ const current = translateY()
128
+ const mostClosedY = translateYs[0]
129
+
130
+ const draggedPastLowest = current - mostClosedY
131
+ const fastDownwardNearBottom =
132
+ velocity > FLICK_VELOCITY && current >= mostClosedY - DISMISS_DISTANCE_PX / 2
133
+ if (draggedPastLowest > DISMISS_DISTANCE_PX || fastDownwardNearBottom) {
134
+ props.onOpenChange(false)
135
+ return
136
+ }
137
+
138
+ let nearest = 0
139
+ let bestDist = Infinity
140
+ translateYs.forEach((t, i) => {
141
+ const d = Math.abs(current - t)
142
+ if (d < bestDist) {
143
+ bestDist = d
144
+ nearest = i
145
+ }
146
+ })
147
+
148
+ let target = nearest
149
+ if (velocity > FLICK_VELOCITY) target = Math.max(nearest - 1, 0)
150
+ else if (velocity < -FLICK_VELOCITY) target = Math.min(nearest + 1, translateYs.length - 1)
151
+
152
+ snapIndex = target
153
+ animateTo(translateYs[target])
154
+ }
155
+
156
+ const onHandlePointerDown = (e: PointerEvent) => {
157
+ if (motionReduced()) return
158
+ e.preventDefault()
159
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
160
+ controls?.stop()
161
+ dragging = true
162
+ dragStartClientY = e.clientY
163
+ dragStartTranslate = translateY()
164
+ lastClientY = e.clientY
165
+ lastTime = performance.now()
166
+ velocity = 0
167
+ }
168
+
169
+ const onHandlePointerMove = (e: PointerEvent) => {
170
+ if (!dragging) return
171
+ const next = Math.max(dragStartTranslate + (e.clientY - dragStartClientY), 0)
172
+ setTranslateY(next)
173
+
174
+ const now = performance.now()
175
+ const dt = now - lastTime
176
+ if (dt > 0) velocity = (e.clientY - lastClientY) / dt
177
+ lastClientY = e.clientY
178
+ lastTime = now
179
+ }
180
+
181
+ const onHandlePointerUp = (e: PointerEvent) => {
182
+ if (!dragging) return
183
+ dragging = false
184
+ ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
185
+ resolveDragEnd()
186
+ }
187
+
188
+ const onKeyDown = (e: KeyboardEvent) => {
189
+ if (e.key === 'Escape' && props.open) props.onOpenChange(false)
190
+ }
191
+ const onResize = () => {
192
+ if (!mounted()) return
193
+ computeGeometry()
194
+ setTranslateY(translateYs[clamp(snapIndex, 0, translateYs.length - 1)])
195
+ }
196
+
197
+ onMount(() => {
198
+ document.addEventListener('keydown', onKeyDown)
199
+ window.addEventListener('resize', onResize)
200
+ })
201
+ onCleanup(() => {
202
+ document.removeEventListener('keydown', onKeyDown)
203
+ window.removeEventListener('resize', onResize)
204
+ controls?.stop()
205
+ if (mounted()) unlockScroll()
206
+ })
207
+
208
+ return (
209
+ <Show when={mounted()}>
210
+ <Portal>
211
+ <div
212
+ aria-hidden="true"
213
+ onClick={() => props.onOpenChange(false)}
214
+ class="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-[2px]"
215
+ />
216
+ <div
217
+ ref={(el) => {
218
+ requestAnimationFrame(() => {
219
+ el.focus()
220
+ animateTo(translateYs[snapIndex])
221
+ })
222
+ }}
223
+ role="dialog"
224
+ aria-modal="true"
225
+ aria-label="Bottom sheet"
226
+ tabindex={-1}
227
+ class={cn(
228
+ 'fixed inset-x-0 bottom-0 z-[9999] flex flex-col overflow-hidden rounded-t-2xl border border-border bg-glass shadow-2xl outline-none',
229
+ props.class,
230
+ )}
231
+ style={{
232
+ height: `${maxHeightPx}px`,
233
+ transform: `translateY(${translateY()}px)`,
234
+ }}
235
+ >
236
+ <div
237
+ role="button"
238
+ tabindex={0}
239
+ aria-label="Drag handle"
240
+ onPointerDown={onHandlePointerDown}
241
+ onPointerMove={onHandlePointerMove}
242
+ onPointerUp={onHandlePointerUp}
243
+ onPointerCancel={onHandlePointerUp}
244
+ onKeyDown={(e) => {
245
+ if (e.key === 'ArrowUp') {
246
+ e.preventDefault()
247
+ snapIndex = Math.min(snapIndex + 1, translateYs.length - 1)
248
+ animateTo(translateYs[snapIndex])
249
+ } else if (e.key === 'ArrowDown') {
250
+ e.preventDefault()
251
+ snapIndex = Math.max(snapIndex - 1, 0)
252
+ animateTo(translateYs[snapIndex])
253
+ }
254
+ }}
255
+ class="flex shrink-0 touch-none items-center justify-center py-3 outline-none"
256
+ >
257
+ <div class="h-1.5 w-10 rounded-full bg-muted-foreground/40" />
258
+ </div>
259
+ <div class="flex-1 overflow-y-auto overscroll-contain px-4 pb-4">{props.children}</div>
260
+ </div>
261
+ </Portal>
262
+ </Show>
263
+ )
264
+ }
@@ -0,0 +1,172 @@
1
+ // Hierarchical data table: rows can nest via `children`, expand/collapse lives
2
+ // in the first column (chevron + depth indentation), all other columns render
3
+ // like a plain DataGrid column. Built on the Table primitives + Tree's toggle idiom.
4
+ import { ChevronRight } 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 { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from './Table'
10
+
11
+ /** A column definition for {@link TreeTable}. */
12
+ export interface TreeTableColumn<T> {
13
+ /** Row data property this column reads when `cell` is omitted. */
14
+ key: string
15
+ /** Column heading content. */
16
+ header: JSX.Element
17
+ /** Custom cell renderer; falls back to `String(row[key])` when omitted. */
18
+ cell?: (row: T) => JSX.Element
19
+ /** Right-align the column (numbers, totals). */
20
+ align?: 'left' | 'right'
21
+ }
22
+
23
+ /** A single node in a {@link TreeTable}; nodes may nest via `children`. */
24
+ export interface TreeTableRow<T> {
25
+ /** Unique identifier; used to track expanded state. */
26
+ id: string
27
+ /** Row payload passed to each column's `cell` renderer. */
28
+ data: T
29
+ /** Child rows revealed when this row is expanded. */
30
+ children?: TreeTableRow<T>[]
31
+ }
32
+
33
+ export interface TreeTableProps<T> {
34
+ columns: TreeTableColumn<T>[]
35
+ rows: TreeTableRow<T>[]
36
+ /** Expand every row with children on first render. Defaults to false. */
37
+ defaultExpanded?: boolean
38
+ class?: string
39
+ }
40
+
41
+ function collectIds<T>(rows: TreeTableRow<T>[], out: Set<string>): void {
42
+ for (const row of rows) {
43
+ if (row.children?.length) {
44
+ out.add(row.id)
45
+ collectIds(row.children, out)
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Hierarchical table: rows may carry `children`, revealed by clicking the
52
+ * chevron in the first column. Depth is shown via indentation; expanded state
53
+ * is tracked internally, seeded fully expanded when `defaultExpanded` is set.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * <TreeTable
58
+ * defaultExpanded
59
+ * columns={[
60
+ * { key: 'name', header: 'Name' },
61
+ * { key: 'size', header: 'Size', align: 'right', cell: (row) => `${row.size} KB` },
62
+ * ]}
63
+ * rows={[
64
+ * {
65
+ * id: 'src',
66
+ * data: { name: 'src', size: 0 },
67
+ * children: [{ id: 'index', data: { name: 'index.ts', size: 2 } }],
68
+ * },
69
+ * ]}
70
+ * />
71
+ * ```
72
+ */
73
+ export function TreeTable<T>(props: TreeTableProps<T>): JSX.Element {
74
+ const [expanded, setExpanded] = createSignal<Set<string>>(
75
+ props.defaultExpanded ? seedExpanded(props.rows) : new Set<string>(),
76
+ )
77
+
78
+ function seedExpanded(rows: TreeTableRow<T>[]): Set<string> {
79
+ const ids = new Set<string>()
80
+ collectIds(rows, ids)
81
+ return ids
82
+ }
83
+
84
+ const toggle = (id: string) =>
85
+ setExpanded((prev) => {
86
+ const next = new Set(prev)
87
+ if (next.has(id)) next.delete(id)
88
+ else next.add(id)
89
+ return next
90
+ })
91
+
92
+ const Rows = (rowsProps: { rows: TreeTableRow<T>[]; depth: number }): JSX.Element => (
93
+ <For each={rowsProps.rows}>
94
+ {(row) => {
95
+ const hasChildren = () => (row.children?.length ?? 0) > 0
96
+ const isExpanded = () => expanded().has(row.id)
97
+
98
+ return (
99
+ <>
100
+ <TableRow>
101
+ <For each={props.columns}>
102
+ {(col, colIndex) => (
103
+ <TableCell class={cn(col.align === 'right' && 'text-right tabular-nums')}>
104
+ <Show
105
+ when={colIndex() === 0}
106
+ fallback={
107
+ col.cell
108
+ ? col.cell(row.data)
109
+ : String((row.data as Record<string, unknown>)[col.key] ?? '')
110
+ }
111
+ >
112
+ <div
113
+ class="flex items-center gap-1.5"
114
+ style={{ 'padding-left': `${rowsProps.depth * 1}rem` }}
115
+ >
116
+ <Show
117
+ when={hasChildren()}
118
+ fallback={<span class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
119
+ >
120
+ <button
121
+ type="button"
122
+ class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
123
+ aria-expanded={isExpanded()}
124
+ aria-label={isExpanded() ? 'Collapse row' : 'Expand row'}
125
+ onClick={() => toggle(row.id)}
126
+ >
127
+ <ChevronRight
128
+ class={cn(
129
+ 'h-3.5 w-3.5 transition-transform duration-200',
130
+ isExpanded() && 'rotate-90',
131
+ )}
132
+ aria-hidden="true"
133
+ />
134
+ </button>
135
+ </Show>
136
+ <span>
137
+ {col.cell
138
+ ? col.cell(row.data)
139
+ : String((row.data as Record<string, unknown>)[col.key] ?? '')}
140
+ </span>
141
+ </div>
142
+ </Show>
143
+ </TableCell>
144
+ )}
145
+ </For>
146
+ </TableRow>
147
+ <Show when={hasChildren() && isExpanded()}>
148
+ <Rows rows={row.children ?? []} depth={rowsProps.depth + 1} />
149
+ </Show>
150
+ </>
151
+ )
152
+ }}
153
+ </For>
154
+ )
155
+
156
+ return (
157
+ <Table class={props.class}>
158
+ <TableHead>
159
+ <TableRow>
160
+ <For each={props.columns}>
161
+ {(col) => (
162
+ <TableHeadCell class={cn(col.align === 'right' && 'text-right')}>{col.header}</TableHeadCell>
163
+ )}
164
+ </For>
165
+ </TableRow>
166
+ </TableHead>
167
+ <TableBody>
168
+ <Rows rows={props.rows} depth={0} />
169
+ </TableBody>
170
+ </Table>
171
+ )
172
+ }