@a4ui/core 0.24.3 → 0.26.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/README.md +11 -11
- package/dist/elements.css +145 -0
- package/dist/full.css +145 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.js +3225 -2582
- package/dist/lib/createOptimistic.d.ts +17 -0
- package/dist/lib/easing.d.ts +42 -0
- package/dist/lib/viewTransition.d.ts +8 -0
- package/dist/ui/ArtifactPanel.d.ts +29 -0
- package/dist/ui/CategoryStrip.d.ts +24 -0
- package/dist/ui/ChatThread.d.ts +24 -0
- package/dist/ui/Citation.d.ts +44 -0
- package/dist/ui/CodeTabs.d.ts +27 -0
- package/dist/ui/FloatingToolbar.d.ts +26 -0
- package/dist/ui/InlineSelect.d.ts +33 -0
- package/dist/ui/MasterDetail.d.ts +27 -0
- package/dist/ui/Message.d.ts +28 -0
- package/dist/ui/PageTransition.d.ts +25 -0
- package/dist/ui/PillSearch.d.ts +38 -0
- package/dist/ui/PromptComposer.d.ts +34 -0
- package/dist/ui/StreamingText.d.ts +26 -0
- package/package.json +1 -1
- package/preset.js +20 -0
- package/src/index.ts +21 -1
- package/src/lib/createOptimistic.ts +43 -0
- package/src/lib/easing.ts +43 -0
- package/src/lib/viewTransition.ts +32 -0
- package/src/ui/ArtifactPanel.tsx +88 -0
- package/src/ui/CategoryStrip.tsx +80 -0
- package/src/ui/ChatThread.tsx +59 -0
- package/src/ui/Citation.tsx +97 -0
- package/src/ui/CodeTabs.tsx +100 -0
- package/src/ui/FloatingToolbar.tsx +75 -0
- package/src/ui/InlineSelect.tsx +101 -0
- package/src/ui/MasterDetail.tsx +117 -0
- package/src/ui/Message.tsx +69 -0
- package/src/ui/PageTransition.tsx +90 -0
- package/src/ui/PillSearch.tsx +86 -0
- package/src/ui/PromptComposer.tsx +138 -0
- package/src/ui/StreamingText.tsx +98 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Wrapper around the browser View Transitions API. Not every target browser
|
|
2
|
+
// implements `document.startViewTransition` yet, and even where it exists we
|
|
3
|
+
// want to skip it under reduced motion — this module centralizes both checks
|
|
4
|
+
// so callers don't have to feature-detect or re-import motionReduced themselves.
|
|
5
|
+
import { motionReduced } from './motion'
|
|
6
|
+
|
|
7
|
+
/** Minimal shape of `Document` augmented with the View Transitions API. */
|
|
8
|
+
interface DocumentWithViewTransitions {
|
|
9
|
+
startViewTransition: (callback: () => void) => unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function supportsViewTransitions(doc: Document): doc is Document & DocumentWithViewTransitions {
|
|
13
|
+
return (
|
|
14
|
+
'startViewTransition' in doc &&
|
|
15
|
+
typeof (doc as unknown as DocumentWithViewTransitions).startViewTransition === 'function'
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run `update` inside a View Transition when the browser supports it, so DOM
|
|
21
|
+
* changes cross-fade automatically; otherwise just run `update` synchronously.
|
|
22
|
+
* Respects reduced-motion (skips the transition).
|
|
23
|
+
* @example
|
|
24
|
+
* startViewTransition(() => setRoute('details'))
|
|
25
|
+
*/
|
|
26
|
+
export function startViewTransition(update: () => void): void {
|
|
27
|
+
if (typeof document === 'undefined' || motionReduced() || !supportsViewTransitions(document)) {
|
|
28
|
+
update()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
document.startViewTransition(update)
|
|
32
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Inline (non-portalled) right-side panel for AI-generated output — code,
|
|
2
|
+
// preview, or doc — shown alongside a chat. Unlike `Drawer` (a portalled
|
|
3
|
+
// overlay on Kobalte's Dialog), the caller places this directly in a flex row
|
|
4
|
+
// next to the main content: it reserves no space when closed (width collapses
|
|
5
|
+
// to 0) and reveals by animating its own width, so the chat pane reflows next
|
|
6
|
+
// to it instead of being covered. We collapse to width 0 rather than toggling
|
|
7
|
+
// `display: none` because a `display` toggle jumps instantly — animating width
|
|
8
|
+
// is what makes it slide.
|
|
9
|
+
import { X } from 'lucide-solid'
|
|
10
|
+
import type { JSX } from 'solid-js'
|
|
11
|
+
|
|
12
|
+
import { cn } from '../lib/cn'
|
|
13
|
+
import { motionReduced } from '../lib/motion'
|
|
14
|
+
|
|
15
|
+
export interface ArtifactPanelProps {
|
|
16
|
+
open: boolean
|
|
17
|
+
onClose?: () => void
|
|
18
|
+
title?: string
|
|
19
|
+
/** Panel width in px when open. @default 420 */
|
|
20
|
+
width?: number
|
|
21
|
+
children: JSX.Element
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Right-side split panel that holds generated output (code, preview, doc) next
|
|
27
|
+
* to a chat — for AI UIs that keep the artifact and the conversation visible at
|
|
28
|
+
* once. Inline, not an overlay: place it as the last child of a flex row next
|
|
29
|
+
* to the main content. Slides open/closed by animating width (and a matching
|
|
30
|
+
* fade/slide on its content), reduced-motion aware via `motionReduced()`.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* const [open, setOpen] = createSignal(false)
|
|
35
|
+
* <div class="flex h-full">
|
|
36
|
+
* <main class="min-w-0 flex-1">…chat…</main>
|
|
37
|
+
* <ArtifactPanel open={open()} onClose={() => setOpen(false)} title="script.py">
|
|
38
|
+
* <pre class="p-4 text-sm">…generated code…</pre>
|
|
39
|
+
* </ArtifactPanel>
|
|
40
|
+
* </div>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function ArtifactPanel(props: ArtifactPanelProps): JSX.Element {
|
|
44
|
+
const width = () => props.width ?? 420
|
|
45
|
+
const reduced = () => motionReduced()
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
role="complementary"
|
|
50
|
+
aria-label={props.title ?? 'Artifact'}
|
|
51
|
+
aria-hidden={!props.open}
|
|
52
|
+
class={cn(
|
|
53
|
+
'card h-full shrink-0 overflow-hidden border-border bg-glass',
|
|
54
|
+
props.open ? 'border-l' : 'border-0',
|
|
55
|
+
reduced() ? undefined : 'transition-[width] duration-300 ease-in-out',
|
|
56
|
+
props.class,
|
|
57
|
+
)}
|
|
58
|
+
style={{ width: `${props.open ? width() : 0}px` }}
|
|
59
|
+
>
|
|
60
|
+
{/* Fixed-width inner wrapper: the content never reflows/wraps while the
|
|
61
|
+
outer element's width animates between 0 and `width()`. */}
|
|
62
|
+
<div
|
|
63
|
+
class={cn(
|
|
64
|
+
'flex h-full flex-col',
|
|
65
|
+
reduced() ? undefined : 'transition-[opacity,transform] duration-300 ease-in-out',
|
|
66
|
+
)}
|
|
67
|
+
style={{
|
|
68
|
+
width: `${width()}px`,
|
|
69
|
+
opacity: props.open ? 1 : 0,
|
|
70
|
+
transform: props.open ? 'translateX(0)' : 'translateX(16px)',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/90 px-5 py-4 backdrop-blur">
|
|
74
|
+
<span class="truncate text-[16px] font-bold leading-tight">{props.title ?? 'Artifact'}</span>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => props.onClose?.()}
|
|
78
|
+
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
79
|
+
aria-label="Close"
|
|
80
|
+
>
|
|
81
|
+
<X class="h-5 w-5" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="min-h-0 flex-1 overflow-y-auto">{props.children}</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Horizontally-scrollable strip of icon+label category filters, with an
|
|
2
|
+
// active-underline indicator — e.g. a storefront category nav (All, Fresh,
|
|
3
|
+
// Bakery, Dairy, …). Scrollbar is hidden; Left/Right arrows move selection.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
|
|
9
|
+
/** A single selectable category: value, label, and optional leading icon. */
|
|
10
|
+
export interface CategoryItem {
|
|
11
|
+
value: string
|
|
12
|
+
label: string
|
|
13
|
+
icon?: JSX.Element
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CategoryStripProps {
|
|
17
|
+
items: CategoryItem[]
|
|
18
|
+
value: string
|
|
19
|
+
onChange: (value: string) => void
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Horizontally-scrollable row of icon-over-label category tabs with a
|
|
25
|
+
* bottom-underline indicator on the active item. Left/Right arrow keys move
|
|
26
|
+
* the selection between items.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <CategoryStrip items={categories} value={category()} onChange={setCategory} />
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function CategoryStrip(props: CategoryStripProps): JSX.Element {
|
|
34
|
+
const activeIndex = () => props.items.findIndex((item) => item.value === props.value)
|
|
35
|
+
|
|
36
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
37
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
|
38
|
+
event.preventDefault()
|
|
39
|
+
const count = props.items.length
|
|
40
|
+
if (count === 0) return
|
|
41
|
+
const current = activeIndex()
|
|
42
|
+
const delta = event.key === 'ArrowRight' ? 1 : -1
|
|
43
|
+
const next = ((current === -1 ? 0 : current) + delta + count) % count
|
|
44
|
+
props.onChange(props.items[next].value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
role="tablist"
|
|
50
|
+
class={cn(
|
|
51
|
+
'flex gap-6 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
52
|
+
props.class,
|
|
53
|
+
)}
|
|
54
|
+
onKeyDown={onKeyDown}
|
|
55
|
+
>
|
|
56
|
+
<For each={props.items}>
|
|
57
|
+
{(item) => {
|
|
58
|
+
const isActive = () => item.value === props.value
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
role="tab"
|
|
63
|
+
aria-selected={isActive()}
|
|
64
|
+
class={cn(
|
|
65
|
+
'flex shrink-0 flex-col items-center gap-1 border-b-2 px-1 pb-2 text-xs font-medium transition-colors',
|
|
66
|
+
isActive()
|
|
67
|
+
? 'border-primary text-foreground'
|
|
68
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
69
|
+
)}
|
|
70
|
+
onClick={() => props.onChange(item.value)}
|
|
71
|
+
>
|
|
72
|
+
{item.icon}
|
|
73
|
+
<span>{item.label}</span>
|
|
74
|
+
</button>
|
|
75
|
+
)
|
|
76
|
+
}}
|
|
77
|
+
</For>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ChatThread — layout shell for AI/conversation UIs: a centered, scrollable
|
|
2
|
+
// reading column that stacks chat messages. Purely a container: it has no
|
|
3
|
+
// idea what a "message" looks like, so any element can be stacked inside it.
|
|
4
|
+
import { createEffect, onMount, type JSX } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
import { motionReduced } from '../lib/motion'
|
|
8
|
+
|
|
9
|
+
export interface ChatThreadProps {
|
|
10
|
+
children: JSX.Element
|
|
11
|
+
/** Max width of the reading column, in px. Defaults to `768`. */
|
|
12
|
+
maxWidth?: number
|
|
13
|
+
/** Auto-scroll to the newest message on mount and whenever children change. Defaults to `true`. */
|
|
14
|
+
stickToBottom?: boolean
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scrollable, centered reading column that stacks chat messages. Layout-only:
|
|
20
|
+
* it renders whatever is passed as `children`, so pair it with your own
|
|
21
|
+
* message bubbles/components.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <ChatThread maxWidth={640}>
|
|
26
|
+
* <div>Hi! Ask me anything about your itinerary.</div>
|
|
27
|
+
* <div>What's the weather like in Rivertown this weekend?</div>
|
|
28
|
+
* <div>Sunny and mild, 18–22°C, light breeze on Saturday.</div>
|
|
29
|
+
* </ChatThread>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function ChatThread(props: ChatThreadProps): JSX.Element {
|
|
33
|
+
let scrollEl: HTMLDivElement | undefined
|
|
34
|
+
|
|
35
|
+
const scrollToBottom = () => {
|
|
36
|
+
if (!scrollEl) return
|
|
37
|
+
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: motionReduced() ? 'auto' : 'smooth' })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onMount(() => {
|
|
41
|
+
if (props.stickToBottom ?? true) scrollToBottom()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
createEffect(() => {
|
|
45
|
+
// Track children so newly appended messages pull the thread down.
|
|
46
|
+
void props.children
|
|
47
|
+
if (props.stickToBottom ?? true) queueMicrotask(scrollToBottom)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={scrollEl}
|
|
53
|
+
class={cn('mx-auto flex w-full flex-col gap-4 overflow-y-auto', props.class)}
|
|
54
|
+
style={{ 'max-width': `${props.maxWidth ?? 768}px` }}
|
|
55
|
+
>
|
|
56
|
+
{props.children}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Citation — inline source-reference chip for AI answers that cite sources,
|
|
2
|
+
// plus a compact SourceList for the "Sources" block below the answer.
|
|
3
|
+
import { For, type JSX, Show } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../lib/cn'
|
|
6
|
+
|
|
7
|
+
const CITATION_BASE =
|
|
8
|
+
'citation inline-flex items-center justify-center rounded-full bg-muted text-muted-foreground ' +
|
|
9
|
+
'text-[0.65rem] leading-none align-super h-[1.1em] min-w-[1.1em] px-1 font-semibold'
|
|
10
|
+
|
|
11
|
+
export interface CitationProps {
|
|
12
|
+
/** The source number shown in the chip, e.g. `1`. */
|
|
13
|
+
index: number
|
|
14
|
+
/** Links out to the source when present (opens in a new tab). */
|
|
15
|
+
href?: string
|
|
16
|
+
/** Shown as a native tooltip and used as the accessible name. */
|
|
17
|
+
title?: string
|
|
18
|
+
class?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Small inline chip citing a numbered source, meant to sit inside a sentence
|
|
23
|
+
* right after the claim it backs up.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* <p>
|
|
28
|
+
* Glass surfaces use a translucent backdrop blur
|
|
29
|
+
* <Citation index={1} href="https://example.com/spec" title="Design spec" />.
|
|
30
|
+
* </p>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function Citation(props: CitationProps): JSX.Element {
|
|
34
|
+
return (
|
|
35
|
+
<Show
|
|
36
|
+
when={props.href}
|
|
37
|
+
fallback={
|
|
38
|
+
<span class={cn(CITATION_BASE, props.class)} title={props.title}>
|
|
39
|
+
{props.index}
|
|
40
|
+
</span>
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
<a
|
|
44
|
+
href={props.href}
|
|
45
|
+
target="_blank"
|
|
46
|
+
rel="noreferrer"
|
|
47
|
+
title={props.title}
|
|
48
|
+
aria-label={props.title ?? `Source ${props.index}`}
|
|
49
|
+
class={cn(CITATION_BASE, 'hover:bg-primary/15 hover:text-foreground', props.class)}
|
|
50
|
+
>
|
|
51
|
+
{props.index}
|
|
52
|
+
</a>
|
|
53
|
+
</Show>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SourceListProps {
|
|
58
|
+
sources: { title: string; href?: string }[]
|
|
59
|
+
class?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compact numbered "Sources" block matching the indices used by {@link Citation}.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```tsx
|
|
67
|
+
* <SourceList
|
|
68
|
+
* sources={[
|
|
69
|
+
* { title: 'Design spec', href: 'https://example.com/spec' },
|
|
70
|
+
* { title: 'Internal style guide' },
|
|
71
|
+
* ]}
|
|
72
|
+
* />
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function SourceList(props: SourceListProps): JSX.Element {
|
|
76
|
+
return (
|
|
77
|
+
<ol class={cn('flex flex-col gap-1 text-sm text-muted-foreground', props.class)}>
|
|
78
|
+
<For each={props.sources}>
|
|
79
|
+
{(source, index) => (
|
|
80
|
+
<li class="flex gap-2">
|
|
81
|
+
<span class="text-muted-foreground/70">{index() + 1}.</span>
|
|
82
|
+
<Show when={source.href} fallback={<span>{source.title}</span>}>
|
|
83
|
+
<a
|
|
84
|
+
href={source.href}
|
|
85
|
+
target="_blank"
|
|
86
|
+
rel="noreferrer"
|
|
87
|
+
class="text-foreground underline decoration-border underline-offset-2 hover:text-primary"
|
|
88
|
+
>
|
|
89
|
+
{source.title}
|
|
90
|
+
</a>
|
|
91
|
+
</Show>
|
|
92
|
+
</li>
|
|
93
|
+
)}
|
|
94
|
+
</For>
|
|
95
|
+
</ol>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Tabbed code blocks with a per-tab copy button, for embedding several code
|
|
2
|
+
// samples/languages (docs, marketing) in a single card. Plain monospace —
|
|
3
|
+
// no syntax highlighting.
|
|
4
|
+
import { Check, Copy } 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
|
+
|
|
10
|
+
/** A single tab's content: label, source code, and optional language tag. */
|
|
11
|
+
export interface CodeTab {
|
|
12
|
+
label: string
|
|
13
|
+
code: string
|
|
14
|
+
/** Language tag, e.g. `'tsx'`. Not used for highlighting; informational only. */
|
|
15
|
+
lang?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CodeTabsProps {
|
|
19
|
+
tabs: CodeTab[]
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const COPIED_TIMEOUT_MS = 1500
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Card of tabbed code blocks with a copy button for the active tab, for
|
|
27
|
+
* showing several code samples or language variants side by side.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <CodeTabs
|
|
32
|
+
* tabs={[
|
|
33
|
+
* { label: 'npm', code: 'npm install @a4ui/core' },
|
|
34
|
+
* { label: 'pnpm', code: 'pnpm add @a4ui/core' },
|
|
35
|
+
* ]}
|
|
36
|
+
* />
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function CodeTabs(props: CodeTabsProps): JSX.Element {
|
|
40
|
+
const [activeIndex, setActiveIndex] = createSignal(0)
|
|
41
|
+
const [copied, setCopied] = createSignal(false)
|
|
42
|
+
|
|
43
|
+
const active = () => props.tabs[activeIndex()]
|
|
44
|
+
|
|
45
|
+
const copy = () => {
|
|
46
|
+
if (!navigator.clipboard) return
|
|
47
|
+
navigator.clipboard.writeText(active().code).then(() => {
|
|
48
|
+
setCopied(true)
|
|
49
|
+
setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div class={cn('overflow-hidden rounded-lg border border-border bg-card', props.class)}>
|
|
55
|
+
<div class="flex items-center justify-between border-b border-border pr-2">
|
|
56
|
+
<div class="flex" role="tablist">
|
|
57
|
+
<For each={props.tabs}>
|
|
58
|
+
{(tab, index) => (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
role="tab"
|
|
62
|
+
aria-selected={activeIndex() === index()}
|
|
63
|
+
class={cn(
|
|
64
|
+
'border-b-2 px-4 py-2 text-sm font-medium transition-colors',
|
|
65
|
+
activeIndex() === index()
|
|
66
|
+
? 'border-foreground text-foreground'
|
|
67
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
68
|
+
)}
|
|
69
|
+
onClick={() => setActiveIndex(index())}
|
|
70
|
+
>
|
|
71
|
+
{tab.label}
|
|
72
|
+
</button>
|
|
73
|
+
)}
|
|
74
|
+
</For>
|
|
75
|
+
</div>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
|
79
|
+
onClick={copy}
|
|
80
|
+
>
|
|
81
|
+
<Show
|
|
82
|
+
when={copied()}
|
|
83
|
+
fallback={
|
|
84
|
+
<>
|
|
85
|
+
<Copy class="h-3.5 w-3.5" />
|
|
86
|
+
Copy
|
|
87
|
+
</>
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
<Check class="h-3.5 w-3.5" />
|
|
91
|
+
Copied
|
|
92
|
+
</Show>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
<pre class="overflow-x-auto p-4 text-sm font-mono text-foreground">
|
|
96
|
+
<code>{active().code}</code>
|
|
97
|
+
</pre>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Detached, centered glass toolbar that condenses (tighter padding, slight
|
|
2
|
+
// scale-down, stronger blur) once the page scrolls past a small threshold —
|
|
3
|
+
// a "Liquid Glass"-style floating bar for persistent actions.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { createSignal, onCleanup, onMount } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
// Scroll distance (px) past which the bar condenses.
|
|
11
|
+
const CONDENSE_THRESHOLD = 24
|
|
12
|
+
|
|
13
|
+
const POSITION_CLASSES: Record<'top' | 'bottom', string> = {
|
|
14
|
+
top: 'top-4',
|
|
15
|
+
bottom: 'bottom-4',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FloatingToolbarProps {
|
|
19
|
+
children: JSX.Element
|
|
20
|
+
/** Which edge of the viewport the bar is anchored to. Defaults to `'bottom'`. */
|
|
21
|
+
position?: 'top' | 'bottom'
|
|
22
|
+
/** Shrink padding/scale and intensify the glass blur once scrolled past ~24px. Defaults to `true`. */
|
|
23
|
+
condenseOnScroll?: boolean
|
|
24
|
+
class?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fixed, horizontally-centered glass toolbar that floats above the page
|
|
29
|
+
* content. When `condenseOnScroll` is on (the default), it tightens its
|
|
30
|
+
* padding and scales down slightly once the user scrolls past a small
|
|
31
|
+
* threshold, mimicking a "Liquid Glass" detached bar. Reduced-motion aware:
|
|
32
|
+
* the condense change applies instantly, without a transition, when
|
|
33
|
+
* {@link motionReduced} is true.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <FloatingToolbar position="bottom">
|
|
38
|
+
* <IconButton label="Undo"><UndoIcon /></IconButton>
|
|
39
|
+
* <IconButton label="Redo"><RedoIcon /></IconButton>
|
|
40
|
+
* </FloatingToolbar>
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function FloatingToolbar(props: FloatingToolbarProps): JSX.Element {
|
|
44
|
+
const [condensed, setCondensed] = createSignal(false)
|
|
45
|
+
|
|
46
|
+
onMount(() => {
|
|
47
|
+
if (props.condenseOnScroll === false) return
|
|
48
|
+
|
|
49
|
+
const onScroll = () => {
|
|
50
|
+
setCondensed(window.scrollY > CONDENSE_THRESHOLD)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onScroll()
|
|
54
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
55
|
+
onCleanup(() => window.removeEventListener('scroll', onScroll))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
role="toolbar"
|
|
61
|
+
class={cn(
|
|
62
|
+
'card glass fixed left-1/2 z-40 flex -translate-x-1/2 items-center gap-1 rounded-full',
|
|
63
|
+
'border border-border/60 shadow-lg',
|
|
64
|
+
POSITION_CLASSES[props.position ?? 'bottom'],
|
|
65
|
+
!motionReduced() && 'transition-all duration-300 ease-out',
|
|
66
|
+
condensed()
|
|
67
|
+
? 'scale-95 gap-0.5 px-2 py-1 backdrop-blur-xl'
|
|
68
|
+
: 'scale-100 gap-1 px-3 py-2 backdrop-blur-md',
|
|
69
|
+
props.class,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{props.children}
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// InlineSelect — click-to-edit select. Shows the current value as plain text
|
|
2
|
+
// until clicked, then swaps to a `Select` for picking a new value; committing
|
|
3
|
+
// (or blurring/Escape) returns it to text. For status/priority-style editing
|
|
4
|
+
// inline in rows/tables.
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createEffect, createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { Select } from './Select'
|
|
10
|
+
|
|
11
|
+
export interface InlineSelectOption {
|
|
12
|
+
value: string
|
|
13
|
+
label: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface InlineSelectProps {
|
|
17
|
+
value: string
|
|
18
|
+
options: InlineSelectOption[]
|
|
19
|
+
onChange: (value: string) => void
|
|
20
|
+
placeholder?: string
|
|
21
|
+
class?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Edit-in-place select: renders the current value as a clickable chip until
|
|
26
|
+
* clicked, then swaps to a `Select` for picking a new value. Committing a
|
|
27
|
+
* choice returns it to text; blurring away or pressing Escape cancels back
|
|
28
|
+
* to the previous value. Built for status/priority-style editing inline in
|
|
29
|
+
* rows/tables.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <InlineSelect
|
|
34
|
+
* value={status()}
|
|
35
|
+
* options={[
|
|
36
|
+
* { value: 'todo', label: 'To do' },
|
|
37
|
+
* { value: 'doing', label: 'In progress' },
|
|
38
|
+
* { value: 'done', label: 'Done' },
|
|
39
|
+
* ]}
|
|
40
|
+
* onChange={setStatus}
|
|
41
|
+
* />
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function InlineSelect(props: InlineSelectProps): JSX.Element {
|
|
45
|
+
const [editing, setEditing] = createSignal(false)
|
|
46
|
+
let wrapperEl: HTMLDivElement | undefined
|
|
47
|
+
|
|
48
|
+
const selectedLabel = () =>
|
|
49
|
+
props.options.find((option) => option.value === props.value)?.label ?? props.placeholder ?? ''
|
|
50
|
+
|
|
51
|
+
const cancel = () => setEditing(false)
|
|
52
|
+
|
|
53
|
+
const commit = (value: string) => {
|
|
54
|
+
props.onChange(value)
|
|
55
|
+
setEditing(false)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Focus the <select> as soon as it mounts, so a click straight into edit
|
|
59
|
+
// mode is immediately usable from the keyboard (and blur-to-cancel below
|
|
60
|
+
// has something to blur from).
|
|
61
|
+
createEffect(() => {
|
|
62
|
+
if (editing()) wrapperEl?.querySelector('select')?.focus()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
ref={wrapperEl}
|
|
68
|
+
class={cn('inline-flex', props.class)}
|
|
69
|
+
onFocusOut={() => {
|
|
70
|
+
// Defer: swapping the trigger button for the <select> fires a transient
|
|
71
|
+
// focusout (relatedTarget is momentarily null) before the effect below
|
|
72
|
+
// moves focus onto the <select>. Re-check on the next tick so we only
|
|
73
|
+
// cancel when focus has genuinely left the wrapper.
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
if (wrapperEl && !wrapperEl.contains(document.activeElement)) cancel()
|
|
76
|
+
}, 0)
|
|
77
|
+
}}
|
|
78
|
+
onKeyDown={(ev) => {
|
|
79
|
+
if (ev.key === 'Escape') cancel()
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<Show
|
|
83
|
+
when={editing()}
|
|
84
|
+
fallback={
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
class="rounded px-1.5 py-0.5 text-left hover:bg-muted"
|
|
88
|
+
aria-label={`Edit — ${selectedLabel()}`}
|
|
89
|
+
onClick={() => setEditing(true)}
|
|
90
|
+
>
|
|
91
|
+
{selectedLabel()}
|
|
92
|
+
</button>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<Select value={props.value} onChange={commit}>
|
|
96
|
+
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
|
97
|
+
</Select>
|
|
98
|
+
</Show>
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|