@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.
Files changed (40) hide show
  1. package/README.md +11 -11
  2. package/dist/elements.css +145 -0
  3. package/dist/full.css +145 -0
  4. package/dist/index.d.ts +17 -1
  5. package/dist/index.js +3225 -2582
  6. package/dist/lib/createOptimistic.d.ts +17 -0
  7. package/dist/lib/easing.d.ts +42 -0
  8. package/dist/lib/viewTransition.d.ts +8 -0
  9. package/dist/ui/ArtifactPanel.d.ts +29 -0
  10. package/dist/ui/CategoryStrip.d.ts +24 -0
  11. package/dist/ui/ChatThread.d.ts +24 -0
  12. package/dist/ui/Citation.d.ts +44 -0
  13. package/dist/ui/CodeTabs.d.ts +27 -0
  14. package/dist/ui/FloatingToolbar.d.ts +26 -0
  15. package/dist/ui/InlineSelect.d.ts +33 -0
  16. package/dist/ui/MasterDetail.d.ts +27 -0
  17. package/dist/ui/Message.d.ts +28 -0
  18. package/dist/ui/PageTransition.d.ts +25 -0
  19. package/dist/ui/PillSearch.d.ts +38 -0
  20. package/dist/ui/PromptComposer.d.ts +34 -0
  21. package/dist/ui/StreamingText.d.ts +26 -0
  22. package/package.json +1 -1
  23. package/preset.js +20 -0
  24. package/src/index.ts +21 -1
  25. package/src/lib/createOptimistic.ts +43 -0
  26. package/src/lib/easing.ts +43 -0
  27. package/src/lib/viewTransition.ts +32 -0
  28. package/src/ui/ArtifactPanel.tsx +88 -0
  29. package/src/ui/CategoryStrip.tsx +80 -0
  30. package/src/ui/ChatThread.tsx +59 -0
  31. package/src/ui/Citation.tsx +97 -0
  32. package/src/ui/CodeTabs.tsx +100 -0
  33. package/src/ui/FloatingToolbar.tsx +75 -0
  34. package/src/ui/InlineSelect.tsx +101 -0
  35. package/src/ui/MasterDetail.tsx +117 -0
  36. package/src/ui/Message.tsx +69 -0
  37. package/src/ui/PageTransition.tsx +90 -0
  38. package/src/ui/PillSearch.tsx +86 -0
  39. package/src/ui/PromptComposer.tsx +138 -0
  40. 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
+ }