@pienter/ui 0.5.0 → 0.7.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/CHANGELOG.md +58 -0
- package/CONVENTIONS.md +45 -0
- package/README.md +30 -0
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +21 -34
- package/components/form/number-field/NumberField.vue +0 -1
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/text-input/text-input.css +2 -0
- package/components/layout/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +2 -1
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +11 -8
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/composables/useMenu.ts +20 -27
- package/package.json +12 -2
- package/styles/0-settings/colors.css +10 -0
- package/utils/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type { BlockType, ContentBlock, BlockPatch } from './types.js';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts" generic="T extends ContentBlock">
|
|
6
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
7
|
+
import Button from '../../action/button/Button.vue';
|
|
8
|
+
import IconButton from '../../action/button/IconButton.vue';
|
|
9
|
+
import Icon from '../../display/icon/Icon.vue';
|
|
10
|
+
import { announce } from '../../../utils/a11y/live-region.js';
|
|
11
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
12
|
+
import type { BlockPatch, BlockType, ContentBlock } from './types.js';
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(
|
|
15
|
+
defineProps<{
|
|
16
|
+
modelValue: readonly T[];
|
|
17
|
+
blockTypes: readonly BlockType<T>[];
|
|
18
|
+
/** Optional controlled list of collapsed block ids. */
|
|
19
|
+
collapsed?: readonly string[];
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
label?: string;
|
|
22
|
+
blockLabel?: (block: T, index: number) => string;
|
|
23
|
+
}>(),
|
|
24
|
+
{
|
|
25
|
+
disabled: false,
|
|
26
|
+
collapsed: undefined,
|
|
27
|
+
label: 'Content blocks',
|
|
28
|
+
blockLabel: undefined,
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
'update:modelValue': [blocks: T[]];
|
|
34
|
+
'update:collapsed': [ids: string[]];
|
|
35
|
+
}>();
|
|
36
|
+
defineSlots<{
|
|
37
|
+
block: (scope: {
|
|
38
|
+
block: T;
|
|
39
|
+
index: number;
|
|
40
|
+
update: (patch: BlockPatch<T>) => void;
|
|
41
|
+
disabled: boolean;
|
|
42
|
+
}) => unknown;
|
|
43
|
+
summary?: (scope: { block: T; index: number }) => unknown;
|
|
44
|
+
empty?: () => unknown;
|
|
45
|
+
}>();
|
|
46
|
+
|
|
47
|
+
const id = generateId('blocks');
|
|
48
|
+
const localCollapsed = ref<string[]>([]);
|
|
49
|
+
const collapsed = computed(
|
|
50
|
+
() => new Set(props.collapsed ?? localCollapsed.value),
|
|
51
|
+
);
|
|
52
|
+
const dragging = ref<string>();
|
|
53
|
+
const dropIndex = ref(0);
|
|
54
|
+
const items = new Map<string, HTMLElement>();
|
|
55
|
+
let gesture:
|
|
56
|
+
| {
|
|
57
|
+
id: string;
|
|
58
|
+
pointerId: number;
|
|
59
|
+
startX: number;
|
|
60
|
+
startY: number;
|
|
61
|
+
y: number;
|
|
62
|
+
}
|
|
63
|
+
| undefined;
|
|
64
|
+
let pointerListeners: AbortController | undefined;
|
|
65
|
+
let scrollFrame = 0;
|
|
66
|
+
let scrollParent: HTMLElement | undefined;
|
|
67
|
+
|
|
68
|
+
function setItem(key: string, element: HTMLElement | null) {
|
|
69
|
+
if (element) items.set(key, element);
|
|
70
|
+
else items.delete(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function typeLabel(block: T) {
|
|
74
|
+
return (
|
|
75
|
+
props.blockTypes.find((type) => type.type === block.type)?.label ??
|
|
76
|
+
block.type
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function title(block: T, index: number) {
|
|
81
|
+
return props.blockLabel?.(block, index) || typeLabel(block);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function bodyId(block: T) {
|
|
85
|
+
return `${id}-${encodeURIComponent(block.id)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function focusBlock(key: string) {
|
|
89
|
+
void nextTick(() =>
|
|
90
|
+
items
|
|
91
|
+
.get(key)
|
|
92
|
+
?.querySelector<HTMLButtonElement>('[data-block-toggle]')
|
|
93
|
+
?.focus(),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function add(type: BlockType<T>) {
|
|
98
|
+
if (props.disabled) return;
|
|
99
|
+
const block = type.create();
|
|
100
|
+
if (
|
|
101
|
+
!block.id ||
|
|
102
|
+
block.type !== type.type ||
|
|
103
|
+
props.modelValue.some((item) => item.id === block.id)
|
|
104
|
+
) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
'Block factories must return the requested type and a unique, non-empty id.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
emit('update:modelValue', [...props.modelValue, { ...block }]);
|
|
110
|
+
focusBlock(block.id);
|
|
111
|
+
announce(`${type.label} added. ${props.modelValue.length + 1} blocks.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function update(key: string, patch: BlockPatch<T>) {
|
|
115
|
+
if (props.disabled) return;
|
|
116
|
+
emit(
|
|
117
|
+
'update:modelValue',
|
|
118
|
+
props.modelValue.map((block) =>
|
|
119
|
+
block.id === key
|
|
120
|
+
? { ...block, ...patch, id: block.id, type: block.type }
|
|
121
|
+
: block,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function remove(key: string) {
|
|
127
|
+
if (props.disabled) return;
|
|
128
|
+
const index = props.modelValue.findIndex((block) => block.id === key);
|
|
129
|
+
const block = props.modelValue[index];
|
|
130
|
+
if (!block) return;
|
|
131
|
+
const remaining = props.modelValue.filter((block) => block.id !== key);
|
|
132
|
+
emit('update:modelValue', remaining);
|
|
133
|
+
setCollapsed([...collapsed.value].filter((id) => id !== key));
|
|
134
|
+
const next = remaining[Math.min(index, remaining.length - 1)];
|
|
135
|
+
if (next) focusBlock(next.id);
|
|
136
|
+
else
|
|
137
|
+
void nextTick(() =>
|
|
138
|
+
document
|
|
139
|
+
.getElementById(`${id}-add`)
|
|
140
|
+
?.querySelector<HTMLButtonElement>('button')
|
|
141
|
+
?.focus(),
|
|
142
|
+
);
|
|
143
|
+
announce(`${title(block, index)} removed. ${remaining.length} blocks.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function move(key: string, target: number) {
|
|
147
|
+
if (props.disabled) return;
|
|
148
|
+
const source = props.modelValue.findIndex((block) => block.id === key);
|
|
149
|
+
if (
|
|
150
|
+
source < 0 ||
|
|
151
|
+
target < 0 ||
|
|
152
|
+
target >= props.modelValue.length ||
|
|
153
|
+
source === target
|
|
154
|
+
)
|
|
155
|
+
return;
|
|
156
|
+
const blocks = [...props.modelValue];
|
|
157
|
+
const block = blocks.splice(source, 1)[0]!;
|
|
158
|
+
blocks.splice(target, 0, block);
|
|
159
|
+
emit('update:modelValue', blocks);
|
|
160
|
+
focusBlock(key);
|
|
161
|
+
announce(
|
|
162
|
+
`${title(block, source)} moved to position ${target + 1} of ${blocks.length}.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function toggle(key: string) {
|
|
167
|
+
if (props.disabled) return;
|
|
168
|
+
const next = new Set(collapsed.value);
|
|
169
|
+
if (next.has(key)) next.delete(key);
|
|
170
|
+
else next.add(key);
|
|
171
|
+
setCollapsed([...next]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function setCollapsed(ids: string[]) {
|
|
175
|
+
if (props.collapsed === undefined) localCollapsed.value = ids;
|
|
176
|
+
emit('update:collapsed', ids);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const otherBlocks = computed(() =>
|
|
180
|
+
props.modelValue.filter((block) => block.id !== dragging.value),
|
|
181
|
+
);
|
|
182
|
+
function indicator(key: string) {
|
|
183
|
+
if (!dragging.value) return undefined;
|
|
184
|
+
if (otherBlocks.value[dropIndex.value]?.id === key) return 'before';
|
|
185
|
+
if (
|
|
186
|
+
dropIndex.value === otherBlocks.value.length &&
|
|
187
|
+
otherBlocks.value.at(-1)?.id === key
|
|
188
|
+
)
|
|
189
|
+
return 'after';
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function locateDrop(y: number) {
|
|
194
|
+
dropIndex.value = otherBlocks.value.filter((block) => {
|
|
195
|
+
const rect = items.get(block.id)?.getBoundingClientRect();
|
|
196
|
+
return rect && y > rect.top + rect.height / 2;
|
|
197
|
+
}).length;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function autoScroll() {
|
|
201
|
+
if (!gesture || !dragging.value) return;
|
|
202
|
+
const bounds = scrollParent?.getBoundingClientRect();
|
|
203
|
+
const top = bounds?.top ?? 0;
|
|
204
|
+
const bottom = bounds?.bottom ?? window.innerHeight;
|
|
205
|
+
const delta = gesture.y < top + 48 ? -14 : gesture.y > bottom - 48 ? 14 : 0;
|
|
206
|
+
if (delta) {
|
|
207
|
+
if (scrollParent) scrollParent.scrollTop += delta;
|
|
208
|
+
else window.scrollBy(0, delta);
|
|
209
|
+
locateDrop(gesture.y);
|
|
210
|
+
}
|
|
211
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stopDrag() {
|
|
215
|
+
pointerListeners?.abort();
|
|
216
|
+
pointerListeners = undefined;
|
|
217
|
+
cancelAnimationFrame(scrollFrame);
|
|
218
|
+
gesture = undefined;
|
|
219
|
+
dragging.value = undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function startDrag(event: PointerEvent, key: string) {
|
|
223
|
+
if (props.disabled || event.button !== 0 || props.modelValue.length < 2)
|
|
224
|
+
return;
|
|
225
|
+
stopDrag();
|
|
226
|
+
const handle = event.currentTarget as HTMLButtonElement;
|
|
227
|
+
handle.focus({ preventScroll: true });
|
|
228
|
+
gesture = {
|
|
229
|
+
id: key,
|
|
230
|
+
pointerId: event.pointerId,
|
|
231
|
+
startX: event.clientX,
|
|
232
|
+
startY: event.clientY,
|
|
233
|
+
y: event.clientY,
|
|
234
|
+
};
|
|
235
|
+
scrollParent = undefined;
|
|
236
|
+
for (
|
|
237
|
+
let parent = handle.parentElement;
|
|
238
|
+
parent;
|
|
239
|
+
parent = parent.parentElement
|
|
240
|
+
) {
|
|
241
|
+
if (
|
|
242
|
+
/(auto|scroll)/.test(getComputedStyle(parent).overflowY) &&
|
|
243
|
+
parent.scrollHeight > parent.clientHeight
|
|
244
|
+
) {
|
|
245
|
+
scrollParent = parent;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
handle.setPointerCapture(event.pointerId);
|
|
251
|
+
} catch {
|
|
252
|
+
/* Synthetic pointer events have no capturable pointer. */
|
|
253
|
+
}
|
|
254
|
+
pointerListeners = new AbortController();
|
|
255
|
+
const options = { signal: pointerListeners.signal };
|
|
256
|
+
window.addEventListener('pointermove', dragMove, options);
|
|
257
|
+
window.addEventListener('pointerup', finishDrag, options);
|
|
258
|
+
window.addEventListener('pointercancel', stopDrag, options);
|
|
259
|
+
window.addEventListener('blur', stopDrag, options);
|
|
260
|
+
window.addEventListener('keydown', cancelWithEscape, options);
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function dragMove(event: PointerEvent) {
|
|
265
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
266
|
+
gesture.y = event.clientY;
|
|
267
|
+
if (!dragging.value) {
|
|
268
|
+
if (
|
|
269
|
+
Math.hypot(
|
|
270
|
+
event.clientX - gesture.startX,
|
|
271
|
+
event.clientY - gesture.startY,
|
|
272
|
+
) < 5
|
|
273
|
+
)
|
|
274
|
+
return;
|
|
275
|
+
dragging.value = gesture.id;
|
|
276
|
+
scrollFrame = requestAnimationFrame(autoScroll);
|
|
277
|
+
}
|
|
278
|
+
locateDrop(event.clientY);
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function finishDrag(event: PointerEvent) {
|
|
283
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
284
|
+
const key = dragging.value;
|
|
285
|
+
const target = dropIndex.value;
|
|
286
|
+
stopDrag();
|
|
287
|
+
if (key) move(key, target);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function cancelWithEscape(event: KeyboardEvent) {
|
|
291
|
+
if (event.key === 'Escape' && gesture) {
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
stopDrag();
|
|
294
|
+
announce('Reordering cancelled.');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
watch(
|
|
299
|
+
() => props.disabled,
|
|
300
|
+
(disabled) => {
|
|
301
|
+
if (disabled) stopDrag();
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
watch(() => props.modelValue.map((block) => block.id).join('\u0000'), stopDrag);
|
|
305
|
+
onBeforeUnmount(stopDrag);
|
|
306
|
+
</script>
|
|
307
|
+
|
|
308
|
+
<template>
|
|
309
|
+
<div
|
|
310
|
+
class="pui-block-editor"
|
|
311
|
+
:data-disabled="disabled ? 'true' : undefined"
|
|
312
|
+
>
|
|
313
|
+
<p :id="`${id}-hint`" class="pui-block-editor__hint">
|
|
314
|
+
Drag to reorder, or use the move buttons.
|
|
315
|
+
</p>
|
|
316
|
+
<ol
|
|
317
|
+
v-if="modelValue.length"
|
|
318
|
+
class="pui-block-editor__list"
|
|
319
|
+
:aria-label="label"
|
|
320
|
+
:aria-describedby="`${id}-hint`"
|
|
321
|
+
>
|
|
322
|
+
<li
|
|
323
|
+
v-for="(block, index) in modelValue"
|
|
324
|
+
:key="block.id"
|
|
325
|
+
:ref="
|
|
326
|
+
(element) =>
|
|
327
|
+
setItem(block.id, element as HTMLElement | null)
|
|
328
|
+
"
|
|
329
|
+
class="pui-block-editor__item"
|
|
330
|
+
:data-block-id="block.id"
|
|
331
|
+
:data-dragging="dragging === block.id ? 'true' : undefined"
|
|
332
|
+
:data-drop="indicator(block.id)"
|
|
333
|
+
>
|
|
334
|
+
<div class="pui-block-editor__header">
|
|
335
|
+
<button
|
|
336
|
+
class="pui-block-editor__drag"
|
|
337
|
+
type="button"
|
|
338
|
+
:disabled="disabled || modelValue.length < 2"
|
|
339
|
+
:aria-label="`Drag ${title(block, index)}`"
|
|
340
|
+
:aria-describedby="`${id}-hint`"
|
|
341
|
+
@pointerdown="startDrag($event, block.id)"
|
|
342
|
+
>
|
|
343
|
+
<Icon name="arrow-up-down" size="sm" />
|
|
344
|
+
</button>
|
|
345
|
+
<div class="pui-block-editor__heading">
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
class="pui-block-editor__toggle"
|
|
349
|
+
data-block-toggle
|
|
350
|
+
:disabled="disabled"
|
|
351
|
+
:aria-label="`${collapsed.has(block.id) ? 'Expand' : 'Collapse'} ${title(block, index)}`"
|
|
352
|
+
:aria-expanded="
|
|
353
|
+
collapsed.has(block.id) ? 'false' : 'true'
|
|
354
|
+
"
|
|
355
|
+
:aria-controls="bodyId(block)"
|
|
356
|
+
@click="toggle(block.id)"
|
|
357
|
+
>
|
|
358
|
+
<Icon
|
|
359
|
+
:name="
|
|
360
|
+
collapsed.has(block.id)
|
|
361
|
+
? 'chevron-right'
|
|
362
|
+
: 'chevron-down'
|
|
363
|
+
"
|
|
364
|
+
size="sm"
|
|
365
|
+
/>
|
|
366
|
+
<span class="pui-block-editor__title">{{
|
|
367
|
+
title(block, index)
|
|
368
|
+
}}</span>
|
|
369
|
+
<span
|
|
370
|
+
v-if="title(block, index) !== typeLabel(block)"
|
|
371
|
+
class="pui-block-editor__type"
|
|
372
|
+
>{{ typeLabel(block) }}</span
|
|
373
|
+
>
|
|
374
|
+
</button>
|
|
375
|
+
<div
|
|
376
|
+
v-if="$slots.summary"
|
|
377
|
+
class="pui-block-editor__summary"
|
|
378
|
+
>
|
|
379
|
+
<slot
|
|
380
|
+
name="summary"
|
|
381
|
+
:block="block"
|
|
382
|
+
:index="index"
|
|
383
|
+
/>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
<div class="pui-block-editor__actions">
|
|
387
|
+
<IconButton
|
|
388
|
+
name="arrow-up"
|
|
389
|
+
variant="ghost"
|
|
390
|
+
size="sm"
|
|
391
|
+
:label="`Move ${title(block, index)} up`"
|
|
392
|
+
:disabled="disabled || index === 0"
|
|
393
|
+
@click="move(block.id, index - 1)"
|
|
394
|
+
/>
|
|
395
|
+
<IconButton
|
|
396
|
+
name="arrow-down"
|
|
397
|
+
variant="ghost"
|
|
398
|
+
size="sm"
|
|
399
|
+
:label="`Move ${title(block, index)} down`"
|
|
400
|
+
:disabled="
|
|
401
|
+
disabled || index === modelValue.length - 1
|
|
402
|
+
"
|
|
403
|
+
@click="move(block.id, index + 1)"
|
|
404
|
+
/>
|
|
405
|
+
<IconButton
|
|
406
|
+
name="trash-2"
|
|
407
|
+
variant="ghost"
|
|
408
|
+
size="sm"
|
|
409
|
+
:label="`Remove ${title(block, index)}`"
|
|
410
|
+
:disabled="disabled"
|
|
411
|
+
@click="remove(block.id)"
|
|
412
|
+
/>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
<div
|
|
416
|
+
:id="bodyId(block)"
|
|
417
|
+
:hidden="collapsed.has(block.id)"
|
|
418
|
+
class="pui-block-editor__body"
|
|
419
|
+
:inert="disabled ? true : undefined"
|
|
420
|
+
>
|
|
421
|
+
<slot
|
|
422
|
+
name="block"
|
|
423
|
+
:block="block"
|
|
424
|
+
:index="index"
|
|
425
|
+
:update="
|
|
426
|
+
(patch: BlockPatch<T>) => update(block.id, patch)
|
|
427
|
+
"
|
|
428
|
+
:disabled="disabled"
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
</li>
|
|
432
|
+
</ol>
|
|
433
|
+
<div v-else class="pui-block-editor__empty">
|
|
434
|
+
<slot name="empty"
|
|
435
|
+
>No blocks yet. Add a block to start writing.</slot
|
|
436
|
+
>
|
|
437
|
+
</div>
|
|
438
|
+
<div :id="`${id}-add`" class="pui-block-editor__add">
|
|
439
|
+
<Button
|
|
440
|
+
v-for="type in blockTypes"
|
|
441
|
+
:key="type.type"
|
|
442
|
+
icon="plus"
|
|
443
|
+
size="sm"
|
|
444
|
+
:disabled="disabled"
|
|
445
|
+
@click="add(type)"
|
|
446
|
+
>Add {{ type.label }}</Button
|
|
447
|
+
>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</template>
|
|
451
|
+
|
|
452
|
+
<style>
|
|
453
|
+
@import './block-editor.css';
|
|
454
|
+
</style>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-block-editor {
|
|
3
|
+
display: grid;
|
|
4
|
+
gap: var(--space-s);
|
|
5
|
+
min-inline-size: 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.pui-block-editor__hint,
|
|
9
|
+
.pui-block-editor__summary {
|
|
10
|
+
margin: 0;
|
|
11
|
+
color: var(--text-clr-muted);
|
|
12
|
+
font-size: var(--step--1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.pui-block-editor__list {
|
|
16
|
+
display: grid;
|
|
17
|
+
gap: var(--space-s);
|
|
18
|
+
margin: 0;
|
|
19
|
+
padding: 0;
|
|
20
|
+
list-style: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.pui-block-editor__item {
|
|
24
|
+
position: relative;
|
|
25
|
+
min-inline-size: 0;
|
|
26
|
+
border: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
27
|
+
border-radius: var(--radius-md);
|
|
28
|
+
background: var(--bg-clr-surface);
|
|
29
|
+
|
|
30
|
+
&[data-dragging='true'] {
|
|
31
|
+
opacity: var(--opacity-disabled);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
&[data-drop]::before {
|
|
35
|
+
content: '';
|
|
36
|
+
position: absolute;
|
|
37
|
+
inset-inline: 0;
|
|
38
|
+
block-size: var(--stroke-lg);
|
|
39
|
+
border-radius: var(--radius-pill);
|
|
40
|
+
background: var(--outline-clr-base);
|
|
41
|
+
pointer-events: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
&[data-drop='before']::before {
|
|
45
|
+
inset-block-start: calc(var(--space-2xs) * -1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&[data-drop='after']::before {
|
|
49
|
+
inset-block-end: calc(var(--space-2xs) * -1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.pui-block-editor__header {
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: start;
|
|
56
|
+
gap: var(--space-2xs);
|
|
57
|
+
padding: var(--space-xs);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.pui-block-editor__heading {
|
|
61
|
+
flex: 1;
|
|
62
|
+
min-inline-size: 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.pui-block-editor__drag,
|
|
66
|
+
.pui-block-editor__toggle {
|
|
67
|
+
display: inline-flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: var(--space-2xs);
|
|
70
|
+
min-block-size: var(--control-height-sm);
|
|
71
|
+
padding: 0;
|
|
72
|
+
border: 0;
|
|
73
|
+
background: transparent;
|
|
74
|
+
color: var(--text-clr-base);
|
|
75
|
+
font: inherit;
|
|
76
|
+
text-align: start;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
|
|
79
|
+
&:focus-visible {
|
|
80
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
81
|
+
outline-offset: var(--outline-offset);
|
|
82
|
+
border-radius: var(--radius-xs);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
&:disabled {
|
|
86
|
+
opacity: var(--opacity-disabled);
|
|
87
|
+
cursor: not-allowed;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.pui-block-editor__drag {
|
|
92
|
+
justify-content: center;
|
|
93
|
+
flex: 0 0 var(--control-height-sm);
|
|
94
|
+
touch-action: none;
|
|
95
|
+
cursor: grab;
|
|
96
|
+
|
|
97
|
+
&:active:not(:disabled) {
|
|
98
|
+
cursor: grabbing;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.pui-block-editor__toggle {
|
|
103
|
+
flex-wrap: wrap;
|
|
104
|
+
inline-size: 100%;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.pui-block-editor__title {
|
|
108
|
+
font-size: var(--step--1);
|
|
109
|
+
font-weight: var(--fw-control);
|
|
110
|
+
overflow-wrap: anywhere;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.pui-block-editor__type {
|
|
114
|
+
color: var(--text-clr-muted);
|
|
115
|
+
font-size: var(--step--2);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.pui-block-editor__actions,
|
|
119
|
+
.pui-block-editor__add {
|
|
120
|
+
display: flex;
|
|
121
|
+
flex-wrap: wrap;
|
|
122
|
+
align-items: center;
|
|
123
|
+
gap: var(--space-3xs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.pui-block-editor__body {
|
|
127
|
+
display: grid;
|
|
128
|
+
gap: var(--space-s);
|
|
129
|
+
padding: var(--space-s);
|
|
130
|
+
border-block-start: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
131
|
+
|
|
132
|
+
&[hidden] {
|
|
133
|
+
display: none;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.pui-block-editor__empty {
|
|
138
|
+
padding: var(--space-m);
|
|
139
|
+
border: var(--stroke-sm) dashed var(--border-clr-subtle);
|
|
140
|
+
border-radius: var(--radius-md);
|
|
141
|
+
color: var(--text-clr-muted);
|
|
142
|
+
font-size: var(--step--1);
|
|
143
|
+
text-align: center;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.pui-block-editor__add {
|
|
147
|
+
gap: var(--space-2xs);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ContentBlock {
|
|
2
|
+
id: string;
|
|
3
|
+
type: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface BlockType<T extends ContentBlock = ContentBlock> {
|
|
7
|
+
type: string;
|
|
8
|
+
label: string;
|
|
9
|
+
/** Return a new block with a unique, stable id. */
|
|
10
|
+
create: () => T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BlockPatch<T extends ContentBlock> = Partial<
|
|
14
|
+
Omit<T, 'id' | 'type'>
|
|
15
|
+
>;
|
|
@@ -278,6 +278,18 @@ const filteredOptions = computed(() => {
|
|
|
278
278
|
return props.options.filter((opt) => fn(opt, q));
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
const firstEnabledIndex = computed(() => {
|
|
282
|
+
const index = filteredOptions.value.findIndex((option) => !option.disabled);
|
|
283
|
+
return index >= 0 ? index : null;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const lastEnabledIndex = computed(() => {
|
|
287
|
+
for (let index = filteredOptions.value.length - 1; index >= 0; index -= 1) {
|
|
288
|
+
if (!filteredOptions.value[index]?.disabled) return index;
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
});
|
|
292
|
+
|
|
281
293
|
const activeOptionDomId = computed(() => {
|
|
282
294
|
if (activeIndex.value == null) return null;
|
|
283
295
|
return `${listboxId}-opt-${activeIndex.value}`;
|
|
@@ -343,8 +355,7 @@ function onInput(event: Event): void {
|
|
|
343
355
|
isTyping.value = true;
|
|
344
356
|
// First filtered match becomes the active option so Enter has
|
|
345
357
|
// an obvious target. Skip disabled options.
|
|
346
|
-
|
|
347
|
-
activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
|
|
358
|
+
activeIndex.value = firstEnabledIndex.value;
|
|
348
359
|
if (!isOpen.value) open();
|
|
349
360
|
}
|
|
350
361
|
|
|
@@ -386,12 +397,7 @@ function onKeydown(event: KeyboardEvent): void {
|
|
|
386
397
|
event.preventDefault();
|
|
387
398
|
if (!isOpen.value) {
|
|
388
399
|
open();
|
|
389
|
-
|
|
390
|
-
// the listbox content is visible to scroll into view.
|
|
391
|
-
const firstEnabled = filteredOptions.value.findIndex(
|
|
392
|
-
(o) => !o.disabled,
|
|
393
|
-
);
|
|
394
|
-
activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
|
|
400
|
+
activeIndex.value = firstEnabledIndex.value;
|
|
395
401
|
} else {
|
|
396
402
|
moveActive(1);
|
|
397
403
|
}
|
|
@@ -400,41 +406,22 @@ function onKeydown(event: KeyboardEvent): void {
|
|
|
400
406
|
event.preventDefault();
|
|
401
407
|
if (!isOpen.value) {
|
|
402
408
|
open();
|
|
403
|
-
|
|
404
|
-
.map((o, i) => ({ o, i }))
|
|
405
|
-
.reverse()
|
|
406
|
-
.find(({ o }) => !o.disabled);
|
|
407
|
-
activeIndex.value = lastEnabled ? lastEnabled.i : null;
|
|
409
|
+
activeIndex.value = lastEnabledIndex.value;
|
|
408
410
|
} else {
|
|
409
411
|
moveActive(-1);
|
|
410
412
|
}
|
|
411
413
|
break;
|
|
412
414
|
case 'Home':
|
|
413
|
-
if (!isOpen.value) return;
|
|
414
|
-
event.preventDefault();
|
|
415
|
-
{
|
|
416
|
-
const firstEnabled = filteredOptions.value.findIndex(
|
|
417
|
-
(o) => !o.disabled,
|
|
418
|
-
);
|
|
419
|
-
if (firstEnabled >= 0) {
|
|
420
|
-
activeIndex.value = firstEnabled;
|
|
421
|
-
scrollActiveIntoView();
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
break;
|
|
425
415
|
case 'End':
|
|
426
416
|
if (!isOpen.value) return;
|
|
427
417
|
event.preventDefault();
|
|
428
418
|
{
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
if (lastEnabled >= 0) {
|
|
437
|
-
activeIndex.value = lastEnabled;
|
|
419
|
+
const index =
|
|
420
|
+
event.key === 'Home'
|
|
421
|
+
? firstEnabledIndex.value
|
|
422
|
+
: lastEnabledIndex.value;
|
|
423
|
+
if (index !== null) {
|
|
424
|
+
activeIndex.value = index;
|
|
438
425
|
scrollActiveIntoView();
|
|
439
426
|
}
|
|
440
427
|
}
|