@pienter/ui 0.3.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/CONVENTIONS.md +342 -385
  3. package/README.md +33 -18
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/feedback/toast/toast.css +1 -1
  8. package/components/form/block-editor/BlockEditor.vue +454 -0
  9. package/components/form/block-editor/block-editor.css +149 -0
  10. package/components/form/block-editor/types.ts +15 -0
  11. package/components/form/combobox/Combobox.vue +42 -72
  12. package/components/form/combobox/combobox.css +1 -1
  13. package/components/form/form/Form.vue +1 -2
  14. package/components/form/label/label.css +1 -1
  15. package/components/form/number-field/NumberField.vue +1 -2
  16. package/components/form/number-field/number-field.css +1 -1
  17. package/components/form/radio-group/RadioGroup.vue +2 -5
  18. package/components/form/record-form/RecordFields.vue +128 -0
  19. package/components/form/record-form/RecordForm.vue +116 -0
  20. package/components/form/record-form/fields.ts +20 -0
  21. package/components/form/record-form/record-form.css +15 -0
  22. package/components/form/record-form/types.ts +28 -0
  23. package/components/form/slider/slider.css +2 -3
  24. package/components/form/tags-input/tags-input.css +1 -2
  25. package/components/form/text-input/text-input.css +2 -0
  26. package/components/form/textarea/textarea.css +1 -1
  27. package/components/layout/accordion/Accordion.vue +6 -27
  28. package/components/layout/collapsible/Collapsible.vue +9 -19
  29. package/components/layout/index/Index.vue +353 -0
  30. package/components/layout/index/index.css +114 -0
  31. package/components/layout/index/useIndex.ts +390 -0
  32. package/components/layout/table/table.css +5 -4
  33. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  34. package/components/navigation/breadcrumb/breadcrumb.css +15 -0
  35. package/components/navigation/sidebar/Sidebar.vue +329 -84
  36. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  37. package/components/navigation/sidebar/context.ts +21 -0
  38. package/components/navigation/sidebar/sidebar.css +346 -78
  39. package/components/navigation/sidebar/types.ts +13 -1
  40. package/components/navigation/tabs/Tabs.vue +6 -0
  41. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  42. package/components/overlay/command/Command.vue +10 -38
  43. package/components/overlay/command/command.css +1 -1
  44. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  45. package/components/overlay/modal/Modal.vue +7 -28
  46. package/components/overlay/popover/Popover.vue +9 -31
  47. package/components/overlay/sheet/Sheet.vue +7 -28
  48. package/components/overlay/tooltip/Tooltip.vue +14 -19
  49. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  50. package/composables/useDisclosure.ts +113 -0
  51. package/composables/useEventListener.ts +16 -0
  52. package/composables/useMenu.ts +212 -0
  53. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  54. package/package.json +16 -40
  55. package/styles/0-settings/colors.css +10 -0
  56. package/styles/0-settings/layout.css +18 -0
  57. package/styles/0-settings/motion.css +2 -2
  58. package/styles/0-settings/spacing.css +3 -1
  59. package/utils/a11y/focus.ts +9 -3
  60. package/utils/cms/index.ts +283 -0
  61. package/utils/cms/schema.json +126 -0
  62. package/utils/navigation/sidebar.ts +97 -0
  63. package/utils/validation/form.ts +6 -9
  64. package/components/action/button/Button.astro +0 -95
  65. package/components/action/button/IconButton.astro +0 -86
  66. package/components/display/avatar/Avatar.astro +0 -17
  67. package/components/display/avatar/AvatarStack.astro +0 -9
  68. package/components/display/badge/Badge.astro +0 -15
  69. package/components/display/empty/Empty.astro +0 -9
  70. package/components/display/icon/Icon.astro +0 -52
  71. package/components/feedback/alert/Alert.astro +0 -52
  72. package/components/feedback/progress/Progress.astro +0 -68
  73. package/components/feedback/skeleton/Skeleton.astro +0 -32
  74. package/components/feedback/spinner/Spinner.astro +0 -25
  75. package/components/feedback/toast/Toast.astro +0 -50
  76. package/components/form/checkbox/Checkbox.astro +0 -79
  77. package/components/form/date-input/DateInput.astro +0 -105
  78. package/components/form/form/Form.astro +0 -106
  79. package/components/form/input-otp/InputOTP.astro +0 -147
  80. package/components/form/label/Label.astro +0 -13
  81. package/components/form/number-field/NumberField.astro +0 -142
  82. package/components/form/radio-group/RadioGroup.astro +0 -105
  83. package/components/form/select/Select.astro +0 -105
  84. package/components/form/slider/Slider.astro +0 -205
  85. package/components/form/switch/Switch.astro +0 -75
  86. package/components/form/tags-input/TagsInput.astro +0 -153
  87. package/components/form/text-input/TextInput.astro +0 -84
  88. package/components/form/textarea/Textarea.astro +0 -86
  89. package/components/layout/card/Card.astro +0 -13
  90. package/components/layout/separator/Separator.astro +0 -31
  91. package/components/layout/table/Table.astro +0 -116
  92. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  93. package/components/navigation/navbar/Navbar.astro +0 -62
  94. package/components/navigation/pagination/PaginationFooter.astro +0 -24
  95. package/components/navigation/sidebar/Sidebar.astro +0 -132
  96. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  97. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  98. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  99. package/components/overlay/modal/Modal.astro +0 -66
  100. package/utils/controllers/disclosure.ts +0 -117
  101. package/utils/controllers/form.ts +0 -524
  102. package/utils/controllers/index.ts +0 -39
  103. package/utils/controllers/menu.ts +0 -255
  104. package/utils/controllers/number-field.ts +0 -103
  105. package/utils/controllers/otp.ts +0 -252
  106. package/utils/controllers/sidebar.ts +0 -610
  107. package/utils/controllers/slider.ts +0 -336
  108. package/utils/controllers/tags-input.ts +0 -255
  109. package/utils/controllers/toast.ts +0 -426
