@pienter/ui 0.5.0 → 0.8.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/CONVENTIONS.md +91 -28
  3. package/README.md +66 -0
  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.vue +3 -4
  8. package/components/feedback/toast/ToastHost.vue +165 -0
  9. package/components/feedback/toast/toast.ts +116 -0
  10. package/components/feedback/toast/types.ts +48 -0
  11. package/components/form/block-editor/BlockEditor.vue +455 -0
  12. package/components/form/block-editor/block-editor.css +149 -0
  13. package/components/form/block-editor/types.ts +15 -0
  14. package/components/form/checkbox/Checkbox.vue +10 -2
  15. package/components/form/combobox/Combobox.vue +32 -39
  16. package/components/form/combobox/combobox.css +1 -1
  17. package/components/form/date-input/DateInput.vue +10 -2
  18. package/components/form/date-input/date-input.css +2 -17
  19. package/components/form/form/Form.vue +10 -9
  20. package/components/form/form/form.css +2 -22
  21. package/components/form/input-otp/InputOTP.vue +1 -1
  22. package/components/form/input-otp/input-otp.css +1 -1
  23. package/components/form/label/label.css +2 -11
  24. package/components/form/number-field/NumberField.vue +10 -3
  25. package/components/form/number-field/number-field.css +3 -3
  26. package/components/form/radio-group/RadioGroup.vue +1 -1
  27. package/components/form/record-form/RecordFields.vue +128 -0
  28. package/components/form/record-form/RecordForm.vue +116 -0
  29. package/components/form/record-form/fields.ts +20 -0
  30. package/components/form/record-form/record-form.css +15 -0
  31. package/components/form/record-form/types.ts +28 -0
  32. package/components/form/select/Select.vue +13 -4
  33. package/components/form/select/select.css +6 -9
  34. package/components/form/slider/Slider.vue +1 -1
  35. package/components/form/switch/Switch.vue +1 -1
  36. package/components/form/tags-input/TagsInput.vue +17 -2
  37. package/components/form/tags-input/tags-input.css +16 -12
  38. package/components/form/text-input/TextInput.vue +10 -2
  39. package/components/form/text-input/text-input.css +10 -129
  40. package/components/form/textarea/Textarea.vue +10 -2
  41. package/components/form/textarea/textarea.css +3 -19
  42. package/components/layout/app-layout/AppLayout.vue +116 -0
  43. package/components/layout/app-layout/app-layout.css +115 -0
  44. package/components/layout/index/Index.vue +373 -0
  45. package/components/layout/index/index.css +157 -0
  46. package/components/layout/index/useIndex.ts +407 -0
  47. package/components/layout/table/DataTable.vue +14 -1
  48. package/components/layout/table/Table.vue +12 -0
  49. package/components/layout/table/table.css +2 -1
  50. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  51. package/components/navigation/breadcrumb/breadcrumb.css +20 -0
  52. package/components/navigation/sidebar/Sidebar.vue +11 -8
  53. package/components/navigation/sidebar/sidebar.css +23 -16
  54. package/components/navigation/tabs/Tabs.vue +6 -0
  55. package/components/navigation/tabs/tabs.css +7 -7
  56. package/composables/useMenu.ts +20 -27
  57. package/package.json +17 -2
  58. package/styles/0-settings/colors.css +10 -0
  59. package/styles/4-components/form-field.css +112 -0
  60. package/styles/4-components/index.css +1 -0
  61. package/styles/main.css +1 -0
  62. package/utils/a11y/focus.ts +9 -3
  63. package/utils/a11y/index.ts +5 -1
  64. package/utils/a11y/live-region.ts +2 -1
  65. package/utils/cms/index.ts +283 -0
  66. package/utils/cms/schema.json +126 -0
