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