@a4ui/core 0.32.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/charts/BarList.d.ts +33 -0
- package/dist/charts/CategoryBar.d.ts +23 -0
- package/dist/charts/StatusTracker.d.ts +27 -0
- package/dist/charts/index.d.ts +3 -0
- package/dist/charts.js +446 -316
- package/dist/elements.css +278 -0
- package/dist/full.css +278 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +6423 -4195
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/Callout.d.ts +27 -0
- package/dist/ui/Confetti.d.ts +46 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/CursorTrail.d.ts +25 -0
- package/dist/ui/DiffViewer.d.ts +33 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/Globe.d.ts +44 -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/ModelPicker.d.ts +38 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/ReasoningTrace.d.ts +28 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/Snippet.d.ts +19 -0
- package/dist/ui/SuggestionChips.d.ts +25 -0
- package/dist/ui/ToolCallTimeline.d.ts +34 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/UsageMeter.d.ts +26 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/dist/ui/WorldMap.d.ts +36 -0
- package/package.json +1 -1
- package/src/charts/BarList.tsx +83 -0
- package/src/charts/CategoryBar.tsx +95 -0
- package/src/charts/StatusTracker.tsx +81 -0
- package/src/charts/index.ts +3 -0
- package/src/index.ts +48 -1
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/Callout.tsx +66 -0
- package/src/ui/Confetti.tsx +131 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/CursorTrail.tsx +88 -0
- package/src/ui/DiffViewer.tsx +166 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/Globe.tsx +317 -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/ModelPicker.tsx +119 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/ReasoningTrace.tsx +83 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/Snippet.tsx +64 -0
- package/src/ui/SuggestionChips.tsx +65 -0
- package/src/ui/ToolCallTimeline.tsx +137 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/UsageMeter.tsx +71 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
- package/src/ui/WorldMap.tsx +191 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Full-screen image viewer: a responsive thumbnail grid that opens (via
|
|
2
|
+
// Portal, dark backdrop) into an overlay with the active image, prev/next
|
|
3
|
+
// navigation, a close button, an alt-text caption, and a thumbnail strip —
|
|
4
|
+
// same overlay idiom as Modal (backdrop click / Escape closes, body scroll
|
|
5
|
+
// locks while open), just built directly on Portal instead of Kobalte's
|
|
6
|
+
// Dialog so index/open state can be driven by the caller.
|
|
7
|
+
import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-solid'
|
|
8
|
+
import type { JSX } from 'solid-js'
|
|
9
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
|
|
10
|
+
|
|
11
|
+
import { cn } from '../lib/cn'
|
|
12
|
+
import { motionReduced } from '../lib/motion'
|
|
13
|
+
import { Portal } from './Portal'
|
|
14
|
+
|
|
15
|
+
/** One image in a {@link Lightbox}. */
|
|
16
|
+
export interface LightboxImage {
|
|
17
|
+
src: string
|
|
18
|
+
alt?: string
|
|
19
|
+
/** Smaller image used in the thumbnail grid/strip; falls back to `src`. */
|
|
20
|
+
thumb?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LightboxProps {
|
|
24
|
+
images: LightboxImage[]
|
|
25
|
+
/** Controlled overlay open state. When set, wins over internal state — pair with `onOpenChange`. */
|
|
26
|
+
open?: boolean
|
|
27
|
+
/** Controlled active image index (clamped to the images array). When set, wins over internal state — pair with `onIndexChange`. */
|
|
28
|
+
index?: number
|
|
29
|
+
onOpenChange?: (open: boolean) => void
|
|
30
|
+
onIndexChange?: (index: number) => void
|
|
31
|
+
/** Show the thumbnail strip at the bottom of the overlay. @default true */
|
|
32
|
+
showThumbnails?: boolean
|
|
33
|
+
class?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Responsive thumbnail grid (`thumb || src`) that opens into a full-screen
|
|
38
|
+
* viewer on click: the active image centered, prev/next chevrons, a close
|
|
39
|
+
* button, an alt-text caption, a zoom toggle, and (by default) a thumbnail
|
|
40
|
+
* strip with the active image highlighted. Navigate with the arrow keys or
|
|
41
|
+
* Escape, click the backdrop to close. Works controlled
|
|
42
|
+
* (`open`/`index` + `onOpenChange`/`onIndexChange`) or uncontrolled. Body
|
|
43
|
+
* scroll locks while open, like `Modal`; the zoom transition is skipped
|
|
44
|
+
* under `prefers-reduced-motion` but stays fully functional.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <Lightbox
|
|
49
|
+
* images={[
|
|
50
|
+
* { src: '/photos/1-full.jpg', thumb: '/photos/1-thumb.jpg', alt: 'Sunset over the bay' },
|
|
51
|
+
* { src: '/photos/2-full.jpg', thumb: '/photos/2-thumb.jpg', alt: 'Mountain trail' },
|
|
52
|
+
* ]}
|
|
53
|
+
* />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function Lightbox(props: LightboxProps): JSX.Element {
|
|
57
|
+
const [internalOpen, setInternalOpen] = createSignal(false)
|
|
58
|
+
const open = createMemo(() => props.open ?? internalOpen())
|
|
59
|
+
|
|
60
|
+
const [internalIndex, setInternalIndex] = createSignal(0)
|
|
61
|
+
const rawIndex = createMemo(() => props.index ?? internalIndex())
|
|
62
|
+
|
|
63
|
+
const lastIndex = createMemo(() => Math.max(0, props.images.length - 1))
|
|
64
|
+
const clampIndex = (i: number): number => Math.min(Math.max(i, 0), lastIndex())
|
|
65
|
+
const index = createMemo(() => clampIndex(rawIndex()))
|
|
66
|
+
const current = createMemo(() => props.images[index()])
|
|
67
|
+
|
|
68
|
+
const setOpen = (next: boolean): void => {
|
|
69
|
+
setInternalOpen(next)
|
|
70
|
+
props.onOpenChange?.(next)
|
|
71
|
+
}
|
|
72
|
+
const setIndex = (next: number): void => {
|
|
73
|
+
const clamped = clampIndex(next)
|
|
74
|
+
setInternalIndex(clamped)
|
|
75
|
+
props.onIndexChange?.(clamped)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const openAt = (i: number): void => {
|
|
79
|
+
setIndex(i)
|
|
80
|
+
setOpen(true)
|
|
81
|
+
}
|
|
82
|
+
const close = (): void => setOpen(false)
|
|
83
|
+
const prev = (): void => setIndex(index() - 1)
|
|
84
|
+
const next = (): void => setIndex(index() + 1)
|
|
85
|
+
const showThumbnails = (): boolean => props.showThumbnails !== false
|
|
86
|
+
|
|
87
|
+
// Zoom toggle for the active image; reset whenever the image or open state changes.
|
|
88
|
+
const [zoomed, setZoomed] = createSignal(false)
|
|
89
|
+
createEffect(() => {
|
|
90
|
+
index()
|
|
91
|
+
open()
|
|
92
|
+
setZoomed(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Lock body scroll while the overlay is open — same idiom as Modal's Dialog.
|
|
96
|
+
createEffect(() => {
|
|
97
|
+
if (!open() || typeof document === 'undefined') return
|
|
98
|
+
const previousOverflow = document.body.style.overflow
|
|
99
|
+
document.body.style.overflow = 'hidden'
|
|
100
|
+
onCleanup(() => {
|
|
101
|
+
document.body.style.overflow = previousOverflow
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Escape closes, ArrowLeft/ArrowRight navigate, while the overlay is open.
|
|
106
|
+
createEffect(() => {
|
|
107
|
+
if (!open() || typeof window === 'undefined') return
|
|
108
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
109
|
+
if (e.key === 'Escape') {
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
close()
|
|
112
|
+
} else if (e.key === 'ArrowLeft') {
|
|
113
|
+
e.preventDefault()
|
|
114
|
+
prev()
|
|
115
|
+
} else if (e.key === 'ArrowRight') {
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
next()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
window.addEventListener('keydown', onKeyDown)
|
|
121
|
+
onCleanup(() => window.removeEventListener('keydown', onKeyDown))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div class={cn('grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4', props.class)}>
|
|
126
|
+
<For each={props.images}>
|
|
127
|
+
{(image, i) => (
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
class="group relative aspect-square overflow-hidden rounded-lg border border-border bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
131
|
+
aria-label={image.alt ?? `Open image ${i() + 1}`}
|
|
132
|
+
onClick={() => openAt(i())}
|
|
133
|
+
>
|
|
134
|
+
<img
|
|
135
|
+
src={image.thumb ?? image.src}
|
|
136
|
+
alt={image.alt ?? ''}
|
|
137
|
+
loading="lazy"
|
|
138
|
+
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
139
|
+
/>
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
</For>
|
|
143
|
+
|
|
144
|
+
<Show when={open() && current()}>
|
|
145
|
+
<Portal>
|
|
146
|
+
<div
|
|
147
|
+
role="dialog"
|
|
148
|
+
aria-modal="true"
|
|
149
|
+
aria-label={current()?.alt ?? 'Image viewer'}
|
|
150
|
+
tabindex="-1"
|
|
151
|
+
class="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm outline-none"
|
|
152
|
+
onClick={close}
|
|
153
|
+
ref={(el) => {
|
|
154
|
+
queueMicrotask(() => el.focus())
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<div class="flex items-center justify-end p-4" onClick={(e) => e.stopPropagation()}>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
aria-label="Close"
|
|
161
|
+
onClick={close}
|
|
162
|
+
class="grid h-10 w-10 place-items-center rounded-lg bg-card/80 text-foreground transition hover:bg-muted"
|
|
163
|
+
>
|
|
164
|
+
<X class="h-5 w-5" />
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div class="relative flex flex-1 items-center justify-center px-4">
|
|
169
|
+
<Show when={props.images.length > 1}>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
aria-label="Previous image"
|
|
173
|
+
disabled={index() === 0}
|
|
174
|
+
onClick={(e) => {
|
|
175
|
+
e.stopPropagation()
|
|
176
|
+
prev()
|
|
177
|
+
}}
|
|
178
|
+
class="absolute left-2 z-10 grid h-10 w-10 place-items-center rounded-full bg-card/80 text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40 sm:left-4"
|
|
179
|
+
>
|
|
180
|
+
<ChevronLeft class="h-6 w-6" />
|
|
181
|
+
</button>
|
|
182
|
+
</Show>
|
|
183
|
+
|
|
184
|
+
<img
|
|
185
|
+
src={current()?.src}
|
|
186
|
+
alt={current()?.alt ?? ''}
|
|
187
|
+
onClick={(e) => {
|
|
188
|
+
e.stopPropagation()
|
|
189
|
+
setZoomed((z) => !z)
|
|
190
|
+
}}
|
|
191
|
+
class={cn(
|
|
192
|
+
'max-h-[calc(100vh-10rem)] max-w-[92vw] rounded-lg object-contain',
|
|
193
|
+
!motionReduced() && 'transition-transform duration-300 ease-out',
|
|
194
|
+
zoomed() ? 'scale-150 cursor-zoom-out' : 'cursor-zoom-in',
|
|
195
|
+
)}
|
|
196
|
+
/>
|
|
197
|
+
|
|
198
|
+
<Show when={props.images.length > 1}>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
aria-label="Next image"
|
|
202
|
+
disabled={index() === lastIndex()}
|
|
203
|
+
onClick={(e) => {
|
|
204
|
+
e.stopPropagation()
|
|
205
|
+
next()
|
|
206
|
+
}}
|
|
207
|
+
class="absolute right-2 z-10 grid h-10 w-10 place-items-center rounded-full bg-card/80 text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40 sm:right-4"
|
|
208
|
+
>
|
|
209
|
+
<ChevronRight class="h-6 w-6" />
|
|
210
|
+
</button>
|
|
211
|
+
</Show>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div class="flex flex-col items-center gap-3 p-4" onClick={(e) => e.stopPropagation()}>
|
|
215
|
+
<div class="flex items-center gap-3">
|
|
216
|
+
<Show when={current()?.alt}>
|
|
217
|
+
<p class="text-center text-sm text-muted-foreground">{current()?.alt}</p>
|
|
218
|
+
</Show>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
aria-label={zoomed() ? 'Zoom out' : 'Zoom in'}
|
|
222
|
+
aria-pressed={zoomed()}
|
|
223
|
+
onClick={() => setZoomed((z) => !z)}
|
|
224
|
+
class={cn(
|
|
225
|
+
'grid h-8 w-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground',
|
|
226
|
+
zoomed() && 'bg-muted text-foreground',
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<ZoomIn class="h-4 w-4" />
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<Show when={showThumbnails() && props.images.length > 1}>
|
|
234
|
+
<div class="flex max-w-full gap-2 overflow-x-auto py-1">
|
|
235
|
+
<For each={props.images}>
|
|
236
|
+
{(image, i) => (
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
aria-label={image.alt ?? `Go to image ${i() + 1}`}
|
|
240
|
+
aria-current={index() === i() ? 'true' : undefined}
|
|
241
|
+
onClick={() => setIndex(i())}
|
|
242
|
+
class={cn(
|
|
243
|
+
'h-14 w-14 shrink-0 overflow-hidden rounded-md border-2 transition',
|
|
244
|
+
index() === i()
|
|
245
|
+
? 'border-primary'
|
|
246
|
+
: 'border-transparent opacity-60 hover:opacity-100',
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
<img src={image.thumb ?? image.src} alt="" class="h-full w-full object-cover" />
|
|
250
|
+
</button>
|
|
251
|
+
)}
|
|
252
|
+
</For>
|
|
253
|
+
</div>
|
|
254
|
+
</Show>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</Portal>
|
|
258
|
+
</Show>
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// AI model picker — dropdown trigger (icon + name + chevron) over a menu of
|
|
2
|
+
// models (icon + name + description + badge), selected one checkmarked.
|
|
3
|
+
// Built on Kobalte's DropdownMenu (same primitive as Dropdown.tsx), using its
|
|
4
|
+
// RadioGroup/RadioItem for single-select keyboard navigation and a11y
|
|
5
|
+
// (role="menuitemradio", aria-checked) — Dropdown.tsx's own item API only
|
|
6
|
+
// supports a plain string label, not this richer per-option content.
|
|
7
|
+
import { DropdownMenu } from '@kobalte/core/dropdown-menu'
|
|
8
|
+
import { Check, ChevronDown } from 'lucide-solid'
|
|
9
|
+
import type { JSX } from 'solid-js'
|
|
10
|
+
import { For, Show, createSignal, untrack } from 'solid-js'
|
|
11
|
+
|
|
12
|
+
import { cn } from '../lib/cn'
|
|
13
|
+
|
|
14
|
+
/** A single selectable model in a {@link ModelPicker} menu. */
|
|
15
|
+
export interface ModelOption {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
icon?: JSX.Element
|
|
19
|
+
description?: string
|
|
20
|
+
badge?: JSX.Element
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ModelPickerProps {
|
|
24
|
+
models: ModelOption[]
|
|
25
|
+
/** Controlled selected model id. Omit and use `defaultValue` for uncontrolled use. */
|
|
26
|
+
value?: string
|
|
27
|
+
/** Initial selected model id when uncontrolled. Ignored if `value` is passed. */
|
|
28
|
+
defaultValue?: string
|
|
29
|
+
onChange?: (id: string) => void
|
|
30
|
+
class?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dropdown trigger showing the selected model's icon + name + a chevron; the
|
|
35
|
+
* menu lists every model with icon, name, description, and optional badge,
|
|
36
|
+
* checkmarking the current selection. Controlled via `value`/`onChange` or
|
|
37
|
+
* uncontrolled via `defaultValue`, and fully keyboard-navigable (arrow keys,
|
|
38
|
+
* typeahead, Escape) via Kobalte's `DropdownMenu`.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <ModelPicker
|
|
43
|
+
* models={[
|
|
44
|
+
* { id: 'sonnet', name: 'Claude Sonnet', description: 'Balanced speed and quality' },
|
|
45
|
+
* { id: 'opus', name: 'Claude Opus', description: 'Most capable', badge: <Badge tone="info">New</Badge> },
|
|
46
|
+
* ]}
|
|
47
|
+
* defaultValue="sonnet"
|
|
48
|
+
* onChange={(id) => console.log('selected', id)}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function ModelPicker(props: ModelPickerProps): JSX.Element {
|
|
53
|
+
// Initial value only: read once (untracked), by design — this seeds the
|
|
54
|
+
// uncontrolled fallback and must not re-run when `models`/`defaultValue` change later.
|
|
55
|
+
const [internal, setInternal] = createSignal(untrack(() => props.defaultValue ?? props.models[0]?.id))
|
|
56
|
+
const selectedId = () => props.value ?? internal()
|
|
57
|
+
const selectedModel = () => props.models.find((m) => m.id === selectedId())
|
|
58
|
+
|
|
59
|
+
const handleChange = (id: string) => {
|
|
60
|
+
if (props.value === undefined) setInternal(id)
|
|
61
|
+
props.onChange?.(id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<DropdownMenu preventScroll={false}>
|
|
66
|
+
<DropdownMenu.Trigger
|
|
67
|
+
class={cn(
|
|
68
|
+
'inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors a4-field',
|
|
69
|
+
props.class,
|
|
70
|
+
)}
|
|
71
|
+
aria-label="Select model"
|
|
72
|
+
>
|
|
73
|
+
<Show when={selectedModel()?.icon}>
|
|
74
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
75
|
+
{selectedModel()?.icon}
|
|
76
|
+
</span>
|
|
77
|
+
</Show>
|
|
78
|
+
<span class="truncate font-medium">{selectedModel()?.name}</span>
|
|
79
|
+
<ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
80
|
+
</DropdownMenu.Trigger>
|
|
81
|
+
<DropdownMenu.Portal>
|
|
82
|
+
<DropdownMenu.Content class="z-50 min-w-64 overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
|
|
83
|
+
<DropdownMenu.RadioGroup value={selectedId()} onChange={handleChange}>
|
|
84
|
+
<For each={props.models}>
|
|
85
|
+
{(model) => (
|
|
86
|
+
<DropdownMenu.RadioItem
|
|
87
|
+
value={model.id}
|
|
88
|
+
class="flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 outline-none hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
|
89
|
+
>
|
|
90
|
+
<Show when={model.icon}>
|
|
91
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
92
|
+
{model.icon}
|
|
93
|
+
</span>
|
|
94
|
+
</Show>
|
|
95
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
96
|
+
<DropdownMenu.ItemLabel class="truncate text-sm text-foreground">
|
|
97
|
+
{model.name}
|
|
98
|
+
</DropdownMenu.ItemLabel>
|
|
99
|
+
<Show when={model.description}>
|
|
100
|
+
<DropdownMenu.ItemDescription class="truncate text-xs text-muted-foreground">
|
|
101
|
+
{model.description}
|
|
102
|
+
</DropdownMenu.ItemDescription>
|
|
103
|
+
</Show>
|
|
104
|
+
</span>
|
|
105
|
+
<Show when={model.badge}>{model.badge}</Show>
|
|
106
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-primary">
|
|
107
|
+
<Show when={model.id === selectedId()}>
|
|
108
|
+
<Check class="h-4 w-4" />
|
|
109
|
+
</Show>
|
|
110
|
+
</span>
|
|
111
|
+
</DropdownMenu.RadioItem>
|
|
112
|
+
)}
|
|
113
|
+
</For>
|
|
114
|
+
</DropdownMenu.RadioGroup>
|
|
115
|
+
</DropdownMenu.Content>
|
|
116
|
+
</DropdownMenu.Portal>
|
|
117
|
+
</DropdownMenu>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -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,83 @@
|
|
|
1
|
+
// ReasoningTrace — collapsible "thinking" panel for AI surfaces. Mirrors
|
|
2
|
+
// Collapse.tsx's header/panel idiom (grid-rows transition, chevron rotate)
|
|
3
|
+
// but owns its own open state (seeded from `defaultOpen`) and adds a
|
|
4
|
+
// streaming pulse indicator for in-flight reasoning.
|
|
5
|
+
import { Brain, ChevronDown } from 'lucide-solid'
|
|
6
|
+
import { type JSX, Show, createSignal } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface ReasoningTraceProps {
|
|
11
|
+
/** Reasoning body content; takes precedence over `text` when both are given. */
|
|
12
|
+
children?: JSX.Element
|
|
13
|
+
/** Plain-text reasoning body, rendered with preserved whitespace. */
|
|
14
|
+
text?: string
|
|
15
|
+
/** Header label. Defaults to `'Reasoning'`. */
|
|
16
|
+
label?: JSX.Element
|
|
17
|
+
/** Whether the panel starts expanded. Defaults to `false`. */
|
|
18
|
+
defaultOpen?: boolean
|
|
19
|
+
/** Show a pulsing dot in the header to indicate reasoning is still streaming. */
|
|
20
|
+
streaming?: boolean
|
|
21
|
+
class?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collapsible panel for surfacing a model's intermediate "thinking"/reasoning
|
|
26
|
+
* trace. The header is a toggle button (brain icon + label + rotating
|
|
27
|
+
* chevron, plus a pulsing dot while `streaming`); the body is a muted,
|
|
28
|
+
* smaller-text block showing `text` or `children` with whitespace preserved.
|
|
29
|
+
* Collapsed by default unless `defaultOpen`. Height/opacity transition,
|
|
30
|
+
* instant under reduced motion.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <ReasoningTrace streaming text={reasoningSoFar()} />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function ReasoningTrace(props: ReasoningTraceProps): JSX.Element {
|
|
38
|
+
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div class={cn('rounded-xl border border-border bg-card', props.class)}>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-expanded={open()}
|
|
45
|
+
onClick={() => setOpen(!open())}
|
|
46
|
+
class="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
|
|
47
|
+
>
|
|
48
|
+
<Brain class="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
49
|
+
<span class="flex-1 text-left">{props.label ?? 'Reasoning'}</span>
|
|
50
|
+
<Show when={props.streaming}>
|
|
51
|
+
<span class="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
|
|
52
|
+
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75 motion-reduce:animate-none" />
|
|
53
|
+
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
|
54
|
+
</span>
|
|
55
|
+
</Show>
|
|
56
|
+
<ChevronDown
|
|
57
|
+
class={cn(
|
|
58
|
+
'h-4 w-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
|
59
|
+
open() && 'rotate-180',
|
|
60
|
+
)}
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
/>
|
|
63
|
+
</button>
|
|
64
|
+
<div
|
|
65
|
+
class={cn(
|
|
66
|
+
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
|
67
|
+
open() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
<div class="overflow-hidden">
|
|
71
|
+
<div
|
|
72
|
+
class={cn(
|
|
73
|
+
'whitespace-pre-wrap px-3 pb-3 text-xs leading-relaxed text-muted-foreground transition-opacity duration-200 motion-reduce:transition-none',
|
|
74
|
+
open() ? 'opacity-100' : 'opacity-0',
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{props.children ?? props.text}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|