package/README.md CHANGED
@@ -1,20 +1,13 @@
1
1
  # @pienter/ui
2
2
 
3
- Shared Pienter UI package with framework-specific components, CSS, icons, and browser utilities.
3
+ Shared Pienter UI package with Vue components, CSS, icons, and browser utilities.
4
4
 
5
- This package publishes raw source files. Consuming projects compile the Vue, Astro, TypeScript, and CSS files with their own toolchain.
5
+ This package publishes raw source files. Consuming projects compile the Vue, TypeScript, and CSS files with their own toolchain.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- npm install @pienter/ui
11
- ```
12
-
13
- Install the framework peer you use:
14
-
15
- ```sh
16
- npm install vue
17
- npm install astro
10
+ npm install @pienter/ui vue
18
11
  ```
19
12
 
20
13
  ## Usage
@@ -24,14 +17,6 @@ import Button from '@pienter/ui/components/Button.vue';
24
17
  import '@pienter/ui/styles';
25
18
  ```
26
19
 
27
- Astro components are available beside Vue components:
28
-
29
- ```astro
30
- ---
31
- import Button from '@pienter/ui/components/Button.astro';
32
- ---
33
- ```
34
-
35
20
  Components use explicit flat public entrypoints. Intentional shared types are
36
21
  available through module type entrypoints:
37
22
 
@@ -47,6 +32,36 @@ import { icons } from '@pienter/ui/icons';
47
32
  import { generateId } from '@pienter/ui/utils/a11y/id.js';
