@a4ui/core 0.28.0 → 0.29.1

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,42 @@
1
+ import { JSX } from 'solid-js';
2
+ /** A single block in a {@link BlockEditor}. Content is supplied by the caller. */
3
+ export interface EditorBlock {
4
+ id: string;
5
+ type: string;
6
+ content: JSX.Element;
7
+ }
8
+ /** An option offered by the "add block" menu. */
9
+ export interface BlockTypeOption {
10
+ value: string;
11
+ label: string;
12
+ description?: string;
13
+ icon?: JSX.Element;
14
+ }
15
+ export interface BlockEditorProps {
16
+ blocks: EditorBlock[];
17
+ /** Called on reorder and on delete, with the new array. */
18
+ onChange: (blocks: EditorBlock[]) => void;
19
+ /** Block types offered by the "add" menu. */
20
+ blockTypes?: BlockTypeOption[];
21
+ /** Called when the user picks a type from the add menu; the caller creates + inserts the block. */
22
+ onAddBlock?: (type: string) => void;
23
+ class?: string;
24
+ }
25
+ /**
26
+ * Reorderable, deletable stack of content blocks with a slash-style insert
27
+ * menu — the block-primitives layer of a Notion-style editor, not a
28
+ * rich-text engine. Reordering and the drag handle come from {@link Sortable};
29
+ * the "add block" picker reuses {@link SlashMenu} filtered by a local search
30
+ * input.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <BlockEditor
35
+ * blocks={blocks()}
36
+ * onChange={setBlocks}
37
+ * blockTypes={[{ value: 'text', label: 'Text' }]}
38
+ * onAddBlock={(type) => setBlocks([...blocks(), { id: crypto.randomUUID(), type, content: <p>New</p> }])}
39
+ * />
40
+ * ```
41
+ */
42
+ export declare function BlockEditor(props: BlockEditorProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // import '@a4ui/core/styles.css'
9
9
  // import { Button, Card, Modal } from '@a4ui/core'
10
10
 
11
- export const A4UI_VERSION = '0.28.0'
11
+ export const A4UI_VERSION = '0.29.1'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -177,6 +177,7 @@ export { KpiBlock, type KpiBlockProps } from './ui/KpiBlock'
177
177
  export { MoneyActionButton, type MoneyActionButtonProps, type MoneyActionKind } from './ui/MoneyActionButton'
178
178
  export { Spaces, type SpacesProps, type Space } from './ui/Spaces'
179
179
  export { SideRail, type SideRailProps, type SideRailItem } from './ui/SideRail'
180
+ export { BlockEditor, type BlockEditorProps, type EditorBlock, type BlockTypeOption } from './ui/BlockEditor'
180
181
 
181
182
  // Motion components (src/ui) — animation primitives built on the `motion` engine
182
183
  // (adapted from motion.dev examples). All tree-shakeable and reduced-motion aware;
@@ -0,0 +1,139 @@
1
+ // BlockEditor — block-primitives shell for a Notion-style editor: a
2
+ // reorderable, deletable stack of blocks plus a slash-style "add block" menu.
3
+ // It does not know how to render or edit block content (that's the caller's
4
+ // job via `EditorBlock.content`) — it only owns ordering, removal, and
5
+ // insertion UI. Built entirely from existing primitives: Sortable (drag
6
+ // handle + reorder) and SlashMenu (filterable insert picker).
7
+ import { createSignal, Show, type JSX } from 'solid-js'
8
+ import { Plus, Trash2 } from 'lucide-solid'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { Button } from './Button'
12
+ import { Sortable } from './Sortable'
13
+ import { SlashMenu } from './SlashMenu'
14
+
15
+ /** A single block in a {@link BlockEditor}. Content is supplied by the caller. */
16
+ export interface EditorBlock {
17
+ id: string
18
+ type: string
19
+ content: JSX.Element
20
+ }
21
+
22
+ /** An option offered by the "add block" menu. */
23
+ export interface BlockTypeOption {
24
+ value: string
25
+ label: string
26
+ description?: string
27
+ icon?: JSX.Element
28
+ }
29
+
30
+ export interface BlockEditorProps {
31
+ blocks: EditorBlock[]
32
+ /** Called on reorder and on delete, with the new array. */
33
+ onChange: (blocks: EditorBlock[]) => void
34
+ /** Block types offered by the "add" menu. */
35
+ blockTypes?: BlockTypeOption[]
36
+ /** Called when the user picks a type from the add menu; the caller creates + inserts the block. */
37
+ onAddBlock?: (type: string) => void
38
+ class?: string
39
+ }
40
+
41
+ /**
42
+ * Reorderable, deletable stack of content blocks with a slash-style insert
43
+ * menu — the block-primitives layer of a Notion-style editor, not a
44
+ * rich-text engine. Reordering and the drag handle come from {@link Sortable};
45
+ * the "add block" picker reuses {@link SlashMenu} filtered by a local search
46
+ * input.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * <BlockEditor
51
+ * blocks={blocks()}
52
+ * onChange={setBlocks}
53
+ * blockTypes={[{ value: 'text', label: 'Text' }]}
54
+ * onAddBlock={(type) => setBlocks([...blocks(), { id: crypto.randomUUID(), type, content: <p>New</p> }])}
55
+ * />
56
+ * ```
57
+ */
58
+ export function BlockEditor(props: BlockEditorProps): JSX.Element {
59
+ const [menuOpen, setMenuOpen] = createSignal(false)
60
+ const [query, setQuery] = createSignal('')
61
+
62
+ const closeMenu = () => {
63
+ setMenuOpen(false)
64
+ setQuery('')
65
+ }
66
+
67
+ const handleDelete = (id: string) => {
68
+ props.onChange(props.blocks.filter((block) => block.id !== id))
69
+ }
70
+
71
+ const handleSelect = (type: string) => {
72
+ props.onAddBlock?.(type)
73
+ closeMenu()
74
+ }
75
+
76
+ return (
77
+ <div class={cn('flex flex-col gap-3', props.class)}>
78
+ <Show
79
+ when={props.blocks.length}
80
+ fallback={
81
+ <div class="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
82
+ No blocks yet
83
+ </div>
84
+ }
85
+ >
86
+ <Sortable
87
+ items={props.blocks}
88
+ itemKey={(block) => block.id}
89
+ onReorder={(items) => props.onChange(items)}
90
+ >
91
+ {(block) => (
92
+ <div class="group/block flex w-full items-center justify-between gap-2">
93
+ <div class="min-w-0 flex-1">{block.content}</div>
94
+ <button
95
+ type="button"
96
+ onClick={() => handleDelete(block.id)}
97
+ class="shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover/block:opacity-100"
98
+ aria-label="Delete block"
99
+ >
100
+ <Trash2 class="h-4 w-4" />
101
+ </button>
102
+ </div>
103
+ )}
104
+ </Sortable>
105
+ </Show>
106
+
107
+ <div
108
+ onKeyDown={(e) => {
109
+ if (e.key === 'Escape') closeMenu()
110
+ }}
111
+ >
112
+ <Button
113
+ type="button"
114
+ variant="outline"
115
+ onClick={() => setMenuOpen((open) => !open)}
116
+ aria-expanded={menuOpen()}
117
+ >
118
+ <Plus class="mr-1 h-4 w-4" />
119
+ Add block
120
+ </Button>
121
+
122
+ <Show when={menuOpen()}>
123
+ <div class="mt-2 flex flex-col gap-2">
124
+ <input
125
+ type="text"
126
+ autofocus
127
+ value={query()}
128
+ onInput={(e) => setQuery(e.currentTarget.value)}
129
+ placeholder="Search block types..."
130
+ class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
131
+ aria-label="Filter block types"
132
+ />
133
+ <SlashMenu items={props.blockTypes ?? []} query={query()} onSelect={handleSelect} />
134
+ </div>
135
+ </Show>
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -2,7 +2,7 @@
2
2
  // added tail at `speed` chars/sec (via a rAF loop) rather than snapping
3
3
  // straight to the new length, with a blinking caret while more is expected.
4
4
  // Built for AI answer UIs where `text` grows incrementally as tokens arrive.
5
- import { createEffect, createSignal, onCleanup, type JSX } from 'solid-js'
5
+ import { createEffect, createSignal, onCleanup, untrack, type JSX } from 'solid-js'
6
6
 
7
7
  import { cn } from '../lib/cn'
8
8
  import { motionReduced } from '../lib/motion'
@@ -48,7 +48,12 @@ export function StreamingText(props: StreamingTextProps): JSX.Element {
48
48
  createEffect(() => {
49
49
  const text = props.text
50
50
  const isAppend = text.startsWith(prevText)
51
- const from = isAppend ? revealed() : 0
51
+ // `untrack`: read the current revealed count WITHOUT making this effect
52
+ // depend on `revealed` — otherwise the rAF loop's own `setRevealed` would
53
+ // re-trigger this effect every frame, cancelling and restarting the loop
54
+ // (a stuttery, "janky" reveal). The effect should only re-run when the
55
+ // incoming `text`/`streaming`/`speed` change.
56
+ const from = isAppend ? untrack(revealed) : 0
52
57
  prevText = text
53
58
 
54
59
  stop()