@a4ui/core 0.24.2 → 0.25.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/{NumberInput-DcJlXNKq.js → NumberInput-BQhVucw-.js} +4 -4
- package/dist/commerce.js +1 -1
- package/dist/elements.css +127 -7
- package/dist/elements.iife.js +1 -1
- package/dist/elements.js +4 -4
- package/dist/full.css +127 -7
- package/dist/index.d.ts +12 -1
- package/dist/index.js +3072 -2624
- package/dist/lib/createOptimistic.d.ts +17 -0
- package/dist/lib/viewTransition.d.ts +8 -0
- package/dist/ui/ArtifactPanel.d.ts +29 -0
- package/dist/ui/ChatThread.d.ts +24 -0
- package/dist/ui/Citation.d.ts +44 -0
- package/dist/ui/FloatingToolbar.d.ts +26 -0
- package/dist/ui/InlineSelect.d.ts +33 -0
- package/dist/ui/Message.d.ts +28 -0
- package/dist/ui/PageTransition.d.ts +25 -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 +6 -1
- package/src/index.ts +14 -1
- package/src/lib/createOptimistic.ts +43 -0
- package/src/lib/viewTransition.ts +32 -0
- package/src/ui/ArtifactPanel.tsx +88 -0
- package/src/ui/Badge.tsx +7 -4
- package/src/ui/ChatThread.tsx +59 -0
- package/src/ui/Citation.tsx +97 -0
- package/src/ui/FloatingToolbar.tsx +75 -0
- package/src/ui/InlineSelect.tsx +101 -0
- package/src/ui/Message.tsx +69 -0
- package/src/ui/PageTransition.tsx +90 -0
- package/src/ui/PromptComposer.tsx +138 -0
- package/src/ui/StreamingText.tsx +98 -0
|
@@ -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,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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Message — one chat message row, full-width (no bubble). Rich content is
|
|
2
|
+
// passed as children so the caller can render markdown, code blocks, lists,
|
|
3
|
+
// etc. inside the readable prose block.
|
|
4
|
+
import { type JSX, Match, Show, Switch } from 'solid-js'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/cn'
|
|
7
|
+
|
|
8
|
+
/** Who sent a {@link Message}; drives its subtle visual treatment. */
|
|
9
|
+
export type ChatRole = 'user' | 'assistant' | 'system'
|
|
10
|
+
|
|
11
|
+
export interface MessageProps {
|
|
12
|
+
role: ChatRole
|
|
13
|
+
children: JSX.Element
|
|
14
|
+
/** Display name shown in the header line. Omitted for `system` messages. */
|
|
15
|
+
author?: string
|
|
16
|
+
/** Small avatar node, e.g. an icon or `<Avatar/>`. Omitted for `system` messages. */
|
|
17
|
+
avatar?: JSX.Element
|
|
18
|
+
/** Preformatted timestamp string, e.g. `'2:45 PM'`. */
|
|
19
|
+
timestamp?: string
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Full-width chat message row with no bubble: an optional avatar/author
|
|
25
|
+
* header line followed by a prose content block. Role is differentiated with
|
|
26
|
+
* subtle, token-based treatment only — `assistant` gets a faint muted panel,
|
|
27
|
+
* `user` stays plain, `system` is a small centered note.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <Message role="assistant" author="Nova" timestamp="2:45 PM">
|
|
32
|
+
* <p>Here's the summary you asked for.</p>
|
|
33
|
+
* </Message>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function Message(props: MessageProps): JSX.Element {
|
|
37
|
+
return (
|
|
38
|
+
<Switch>
|
|
39
|
+
<Match when={props.role === 'system'}>
|
|
40
|
+
<div class={cn('text-center text-xs text-muted-foreground italic', props.class)}>
|
|
41
|
+
{props.children}
|
|
42
|
+
</div>
|
|
43
|
+
</Match>
|
|
44
|
+
<Match when={true}>
|
|
45
|
+
<div
|
|
46
|
+
class={cn(
|
|
47
|
+
'flex flex-col gap-1',
|
|
48
|
+
props.role === 'assistant' && 'rounded-xl bg-muted/40 p-3',
|
|
49
|
+
props.role === 'user' && 'rounded-xl bg-primary/5 p-3',
|
|
50
|
+
props.class,
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<Show when={props.author || props.avatar || props.timestamp}>
|
|
54
|
+
<div class="flex items-center gap-2">
|
|
55
|
+
{props.avatar}
|
|
56
|
+
<Show when={props.author}>
|
|
57
|
+
<span class="text-sm font-medium text-foreground">{props.author}</span>
|
|
58
|
+
</Show>
|
|
59
|
+
<Show when={props.timestamp}>
|
|
60
|
+
<span class="text-xs text-muted-foreground">{props.timestamp}</span>
|
|
61
|
+
</Show>
|
|
62
|
+
</div>
|
|
63
|
+
</Show>
|
|
64
|
+
<div class="space-y-2 text-sm leading-relaxed text-foreground">{props.children}</div>
|
|
65
|
+
</div>
|
|
66
|
+
</Match>
|
|
67
|
+
</Switch>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Keyed cross-fade / slide transition for route or view swaps: change `key`
|
|
2
|
+
// and the outgoing content fades (and optionally slides) out, then the new
|
|
3
|
+
// `children` fades in. Driven purely by the `key` prop — no routing library
|
|
4
|
+
// involved. Uses Motion's `animate` (opacity + `y`, transform/opacity only)
|
|
5
|
+
// like `animateIn` in lib/motion.ts, and swaps instantly under reduced motion.
|
|
6
|
+
import { createEffect, createSignal, onCleanup, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
10
|
+
|
|
11
|
+
/** Transition style for {@link PageTransition}. `slide` adds a small vertical shift. */
|
|
12
|
+
export type PageTransitionMode = 'fade' | 'slide'
|
|
13
|
+
|
|
14
|
+
export interface PageTransitionProps {
|
|
15
|
+
/** Change this value (e.g. a route or view id) to trigger a transition. */
|
|
16
|
+
key: string | number
|
|
17
|
+
children: JSX.Element
|
|
18
|
+
/** `fade` = opacity only; `slide` = opacity + small vertical shift. @default 'fade' */
|
|
19
|
+
mode?: PageTransitionMode
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SLIDE_Y = 12
|
|
24
|
+
const OUT_DURATION = 0.16
|
|
25
|
+
const IN_DURATION = 0.22
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cross-fades (or slides) its `children` out and in whenever `key` changes —
|
|
29
|
+
* for route/view transitions. Driven purely by the `key` prop; pair it with a
|
|
30
|
+
* router's current path or any view-switch signal. Swaps instantly under
|
|
31
|
+
* reduced motion.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* <PageTransition key={activeTab()} mode="slide">
|
|
36
|
+
* <TabContent id={activeTab()} />
|
|
37
|
+
* </PageTransition>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function PageTransition(props: PageTransitionProps): JSX.Element {
|
|
41
|
+
const [content, setContent] = createSignal(props.children)
|
|
42
|
+
let wrapEl: HTMLDivElement | undefined
|
|
43
|
+
let controls: ReturnType<typeof animate> | undefined
|
|
44
|
+
let previousKey = props.key
|
|
45
|
+
let mounted = false
|
|
46
|
+
|
|
47
|
+
createEffect(() => {
|
|
48
|
+
const nextKey = props.key
|
|
49
|
+
const nextChildren = props.children
|
|
50
|
+
|
|
51
|
+
if (!mounted) {
|
|
52
|
+
mounted = true
|
|
53
|
+
previousKey = nextKey
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (nextKey === previousKey) {
|
|
58
|
+
// Children changed without a key change: sync in place, no transition.
|
|
59
|
+
setContent(() => nextChildren)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
previousKey = nextKey
|
|
63
|
+
|
|
64
|
+
const el = wrapEl
|
|
65
|
+
const y = (props.mode ?? 'fade') === 'slide' ? SLIDE_Y : 0
|
|
66
|
+
|
|
67
|
+
controls?.stop()
|
|
68
|
+
|
|
69
|
+
if (!el || motionReduced()) {
|
|
70
|
+
setContent(() => nextChildren)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
controls = animate(el, { opacity: [1, 0], y: [0, -y] }, { duration: OUT_DURATION, ease: 'easeIn' })
|
|
75
|
+
controls.finished
|
|
76
|
+
.then(() => {
|
|
77
|
+
setContent(() => nextChildren)
|
|
78
|
+
controls = animate(el, { opacity: [0, 1], y: [y, 0] }, { duration: IN_DURATION, ease: 'easeOut' })
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
onCleanup(() => controls?.stop())
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div ref={wrapEl} class={cn(props.class)}>
|
|
87
|
+
{content()}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The input area for a chat/AI UI: an auto-growing textarea in a glass card,
|
|
2
|
+
// removable attachment chips, and a bottom bar with an optional attach button
|
|
3
|
+
// and a Send button. Submits on Cmd/Ctrl+Enter or the Send click.
|
|
4
|
+
import { Paperclip, SendHorizontal, X } from 'lucide-solid'
|
|
5
|
+
import { createEffect, For, type JSX, Show } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { Button } from './Button'
|
|
9
|
+
|
|
10
|
+
export interface PromptComposerProps {
|
|
11
|
+
value: string
|
|
12
|
+
onInput: (value: string) => void
|
|
13
|
+
onSubmit: (value: string) => void
|
|
14
|
+
placeholder?: string
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
/** Small helper text under the field, e.g. model name or a character count. */
|
|
17
|
+
hint?: JSX.Element
|
|
18
|
+
/** Attachment labels/filenames shown as removable chips above the input. */
|
|
19
|
+
attachments?: string[]
|
|
20
|
+
onRemoveAttachment?: (index: number) => void
|
|
21
|
+
/** When set, shows a paperclip button that calls this on click. */
|
|
22
|
+
onAttach?: () => void
|
|
23
|
+
class?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The input area for a chat/AI UI: an auto-growing textarea inside a glass
|
|
28
|
+
* card, removable attachment chips, and a bottom bar with an optional attach
|
|
29
|
+
* button and a Send button. Fully controlled — the parent owns `value`.
|
|
30
|
+
* Submits on **Cmd/Ctrl+Enter** or the Send button click.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* const [value, setValue] = createSignal('')
|
|
35
|
+
* <PromptComposer
|
|
36
|
+
* value={value()}
|
|
37
|
+
* onInput={setValue}
|
|
38
|
+
* onSubmit={(v) => { sendMessage(v); setValue('') }}
|
|
39
|
+
* placeholder="Ask anything…"
|
|
40
|
+
* />
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function PromptComposer(props: PromptComposerProps): JSX.Element {
|
|
44
|
+
let textareaRef: HTMLTextAreaElement | undefined
|
|
45
|
+
|
|
46
|
+
// Auto-grow: reset height then snap to scrollHeight, capped by max-h via CSS.
|
|
47
|
+
createEffect(() => {
|
|
48
|
+
void props.value
|
|
49
|
+
if (!textareaRef) return
|
|
50
|
+
textareaRef.style.height = 'auto'
|
|
51
|
+
textareaRef.style.height = `${textareaRef.scrollHeight}px`
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const canSubmit = (): boolean => !props.disabled && props.value.trim().length > 0
|
|
55
|
+
|
|
56
|
+
const submit = (): void => {
|
|
57
|
+
if (!canSubmit()) return
|
|
58
|
+
props.onSubmit(props.value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
62
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
63
|
+
event.preventDefault()
|
|
64
|
+
submit()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
class={cn(
|
|
71
|
+
'card w-full rounded-2xl border border-border bg-card p-3 text-card-foreground shadow-lg',
|
|
72
|
+
props.class,
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
<Show when={(props.attachments?.length ?? 0) > 0}>
|
|
76
|
+
<div class="mb-2 flex flex-wrap gap-1.5">
|
|
77
|
+
<For each={props.attachments}>
|
|
78
|
+
{(label, i) => (
|
|
79
|
+
<span class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground ring-1 ring-inset ring-border">
|
|
80
|
+
<span class="max-w-[12rem] truncate">{label}</span>
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
aria-label={`Remove attachment ${label}`}
|
|
84
|
+
onClick={() => props.onRemoveAttachment?.(i())}
|
|
85
|
+
class="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
|
86
|
+
>
|
|
87
|
+
<X class="h-3 w-3" aria-hidden="true" />
|
|
88
|
+
</button>
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</For>
|
|
92
|
+
</div>
|
|
93
|
+
</Show>
|
|
94
|
+
|
|
95
|
+
<textarea
|
|
96
|
+
ref={textareaRef}
|
|
97
|
+
aria-label="Message"
|
|
98
|
+
value={props.value}
|
|
99
|
+
placeholder={props.placeholder ?? 'Send a message…'}
|
|
100
|
+
disabled={props.disabled}
|
|
101
|
+
rows={1}
|
|
102
|
+
onInput={(ev) => props.onInput(ev.currentTarget.value)}
|
|
103
|
+
onKeyDown={handleKeyDown}
|
|
104
|
+
class="a4-field max-h-64 min-h-[24px] w-full resize-none border-0 bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
105
|
+
/>
|
|
106
|
+
|
|
107
|
+
<Show when={props.hint}>
|
|
108
|
+
<p class="mt-1 text-xs text-muted-foreground">{props.hint}</p>
|
|
109
|
+
</Show>
|
|
110
|
+
|
|
111
|
+
<div class="mt-2 flex items-center justify-between gap-2">
|
|
112
|
+
<Show when={props.onAttach} fallback={<span />}>
|
|
113
|
+
<Button
|
|
114
|
+
type="button"
|
|
115
|
+
variant="ghost"
|
|
116
|
+
aria-label="Attach file"
|
|
117
|
+
disabled={props.disabled}
|
|
118
|
+
onClick={() => props.onAttach?.()}
|
|
119
|
+
class="px-2"
|
|
120
|
+
>
|
|
121
|
+
<Paperclip class="h-4 w-4" aria-hidden="true" />
|
|
122
|
+
</Button>
|
|
123
|
+
</Show>
|
|
124
|
+
|
|
125
|
+
<Button
|
|
126
|
+
type="button"
|
|
127
|
+
variant="primary"
|
|
128
|
+
aria-label="Send message"
|
|
129
|
+
disabled={!canSubmit()}
|
|
130
|
+
onClick={submit}
|
|
131
|
+
class="px-2"
|
|
132
|
+
>
|
|
133
|
+
<SendHorizontal class="h-4 w-4" aria-hidden="true" />
|
|
134
|
+
</Button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Streams `text` in as new characters are appended — eases in the newly
|
|
2
|
+
// added tail at `speed` chars/sec (via a rAF loop) rather than snapping
|
|
3
|
+
// straight to the new length, with a blinking caret while more is expected.
|
|
4
|
+
// Built for AI answer UIs where `text` grows incrementally as tokens arrive.
|
|
5
|
+
import { createEffect, createSignal, onCleanup, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface StreamingTextProps {
|
|
11
|
+
/** The (possibly growing) full text so far. */
|
|
12
|
+
text: string
|
|
13
|
+
/** Whether more text is still coming; shows the blinking caret while `true`. @default true */
|
|
14
|
+
streaming?: boolean
|
|
15
|
+
/** Reveal speed, in characters per second, for newly appended text. @default 120 */
|
|
16
|
+
speed?: number
|
|
17
|
+
class?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reveals `text` as it "streams" in: when `text` grows, the newly appended
|
|
22
|
+
* characters ease in at `speed` chars/sec instead of snapping into place,
|
|
23
|
+
* with a blinking caret while `streaming`. Already-revealed characters never
|
|
24
|
+
* re-animate — only the new tail is eased in, so appending to `text` doesn't
|
|
25
|
+
* replay the whole reveal. Skips the reveal (shows the full text at once)
|
|
26
|
+
* under reduced motion — the caret still blinks while `streaming` in that
|
|
27
|
+
* case. `streaming={false}` shows the full text immediately with no caret,
|
|
28
|
+
* for the final/settled answer.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <StreamingText text={answer()} streaming={!done()} />
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function StreamingText(props: StreamingTextProps): JSX.Element {
|
|
36
|
+
// eslint-disable-next-line solid/reactivity -- one-time seed; the effect below re-reads props.text on each change
|
|
37
|
+
const [revealed, setRevealed] = createSignal(motionReduced() ? props.text.length : 0)
|
|
38
|
+
|
|
39
|
+
let raf: number | undefined
|
|
40
|
+
let prevText = ''
|
|
41
|
+
|
|
42
|
+
const stop = (): void => {
|
|
43
|
+
if (raf === undefined) return
|
|
44
|
+
cancelAnimationFrame(raf)
|
|
45
|
+
raf = undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
createEffect(() => {
|
|
49
|
+
const text = props.text
|
|
50
|
+
const isAppend = text.startsWith(prevText)
|
|
51
|
+
const from = isAppend ? revealed() : 0
|
|
52
|
+
prevText = text
|
|
53
|
+
|
|
54
|
+
stop()
|
|
55
|
+
|
|
56
|
+
if (motionReduced() || (props.streaming ?? true) === false) {
|
|
57
|
+
setRevealed(text.length)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (from >= text.length) {
|
|
62
|
+
setRevealed(text.length)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const speed = props.speed ?? 120
|
|
67
|
+
let count = from
|
|
68
|
+
let last: number | undefined
|
|
69
|
+
|
|
70
|
+
const step = (now: number): void => {
|
|
71
|
+
if (last === undefined) last = now
|
|
72
|
+
const elapsed = (now - last) / 1000
|
|
73
|
+
last = now
|
|
74
|
+
count = Math.min(text.length, count + elapsed * speed)
|
|
75
|
+
setRevealed(Math.floor(count))
|
|
76
|
+
raf = count < text.length ? requestAnimationFrame(step) : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setRevealed(from)
|
|
80
|
+
raf = requestAnimationFrame(step)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
onCleanup(stop)
|
|
84
|
+
|
|
85
|
+
const display = (): string => props.text.slice(0, revealed())
|
|
86
|
+
const showCaret = (): boolean => props.streaming ?? true
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<span class={cn('whitespace-pre-wrap', props.class)}>
|
|
90
|
+
{display()}
|
|
91
|
+
{showCaret() && (
|
|
92
|
+
<span aria-hidden="true" class="animate-pulse text-current motion-reduce:animate-none">
|
|
93
|
+
▍
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</span>
|
|
97
|
+
)
|
|
98
|
+
}
|