48
33
  ```
49
34
 
35
+ ### CMS pages
36
+
37
+ Compose ordinary Vue routes with `Index`, `RecordForm`, and `RecordDetails`:
38
+
39
+ ```ts
40
+ import Index from '@pienter/ui/components/Index.vue';
41
+ import RecordForm from '@pienter/ui/components/RecordForm.vue';
42
+ import RecordFields from '@pienter/ui/components/RecordFields.vue';
43
+ import BlockEditor from '@pienter/ui/components/BlockEditor.vue';
44
+ import RecordDetails from '@pienter/ui/components/RecordDetails.vue';
45
+ import type { RecordFormField } from '@pienter/ui/components/RecordForm.types';
46
+ import { decodeListQuery, encodeListQuery } from '@pienter/ui/utils/cms';
47
+ ```
48
+
49
+ Index receives `load(query, { signal })`, columns and query options. It handles
50
+ loading, search, pagination, errors and retry; `sync-query` enables Vue Router URL
51
+ synchronization. Index requires the `vue-router` package; an installed router
52
+ instance is needed only when synchronization is enabled. RecordForm receives `v-model`, fields and validation issues,
53
+ emitting a JSON payload on `submit`. RecordDetails receives a record and fields.
54
+ Use slots for custom cells, controls, values and actions. Your pages own route
55
+ declarations, endpoint functions and saving. Use RecordForm’s `fields` slot and
56
+ RecordFields to put groups in sidebars and tab panels while retaining one form.
57
+ BlockEditor accepts `v-model`, block creation factories and a `block` slot; it
58
+ provides drag-and-drop, keyboard movement, add/remove and collapse controls.
59
+
60
+ The [CMS guide](../../docs/cms/README.md) contains working examples. The
61
+ [backend contract](../../docs/cms/backend-contract.md) defines query parameters,
62
+ response envelopes and validation paths; its JSON Schema ships at
63
+ `@pienter/ui/utils/cms/schema.json`.
64
+
50
65
  ### Themes
51
66
 
52
67
  The default theme follows the operating-system color preference, using
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ export type { RecordDetailsField } from './types.js';
3
+ </script>
4
+
5
+ <script setup lang="ts" generic="T extends object">
6
+ import { readPath } from '../../../utils/cms/index.js';
7
+ import type { RecordDetailsField } from './types.js';
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ record: T;
12
+ fields: readonly RecordDetailsField[];
13
+ emptyValue?: string;
14
+ }>(),
15
+ { emptyValue: '—' },
16
+ );
17
+
18
+ function valueFor(field: RecordDetailsField): unknown {
19
+ return readPath(props.record, field.path ?? [field.name]);
20
+ }
21
+
22
+ function displayValue(value: unknown): string {
23
+ if (value === undefined || value === null || value === '')
24
+ return props.emptyValue;
25
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
26
+ if (typeof value === 'object') return JSON.stringify(value);
27
+ return String(value);
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="pui-record-details">
33
+ <dl class="pui-record-details__list">
34
+ <div
35
+ v-for="field in fields"
36
+ :key="field.name"
37
+ class="pui-record-details__field"
38
+ >
39
+ <dt class="pui-record-details__label">{{ field.label }}</dt>
40
+ <dd class="pui-record-details__value">
41
+ <slot
42
+ :name="`field:${field.name}`"
43
+ :field="field"
44
+ :value="valueFor(field)"
45
+ :record="record"
46
+ >
47
+ {{ displayValue(valueFor(field)) }}
48
+ </slot>
49
+ </dd>
50
+ </div>
51
+ </dl>
52
+ <slot />
53
+ <div v-if="$slots.actions" class="pui-record-details__actions">
54
+ <slot name="actions" :record="record" />
55
+ </div>
56
+ </div>
57
+ </template>
58
+
59
+ <style>
60
+ @import './record-details.css';
61
+ </style>
@@ -0,0 +1,37 @@
1
+ @layer components {
2
+ .pui-record-details {
3
+ display: grid;
4
+ gap: var(--space-s);
5
+
6
+ & .pui-record-details__list {
7
+ display: grid;
8
+ margin: 0;
9
+ }
10
+
11
+ & .pui-record-details__field {
12
+ display: grid;
13
+ grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
14
+ gap: var(--space-s);
15
+ padding-block: var(--space-xs);
16
+ border-block-end: var(--stroke-sm) solid var(--border-clr-base);
17
+ }
18
+
19
+ & .pui-record-details__label {
20
+ color: var(--text-clr-dim);
21
+ font-size: var(--step--1);
22
+ }
23
+
24
+ & .pui-record-details__value {
25
+ margin: 0;
26
+ min-inline-size: 0;
27
+ overflow-wrap: anywhere;
28
+ white-space: pre-wrap;
29
+ }
30
+
31
+ & .pui-record-details__actions {
32
+ display: flex;
33
+ flex-wrap: wrap;
34
+ gap: var(--space-xs);
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ import type { ValuePath } from '../../../utils/cms/index.js';
2
+
3
+ export interface RecordDetailsField {
4
+ name: string;
5
+ label: string;
6
+ /** Defaults to the literal property name, without splitting dots. */
7
+ path?: ValuePath;
8
+ }
@@ -9,7 +9,7 @@
9
9
  display: flex;
10
10
  flex-direction: column;
11
11
  gap: var(--space-2xs);
12
- z-index: 1000;
12
+ z-index: var(--layer-top);
13
13
  pointer-events: none;
14
14
  max-width: calc(100vw - var(--space-s) * 2);
15
15
  }
@@ -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>