@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,117 @@
|
|
|
1
|
+
// List + detail-pane layout with keyboard navigation, e.g. an inbox or a
|
|
2
|
+
// command-result view. The list is a `listbox`; Up/Down move the selection,
|
|
3
|
+
// Home/End jump to the first/last item. Controlled (`selectedId`+`onSelect`)
|
|
4
|
+
// or uncontrolled (internal signal, defaults to the first item).
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createMemo, createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface MasterDetailItem {
|
|
11
|
+
id: string
|
|
12
|
+
label: string
|
|
13
|
+
meta?: JSX.Element
|
|
14
|
+
detail: JSX.Element
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MasterDetailProps {
|
|
18
|
+
items: MasterDetailItem[]
|
|
19
|
+
/** Controlled selection. Uncontrolled (internal signal, defaults to the first item) when omitted. */
|
|
20
|
+
selectedId?: string
|
|
21
|
+
onSelect?: (id: string) => void
|
|
22
|
+
class?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List + detail-pane layout: a scrollable list of items on the left, the
|
|
27
|
+
* selected item's detail content on the right. Arrow keys (and Home/End)
|
|
28
|
+
* move the selection within the list.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <MasterDetail
|
|
33
|
+
* items={messages.map((m) => ({ id: m.id, label: m.subject, meta: <Badge>{m.from}</Badge>, detail: <MessageBody message={m} /> }))}
|
|
34
|
+
* />
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function MasterDetail(props: MasterDetailProps): JSX.Element {
|
|
38
|
+
const [internalId, setInternalId] = createSignal<string | undefined>(props.items[0]?.id)
|
|
39
|
+
|
|
40
|
+
const selectedId = createMemo(() => props.selectedId ?? internalId())
|
|
41
|
+
|
|
42
|
+
const select = (id: string) => {
|
|
43
|
+
if (props.selectedId === undefined) setInternalId(id)
|
|
44
|
+
props.onSelect?.(id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const selectedItem = createMemo(() => props.items.find((item) => item.id === selectedId()))
|
|
48
|
+
|
|
49
|
+
let listEl: HTMLDivElement | undefined
|
|
50
|
+
|
|
51
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
52
|
+
const items = props.items
|
|
53
|
+
if (items.length === 0) return
|
|
54
|
+
const currentIndex = items.findIndex((item) => item.id === selectedId())
|
|
55
|
+
|
|
56
|
+
let nextIndex: number
|
|
57
|
+
if (event.key === 'ArrowDown')
|
|
58
|
+
nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, items.length - 1)
|
|
59
|
+
else if (event.key === 'ArrowUp') nextIndex = currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0)
|
|
60
|
+
else if (event.key === 'Home') nextIndex = 0
|
|
61
|
+
else if (event.key === 'End') nextIndex = items.length - 1
|
|
62
|
+
else return
|
|
63
|
+
|
|
64
|
+
event.preventDefault()
|
|
65
|
+
const next = items[nextIndex]
|
|
66
|
+
if (!next) return
|
|
67
|
+
select(next.id)
|
|
68
|
+
listEl?.querySelector<HTMLElement>(`[data-item-id="${next.id}"]`)?.focus()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div class={cn('grid grid-cols-1 gap-4 sm:grid-cols-[16rem_1fr]', props.class)}>
|
|
73
|
+
<div
|
|
74
|
+
ref={listEl}
|
|
75
|
+
role="listbox"
|
|
76
|
+
aria-label="Items"
|
|
77
|
+
tabindex={0}
|
|
78
|
+
onKeyDown={onKeyDown}
|
|
79
|
+
class="flex max-h-96 flex-col gap-1 overflow-y-auto rounded-xl border border-border bg-card p-1 text-card-foreground sm:max-h-[28rem]"
|
|
80
|
+
>
|
|
81
|
+
<For each={props.items}>
|
|
82
|
+
{(item) => {
|
|
83
|
+
const isSelected = () => item.id === selectedId()
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
role="option"
|
|
88
|
+
aria-selected={isSelected()}
|
|
89
|
+
data-item-id={item.id}
|
|
90
|
+
tabindex={-1}
|
|
91
|
+
onClick={() => select(item.id)}
|
|
92
|
+
class={cn(
|
|
93
|
+
'flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors',
|
|
94
|
+
isSelected() ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<span class="truncate">{item.label}</span>
|
|
98
|
+
<Show when={item.meta}>{item.meta}</Show>
|
|
99
|
+
</button>
|
|
100
|
+
)
|
|
101
|
+
}}
|
|
102
|
+
</For>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="rounded-xl border border-border bg-card p-5 text-card-foreground">
|
|
105
|
+
{/* keyed: re-render the detail when the selected item changes (a plain
|
|
106
|
+
Show wouldn't re-run its child when `when` stays truthy across items). */}
|
|
107
|
+
<Show
|
|
108
|
+
when={selectedItem()}
|
|
109
|
+
keyed
|
|
110
|
+
fallback={<p class="text-sm text-muted-foreground">Select an item to see its details.</p>}
|
|
111
|
+
>
|
|
112
|
+
{(item) => item.detail}
|
|
113
|
+
</Show>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -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,86 @@
|
|
|
1
|
+
// Compact multi-parameter search entry: a single rounded-full bar that
|
|
2
|
+
// compresses several fields (e.g. Where / When / Who) into clickable
|
|
3
|
+
// segments, ending in a round primary search button.
|
|
4
|
+
import type { JSX } from 'solid-js'
|
|
5
|
+
import { For } from 'solid-js'
|
|
6
|
+
import { Search } from 'lucide-solid'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
|
|
10
|
+
export interface PillField {
|
|
11
|
+
/** Stable identifier passed back via {@link PillSearchProps.onFieldClick}. */
|
|
12
|
+
key: string
|
|
13
|
+
/** Small label shown above the value, e.g. `"Where"`. */
|
|
14
|
+
label: string
|
|
15
|
+
/** Current value shown below the label. Falls back to `placeholder` when unset. */
|
|
16
|
+
value?: string
|
|
17
|
+
/** Muted hint shown when `value` is unset. */
|
|
18
|
+
placeholder?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PillSearchProps {
|
|
22
|
+
fields: PillField[]
|
|
23
|
+
/** Called with the clicked field's `key`. */
|
|
24
|
+
onFieldClick?: (key: string) => void
|
|
25
|
+
/** Called when the round search button is pressed. */
|
|
26
|
+
onSearch?: () => void
|
|
27
|
+
class?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Rounded-full search bar that packs several fields into segments with a
|
|
32
|
+
* round primary search button — the classic "Where / When / Who" pattern
|
|
33
|
+
* compressed into one compact control.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <PillSearch
|
|
38
|
+
* fields={[
|
|
39
|
+
* { key: 'where', label: 'Where', value: 'Lisbon' },
|
|
40
|
+
* { key: 'when', label: 'When', placeholder: 'Add dates' },
|
|
41
|
+
* { key: 'who', label: 'Who', placeholder: 'Add guests' },
|
|
42
|
+
* ]}
|
|
43
|
+
* onFieldClick={(key) => console.log('open', key)}
|
|
44
|
+
* onSearch={() => console.log('search')}
|
|
45
|
+
* />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function PillSearch(props: PillSearchProps): JSX.Element {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
class={cn(
|
|
52
|
+
'inline-flex max-w-full items-center overflow-x-auto rounded-full border border-border bg-card shadow-sm',
|
|
53
|
+
props.class,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<For each={props.fields}>
|
|
57
|
+
{(field, index) => (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
aria-label={field.label}
|
|
61
|
+
onClick={() => props.onFieldClick?.(field.key)}
|
|
62
|
+
class={cn(
|
|
63
|
+
'flex min-w-[6.5rem] shrink-0 flex-col items-start px-4 py-2 text-left transition-colors duration-150 hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring',
|
|
64
|
+
index() === 0 ? 'rounded-l-full' : 'border-l border-border',
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<span class="text-xs font-medium text-muted-foreground">{field.label}</span>
|
|
68
|
+
<span class={cn('text-sm', field.value ? 'text-foreground' : 'text-muted-foreground')}>
|
|
69
|
+
{field.value ?? field.placeholder}
|
|
70
|
+
</span>
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
</For>
|
|
74
|
+
<div class="shrink-0 py-1.5 pl-1 pr-1.5">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-label="Search"
|
|
78
|
+
onClick={() => props.onSearch?.()}
|
|
79
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[color,background-color,transform] duration-150 hover:bg-primary/90 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
80
|
+
>
|
|
81
|
+
<Search class="h-4 w-4" aria-hidden="true" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|