@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.
- package/README.md +1 -1
- package/dist/elements.css +9 -0
- package/dist/full.css +9 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1707 -1638
- package/dist/ui/BlockEditor.d.ts +42 -0
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/ui/BlockEditor.tsx +139 -0
- package/src/ui/StreamingText.tsx +7 -2
|
@@ -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
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.
|
|
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
|
+
}
|
package/src/ui/StreamingText.tsx
CHANGED
|
@@ -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
|
-
|
|
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()
|