@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.
@@ -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
+ }