@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.
- package/dist/elements.css +183 -0
- package/dist/full.css +183 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +5857 -4509
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -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/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/SheetSnap.d.ts +28 -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 +25 -1
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -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/OnboardingChecklist.tsx +163 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
|
@@ -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
|
+
}
|