@saena-io/content 0.1.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.
- package/drizzle/0000_tranquil_golden_guardian.sql +16 -0
- package/drizzle/0001_little_darwin.sql +12 -0
- package/drizzle/meta/0000_snapshot.json +94 -0
- package/drizzle/meta/0001_snapshot.json +197 -0
- package/drizzle/meta/_journal.json +20 -0
- package/package.json +41 -0
- package/src/admin/client.ts +43 -0
- package/src/admin/collection-manager.tsx +328 -0
- package/src/admin/editors.tsx +284 -0
- package/src/admin/field-registry.tsx +33 -0
- package/src/admin/form-engine.tsx +394 -0
- package/src/admin/icons.tsx +45 -0
- package/src/admin/index.tsx +343 -0
- package/src/contract.ts +57 -0
- package/src/db/schema.ts +70 -0
- package/src/define.ts +92 -0
- package/src/field/builtins.ts +524 -0
- package/src/field/field.test.ts +292 -0
- package/src/field/index.ts +49 -0
- package/src/field/infer.ts +10 -0
- package/src/field/keys.ts +69 -0
- package/src/field/registry.ts +32 -0
- package/src/field/types.ts +83 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +108 -0
- package/src/public/index.tsx +133 -0
- package/src/service.ts +375 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { Button } from '@saena-io/ui/components/button';
|
|
2
|
+
import {
|
|
3
|
+
Collapsible,
|
|
4
|
+
CollapsibleContent,
|
|
5
|
+
CollapsibleTrigger,
|
|
6
|
+
} from '@saena-io/ui/components/collapsible';
|
|
7
|
+
import { NativeSelect, NativeSelectOption } from '@saena-io/ui/components/native-select';
|
|
8
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
9
|
+
import { type DragEvent, useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { type FieldDecl, type FieldPath, fieldType } from '../field';
|
|
11
|
+
import { getEditor } from './field-registry';
|
|
12
|
+
import { Chevron, GripVertical } from './icons';
|
|
13
|
+
|
|
14
|
+
// The recursive form generator (ADR-0008): one renderer turns any field schema into editing UI. object →
|
|
15
|
+
// labelled children (respecting `visible`), list → item editors with add/remove/reorder, union → a discriminant
|
|
16
|
+
// picker + the active variant's fields, leaf → the registered editor (built-in or custom — one mechanism).
|
|
17
|
+
// Value is a single immutable tree held by the section editor; every onChange rebuilds along the path.
|
|
18
|
+
|
|
19
|
+
interface ViewProps {
|
|
20
|
+
field: FieldDecl;
|
|
21
|
+
value: unknown;
|
|
22
|
+
onChange: (next: unknown) => void;
|
|
23
|
+
path: FieldPath;
|
|
24
|
+
locale: string;
|
|
25
|
+
/** The parent object's value — leaf editors read sibling fields from it (e.g. slug-from-title). */
|
|
26
|
+
siblings?: Record<string, unknown>;
|
|
27
|
+
/** True when this editor is a list item's value — its row already provides remove/reorder, so a leaf editor
|
|
28
|
+
* (e.g. image) should suppress its own removal affordance to avoid a duplicate delete. */
|
|
29
|
+
inList?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A stable id for a new list item (no dots — it becomes an i18n key segment). */
|
|
33
|
+
function newId(): string {
|
|
34
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
35
|
+
return (uuid ?? `id-${Date.now()}-${Math.floor(Math.random() * 1e6)}`).replace(
|
|
36
|
+
/[^a-zA-Z0-9]/g,
|
|
37
|
+
'',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ObjectView({ field, value, onChange, path, locale }: ViewProps) {
|
|
42
|
+
const cfg = field.cfg as { children: Record<string, FieldDecl>; group?: boolean };
|
|
43
|
+
const obj = (value ?? {}) as Record<string, unknown>;
|
|
44
|
+
// A `group` object renders boxed (muted bg, rounded) to show its fields belong together — lighter than a
|
|
45
|
+
// card, and only opt-in (the section root + plain objects stay flat). Children pass `obj` as siblings so
|
|
46
|
+
// leaf editors can read each other (e.g. slug-from-title).
|
|
47
|
+
return (
|
|
48
|
+
<div className={cn('flex flex-col gap-3', cfg.group && 'rounded-md bg-muted/40 p-3')}>
|
|
49
|
+
{Object.entries(cfg.children).map(([k, child]) => {
|
|
50
|
+
if (child.visible && !child.visible(obj)) return null;
|
|
51
|
+
return (
|
|
52
|
+
// A div (not a label): a child may be a composite (object/list/union), not a single control.
|
|
53
|
+
<div key={k} className="flex flex-col gap-1 text-sm">
|
|
54
|
+
<span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
|
55
|
+
{child.label ?? k}
|
|
56
|
+
</span>
|
|
57
|
+
<FieldView
|
|
58
|
+
field={child}
|
|
59
|
+
value={obj[k]}
|
|
60
|
+
path={[...path, k]}
|
|
61
|
+
locale={locale}
|
|
62
|
+
siblings={obj}
|
|
63
|
+
onChange={(v) => onChange({ ...obj, [k]: v })}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ListItem {
|
|
73
|
+
_id: string;
|
|
74
|
+
value: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A card title must be plain text. Blank out anything that looks like a serialized rich-text document (the
|
|
78
|
+
* stored Slate JSON) so a richtext field used as a title degrades to "(empty)" rather than leaking raw JSON. */
|
|
79
|
+
function plainTitle(s: string): string {
|
|
80
|
+
const t = s.trim();
|
|
81
|
+
return t.startsWith('[{') || t.startsWith('{"') ? '' : t;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The label shown on a collapsed item: the schema's `itemTitle(value)`, else a heuristic (a string item is
|
|
85
|
+
* its own title; an object's title/name/label field), else '' (rendered as "(empty)"). */
|
|
86
|
+
function itemTitleOf(cfg: { itemTitle?: (value: unknown) => string }, value: unknown): string {
|
|
87
|
+
if (cfg.itemTitle) {
|
|
88
|
+
try {
|
|
89
|
+
const t = cfg.itemTitle(value);
|
|
90
|
+
return typeof t === 'string' ? plainTitle(t) : '';
|
|
91
|
+
} catch {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === 'string') return plainTitle(value);
|
|
96
|
+
if (value && typeof value === 'object') {
|
|
97
|
+
for (const k of ['title', 'name', 'label', 'heading']) {
|
|
98
|
+
const v = (value as Record<string, unknown>)[k];
|
|
99
|
+
if (typeof v === 'string' && plainTitle(v)) return plainTitle(v);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The insertion indicator while dragging — a primary line at the top or bottom edge of the hovered item
|
|
106
|
+
* (mirrors the rich-text editor's block drop line). The item wrapper is `relative`. */
|
|
107
|
+
function DropLine({ pos }: { pos: 'top' | 'bottom' }) {
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
aria-hidden
|
|
111
|
+
className={cn(
|
|
112
|
+
'pointer-events-none absolute inset-x-0 z-10 h-0.5 bg-primary/60',
|
|
113
|
+
pos === 'top' ? '-top-px' : '-bottom-px',
|
|
114
|
+
)}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ListView({ field, value, onChange, path, locale }: ViewProps) {
|
|
120
|
+
const cfg = field.cfg as { item: FieldDecl; itemTitle?: (value: unknown) => string };
|
|
121
|
+
const item = cfg.item;
|
|
122
|
+
// Composite items (object/union/list) get a collapsible card with a title; scalar items (text/number/…)
|
|
123
|
+
// get a compact draggable row — collapsing a single input would be pointless.
|
|
124
|
+
const composite = fieldType(item.type).kind !== 'leaf';
|
|
125
|
+
const items = ((value ?? { items: [] }) as { items: ListItem[] }).items ?? [];
|
|
126
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
127
|
+
const [drop, setDrop] = useState<{ index: number; pos: 'top' | 'bottom' } | null>(null);
|
|
128
|
+
// Polite live region — keyboard reorder + removal are otherwise silent for screen-reader users.
|
|
129
|
+
const [announce, setAnnounce] = useState('');
|
|
130
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
131
|
+
const focusAfterRemove = useRef<number | null>(null);
|
|
132
|
+
|
|
133
|
+
// After a remove unmounts the keyed row, move focus to the nearest remaining grip (or "+ Add" if empty)
|
|
134
|
+
// so keyboard users don't get dumped to the top of the page.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (focusAfterRemove.current === null) return;
|
|
137
|
+
const idx = focusAfterRemove.current;
|
|
138
|
+
focusAfterRemove.current = null;
|
|
139
|
+
const grips = containerRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
140
|
+
'[data-list-item] button[draggable="true"]',
|
|
141
|
+
);
|
|
142
|
+
if (grips?.length) grips[Math.min(idx, grips.length - 1)]?.focus();
|
|
143
|
+
else containerRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const setItems = (next: ListItem[]) => onChange({ items: next });
|
|
147
|
+
const add = () => setItems([...items, { _id: newId(), value: fieldType(item.type).empty(item) }]);
|
|
148
|
+
const removeAt = (i: number) => {
|
|
149
|
+
setItems(items.filter((_, j) => j !== i));
|
|
150
|
+
setAnnounce(`Removed item ${i + 1}`);
|
|
151
|
+
focusAfterRemove.current = i;
|
|
152
|
+
};
|
|
153
|
+
const updateAt = (i: number, v: unknown) =>
|
|
154
|
+
setItems(items.map((x, j) => (j === i ? { ...x, value: v } : x)));
|
|
155
|
+
const move = (from: number, to: number) => {
|
|
156
|
+
if (from === to || to < 0 || to >= items.length) return;
|
|
157
|
+
const copy = [...items];
|
|
158
|
+
const [moved] = copy.splice(from, 1);
|
|
159
|
+
if (!moved) return;
|
|
160
|
+
copy.splice(to, 0, moved);
|
|
161
|
+
setItems(copy);
|
|
162
|
+
setAnnounce(`Moved to position ${to + 1} of ${items.length}`);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Split top/bottom by the item's HEADER row (not the whole card) so the indicator tracks the cursor even
|
|
166
|
+
// when a card is expanded and tall.
|
|
167
|
+
const posFromEvent = (e: DragEvent<HTMLElement>): 'top' | 'bottom' => {
|
|
168
|
+
const header = e.currentTarget.querySelector('[data-list-header]') ?? e.currentTarget;
|
|
169
|
+
const r = header.getBoundingClientRect();
|
|
170
|
+
return e.clientY < r.top + r.height / 2 ? 'top' : 'bottom';
|
|
171
|
+
};
|
|
172
|
+
// Drop-target props for an item wrapper. Active only mid-reorder (dragIndex set), so they never interfere
|
|
173
|
+
// with editing an open card.
|
|
174
|
+
const targetProps = (i: number) => ({
|
|
175
|
+
'data-list-item': '',
|
|
176
|
+
onDragOver: (e: DragEvent<HTMLElement>) => {
|
|
177
|
+
if (dragIndex === null) return;
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
e.dataTransfer.dropEffect = 'move'; // reorder, not copy — keeps the cursor badge correct
|
|
180
|
+
setDrop({ index: i, pos: posFromEvent(e) });
|
|
181
|
+
},
|
|
182
|
+
onDrop: (e: DragEvent<HTMLElement>) => {
|
|
183
|
+
if (dragIndex === null) return;
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
const pos = posFromEvent(e);
|
|
186
|
+
let to = pos === 'bottom' ? i + 1 : i;
|
|
187
|
+
if (dragIndex < to) to -= 1; // the dragged item leaves first, shifting later indices down
|
|
188
|
+
move(dragIndex, to);
|
|
189
|
+
setDragIndex(null);
|
|
190
|
+
setDrop(null);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const grip = (i: number) => (
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
draggable
|
|
198
|
+
title="Reorder"
|
|
199
|
+
aria-label={`Reorder item ${i + 1} of ${items.length} — drag, or use the arrow keys`}
|
|
200
|
+
aria-keyshortcuts="ArrowUp ArrowDown"
|
|
201
|
+
className="flex h-7 w-5 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:cursor-grabbing"
|
|
202
|
+
onDragStart={(e) => {
|
|
203
|
+
setDragIndex(i);
|
|
204
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
205
|
+
e.dataTransfer.setData('text/plain', String(i)); // required for Firefox to start the drag
|
|
206
|
+
const card = e.currentTarget.closest('[data-list-item]');
|
|
207
|
+
if (card instanceof HTMLElement) e.dataTransfer.setDragImage(card, 16, 16);
|
|
208
|
+
}}
|
|
209
|
+
onDragEnd={() => {
|
|
210
|
+
setDragIndex(null);
|
|
211
|
+
setDrop(null);
|
|
212
|
+
}}
|
|
213
|
+
onKeyDown={(e) => {
|
|
214
|
+
// Keyboard reordering (drag-and-drop is mouse-only); announce boundaries so it never feels stuck.
|
|
215
|
+
if (e.key === 'ArrowUp') {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
if (i === 0) setAnnounce('Already at the top');
|
|
218
|
+
else move(i, i - 1);
|
|
219
|
+
} else if (e.key === 'ArrowDown') {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
if (i === items.length - 1) setAnnounce('Already at the bottom');
|
|
222
|
+
else move(i, i + 1);
|
|
223
|
+
}
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<GripVertical />
|
|
227
|
+
</button>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const remove = (i: number) => (
|
|
231
|
+
<Button
|
|
232
|
+
size="sm"
|
|
233
|
+
variant="ghost"
|
|
234
|
+
className="shrink-0"
|
|
235
|
+
onClick={() => removeAt(i)}
|
|
236
|
+
title="Remove"
|
|
237
|
+
aria-label={`Remove item ${i + 1}`}
|
|
238
|
+
>
|
|
239
|
+
✕
|
|
240
|
+
</Button>
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div ref={containerRef} className="flex flex-col gap-2">
|
|
245
|
+
<div aria-live="polite" className="sr-only">
|
|
246
|
+
{announce}
|
|
247
|
+
</div>
|
|
248
|
+
{items.map((it, i) => {
|
|
249
|
+
const dragging = dragIndex === i;
|
|
250
|
+
const lines = (
|
|
251
|
+
<>
|
|
252
|
+
{drop?.index === i && drop.pos === 'top' ? <DropLine pos="top" /> : null}
|
|
253
|
+
{drop?.index === i && drop.pos === 'bottom' ? <DropLine pos="bottom" /> : null}
|
|
254
|
+
</>
|
|
255
|
+
);
|
|
256
|
+
const body = (
|
|
257
|
+
<FieldView
|
|
258
|
+
field={item}
|
|
259
|
+
value={it.value}
|
|
260
|
+
path={[...path, it._id]}
|
|
261
|
+
locale={locale}
|
|
262
|
+
inList={!composite}
|
|
263
|
+
onChange={(v) => updateAt(i, v)}
|
|
264
|
+
/>
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (composite) {
|
|
268
|
+
const title = itemTitleOf(cfg, it.value);
|
|
269
|
+
return (
|
|
270
|
+
<Collapsible
|
|
271
|
+
key={it._id}
|
|
272
|
+
{...targetProps(i)}
|
|
273
|
+
render={
|
|
274
|
+
<div
|
|
275
|
+
className={cn(
|
|
276
|
+
'relative overflow-hidden rounded-lg bg-card text-card-foreground ring-1 ring-foreground/10 transition-opacity',
|
|
277
|
+
dragging && 'opacity-50',
|
|
278
|
+
)}
|
|
279
|
+
/>
|
|
280
|
+
}
|
|
281
|
+
>
|
|
282
|
+
{lines}
|
|
283
|
+
<div data-list-header className="flex items-center gap-1 pr-1">
|
|
284
|
+
{grip(i)}
|
|
285
|
+
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-2 py-2 text-left">
|
|
286
|
+
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
|
287
|
+
#{i + 1}
|
|
288
|
+
</span>
|
|
289
|
+
<span
|
|
290
|
+
className={cn(
|
|
291
|
+
'min-w-0 truncate text-sm',
|
|
292
|
+
!title && 'text-muted-foreground italic',
|
|
293
|
+
)}
|
|
294
|
+
>
|
|
295
|
+
{title || '(empty)'}
|
|
296
|
+
</span>
|
|
297
|
+
<Chevron className="ml-auto" />
|
|
298
|
+
</CollapsibleTrigger>
|
|
299
|
+
{remove(i)}
|
|
300
|
+
</div>
|
|
301
|
+
<CollapsibleContent>
|
|
302
|
+
<div className="border-t px-3 py-3">{body}</div>
|
|
303
|
+
</CollapsibleContent>
|
|
304
|
+
</Collapsible>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Scalar item — a compact draggable row (no collapse; the input is the whole value).
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
key={it._id}
|
|
312
|
+
{...targetProps(i)}
|
|
313
|
+
className={cn(
|
|
314
|
+
'relative flex items-start gap-1 transition-opacity',
|
|
315
|
+
dragging && 'opacity-50',
|
|
316
|
+
)}
|
|
317
|
+
>
|
|
318
|
+
{lines}
|
|
319
|
+
{grip(i)}
|
|
320
|
+
<div className="min-w-0 flex-1">{body}</div>
|
|
321
|
+
{remove(i)}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
})}
|
|
325
|
+
<Button size="sm" variant="secondary" onClick={add}>
|
|
326
|
+
+ Add
|
|
327
|
+
</Button>
|
|
328
|
+
</div>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function UnionView({ field, value, onChange, path, locale }: ViewProps) {
|
|
333
|
+
const { discriminant, variants } = field.cfg as {
|
|
334
|
+
discriminant: string;
|
|
335
|
+
variants: Record<string, FieldDecl>;
|
|
336
|
+
};
|
|
337
|
+
const obj = (value ?? {}) as Record<string, unknown>;
|
|
338
|
+
const keys = Object.keys(variants);
|
|
339
|
+
const current = obj[discriminant];
|
|
340
|
+
const vid = typeof current === 'string' && variants[current] ? current : keys[0];
|
|
341
|
+
const variantDecl = vid ? variants[vid] : undefined;
|
|
342
|
+
const switchVariant = (next: string) => {
|
|
343
|
+
const v = variants[next];
|
|
344
|
+
onChange({
|
|
345
|
+
[discriminant]: next,
|
|
346
|
+
...(v ? (fieldType(v.type).empty(v) as Record<string, unknown>) : {}),
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
return (
|
|
350
|
+
<div className="flex flex-col gap-3">
|
|
351
|
+
<NativeSelect value={vid ?? ''} onChange={(e) => switchVariant(e.target.value)}>
|
|
352
|
+
{keys.map((k) => (
|
|
353
|
+
<NativeSelectOption key={k} value={k}>
|
|
354
|
+
{variants[k]?.label ?? k}
|
|
355
|
+
</NativeSelectOption>
|
|
356
|
+
))}
|
|
357
|
+
</NativeSelect>
|
|
358
|
+
{variantDecl ? (
|
|
359
|
+
<ObjectView
|
|
360
|
+
field={variantDecl}
|
|
361
|
+
value={obj}
|
|
362
|
+
onChange={onChange}
|
|
363
|
+
path={path}
|
|
364
|
+
locale={locale}
|
|
365
|
+
/>
|
|
366
|
+
) : null}
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Render any field schema node into editing UI. */
|
|
372
|
+
export function FieldView(props: ViewProps) {
|
|
373
|
+
const kind = fieldType(props.field.type).kind;
|
|
374
|
+
if (kind === 'object') return <ObjectView {...props} />;
|
|
375
|
+
if (kind === 'list') return <ListView {...props} />;
|
|
376
|
+
if (kind === 'union') return <UnionView {...props} />;
|
|
377
|
+
const Editor = getEditor(props.field.type);
|
|
378
|
+
if (!Editor) {
|
|
379
|
+
return (
|
|
380
|
+
<div className="text-destructive text-xs">No editor for field type: {props.field.type}</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return (
|
|
384
|
+
<Editor
|
|
385
|
+
value={props.value}
|
|
386
|
+
onChange={props.onChange}
|
|
387
|
+
path={props.path}
|
|
388
|
+
locale={props.locale}
|
|
389
|
+
field={props.field}
|
|
390
|
+
siblings={props.siblings}
|
|
391
|
+
inList={props.inList}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cn } from '@saena-io/ui/lib/utils';
|
|
2
|
+
|
|
3
|
+
// Small inline SVG icons for the admin form chrome (no icon-pack dependency in @saena-io/content — the React-free
|
|
4
|
+
// core stays light; @saena-io/ui owns hugeicons). Kept here so the section panel and the form engine share them.
|
|
5
|
+
|
|
6
|
+
/** A chevron that rotates when its collapsible trigger is expanded — place inside a `group` trigger that
|
|
7
|
+
* carries `aria-expanded` (base-ui CollapsibleTrigger). */
|
|
8
|
+
export function Chevron({ className }: { className?: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<svg
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
strokeWidth={2}
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
className={cn(
|
|
19
|
+
'size-4 shrink-0 transition-transform group-aria-expanded:rotate-180',
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
>
|
|
23
|
+
<path d="m6 9 6 6 6-6" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A six-dot vertical grip — the drag handle, mirroring the rich-text editor's block handle. */
|
|
29
|
+
export function GripVertical({ className }: { className?: string }) {
|
|
30
|
+
return (
|
|
31
|
+
<svg
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="currentColor"
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
className={cn('size-4 shrink-0', className)}
|
|
36
|
+
>
|
|
37
|
+
<circle cx="9" cy="6" r="1.6" />
|
|
38
|
+
<circle cx="15" cy="6" r="1.6" />
|
|
39
|
+
<circle cx="9" cy="12" r="1.6" />
|
|
40
|
+
<circle cx="15" cy="12" r="1.6" />
|
|
41
|
+
<circle cx="9" cy="18" r="1.6" />
|
|
42
|
+
<circle cx="15" cy="18" r="1.6" />
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
}
|