@@ -0,0 +1,455 @@
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
+ Use the move buttons to reorder, or drag with a pointer.
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
+ tabindex="-1"
339
+ :disabled="disabled || modelValue.length < 2"
340
+ :aria-label="`Drag ${title(block, index)}`"
341
+ :aria-describedby="`${id}-hint`"
342
+ @pointerdown="startDrag($event, block.id)"
343
+ >
344
+ <Icon name="arrow-up-down" size="sm" />
345
+ </button>
346
+ <div class="pui-block-editor__heading">
347
+ <button
348
+ type="button"
349
+ class="pui-block-editor__toggle"
350
+ data-block-toggle
351
+ :disabled="disabled"
352
+ :aria-label="`${collapsed.has(block.id) ? 'Expand' : 'Collapse'} ${title(block, index)}`"
353
+ :aria-expanded="
354
+ collapsed.has(block.id) ? 'false' : 'true'
355
+ "
356
+ :aria-controls="bodyId(block)"
357
+ @click="toggle(block.id)"
358
+ >
359
+ <Icon
360
+ :name="
361
+ collapsed.has(block.id)
362
+ ? 'chevron-right'
363
+ : 'chevron-down'
364
+ "
365
+ size="sm"
366
+ />
367
+ <span class="pui-block-editor__title">{{
368
+ title(block, index)
369
+ }}</span>
370
+ <span
371
+ v-if="title(block, index) !== typeLabel(block)"
372
+ class="pui-block-editor__type"
373
+ >{{ typeLabel(block) }}</span
374
+ >
375
+ </button>
376
+ <div
377
+ v-if="$slots.summary"
378
+ class="pui-block-editor__summary"
379
+ >
380
+ <slot
381
+ name="summary"
382
+ :block="block"
383
+ :index="index"
384
+ />
385
+ </div>
386
+ </div>
387
+ <div class="pui-block-editor__actions">
388
+ <IconButton
389
+ name="arrow-up"
390
+ variant="ghost"
391
+ size="sm"
392
+ :label="`Move ${title(block, index)} up`"
393
+ :disabled="disabled || index === 0"
394
+ @click="move(block.id, index - 1)"
395
+ />
396
+ <IconButton
397
+ name="arrow-down"
398
+ variant="ghost"
399
+ size="sm"
400
+ :label="`Move ${title(block, index)} down`"
401
+ :disabled="
402
+ disabled || index === modelValue.length - 1
403
+ "
404
+ @click="move(block.id, index + 1)"
405
+ />
406
+ <IconButton
407
+ name="trash-2"
408
+ variant="ghost"
409
+ size="sm"
410
+ :label="`Remove ${title(block, index)}`"
411
+ :disabled="disabled"
412
+ @click="remove(block.id)"
413
+ />
414
+ </div>
415
+ </div>
416
+ <div
417
+ :id="bodyId(block)"
418
+ :hidden="collapsed.has(block.id)"
419
+ class="pui-block-editor__body"
420
+ :inert="disabled ? true : undefined"
421
+ >
422
+ <slot
423
+ name="block"
424
+ :block="block"
425
+ :index="index"
426
+ :update="
427
+ (patch: BlockPatch<T>) => update(block.id, patch)
428
+ "
429
+ :disabled="disabled"
430
+ />
431
+ </div>
432
+ </li>
433
+ </ol>
434
+ <div v-else class="pui-block-editor__empty">
435
+ <slot name="empty"
436
+ >No blocks yet. Add a block to start writing.</slot
437
+ >
438
+ </div>
439
+ <div :id="`${id}-add`" class="pui-block-editor__add">
440
+ <Button
441
+ v-for="type in blockTypes"
442
+ :key="type.type"
443
+ icon="plus"
444
+ size="sm"
445
+ :disabled="disabled"
446
+ @click="add(type)"
447
+ >Add {{ type.label }}</Button
448
+ >
449
+ </div>
450
+ </div>
451
+ </template>
452
+
453
+ <style>
454
+ @import './block-editor.css';
455
+ </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
+ >;
@@ -19,13 +19,21 @@
19
19
  )
20
20
  "
21
21
  />
22
- <label class="pui-field__label" :for="inputId">{{ label }}</label>
22
+ <label class="pui-field__label" :for="inputId"
23
+ >{{ label
24
+ }}<span
25
+ v-if="required"
26
+ class="pui-field__required"
27
+ aria-hidden="true"
28
+ >*</span
29
+ ></label
30
+ >
23
31
  <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
24
32
  <ul
25
33
  v-if="errors.length"
26
34
  :id="errorsId"
27
35
  class="pui-field__hint"
28
- role="alert"
36
+ role="status"
29
37
  >
30
38
  <li v-for="error in errors" :key="error">{{ error }}</li>
31
39
  </ul>