@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
@@ -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
+ >;
@@ -85,12 +85,8 @@
85
85
  </template>
86
86
 
87
87
  <script setup lang="ts">
88
- import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
89
- import {
90
- mountPopover,
91
- type PopoverControl,
92
- type Placement,
93
- } from '../../../utils/controllers/popover.js';
88
+ import { computed, ref, watch } from 'vue';
89
+ import { usePopover, type Placement } from '../../../composables/usePopover.js';
94
90
  import { generateId } from '../../../utils/a11y/id.js';
95
91
 
96
92
  defineOptions({ inheritAttrs: false });
@@ -218,7 +214,25 @@ const activeIndex = ref<number | null>(null);
218
214
  // label of the currently-selected option.
219
215
  const isTyping = ref(false);
220
216
 
221
- let control: PopoverControl | null = null;
217
+ // Anchor the listbox to the input itself so positioning tracks the
218
+ // input's box (the input is the visual reference, not the wrapper).
219
+ // `modal: false` → `popover="auto"` so the browser handles
220
+ // outside-click / focus-out / Escape light-dismiss. The listbox
221
+ // closes naturally when the user tabs or clicks away.
222
+ const control = usePopover(listboxRef, {
223
+ anchor: inputRef,
224
+ placement: () => props.placement,
225
+ offset: () => props.offset,
226
+ onOpen: () => {
227
+ isOpen.value = true;
228
+ emit('open-change', true);
229
+ },
230
+ onClose: () => {
231
+ isOpen.value = false;
232
+ activeIndex.value = null;
233
+ emit('open-change', false);
234
+ },
235
+ });
222
236
 
223
237
  const hasErrors = computed(() => props.errors.length > 0);
224
238
 
@@ -264,6 +278,18 @@ const filteredOptions = computed(() => {
264
278
  return props.options.filter((opt) => fn(opt, q));
265
279
  });
266
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
+
267
293
  const activeOptionDomId = computed(() => {
268
294
  if (activeIndex.value == null) return null;
269
295
  return `${listboxId}-opt-${activeIndex.value}`;
@@ -271,12 +297,10 @@ const activeOptionDomId = computed(() => {
271
297
 
272
298
  function open(): void {
273
299
  if (props.disabled) return;
274
- if (!control) return;
275
300
  control.show();
276
301
  }
277
302
 
278
303
  function close(): void {
279
- if (!control) return;
280
304
  control.hide();
281
305
  }
282
306
 
@@ -331,8 +355,7 @@ function onInput(event: Event): void {
331
355
  isTyping.value = true;
332
356
  // First filtered match becomes the active option so Enter has
333
357
  // an obvious target. Skip disabled options.
334
- const firstEnabled = filteredOptions.value.findIndex((o) => !o.disabled);
335
- activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
358
+ activeIndex.value = firstEnabledIndex.value;
336
359
  if (!isOpen.value) open();
337
360
  }
338
361
 
@@ -374,12 +397,7 @@ function onKeydown(event: KeyboardEvent): void {
374
397
  event.preventDefault();
375
398
  if (!isOpen.value) {
376
399
  open();
377
- // Defer active-index move until after the popover opens so
378
- // the listbox content is visible to scroll into view.
379
- const firstEnabled = filteredOptions.value.findIndex(
380
- (o) => !o.disabled,
381
- );
382
- activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
400
+ activeIndex.value = firstEnabledIndex.value;
383
401
  } else {
384
402
  moveActive(1);
385
403
  }
@@ -388,41 +406,22 @@ function onKeydown(event: KeyboardEvent): void {
388
406
  event.preventDefault();
389
407
  if (!isOpen.value) {
390
408
  open();
391
- const lastEnabled = [...filteredOptions.value]
392
- .map((o, i) => ({ o, i }))
393
- .reverse()
394
- .find(({ o }) => !o.disabled);
395
- activeIndex.value = lastEnabled ? lastEnabled.i : null;
409
+ activeIndex.value = lastEnabledIndex.value;
396
410
  } else {
397
411
  moveActive(-1);
398
412
  }
399
413
  break;
400
414
  case 'Home':
401
- if (!isOpen.value) return;
402
- event.preventDefault();
403
- {
404
- const firstEnabled = filteredOptions.value.findIndex(
405
- (o) => !o.disabled,
406
- );
407
- if (firstEnabled >= 0) {
408
- activeIndex.value = firstEnabled;
409
- scrollActiveIntoView();
410
- }
411
- }
412
- break;
413
415
  case 'End':
414
416
  if (!isOpen.value) return;
415
417
  event.preventDefault();
416
418
  {
417
- let lastEnabled = -1;
418
- for (let i = filteredOptions.value.length - 1; i >= 0; i -= 1) {
419
- if (!filteredOptions.value[i]?.disabled) {
420
- lastEnabled = i;
421
- break;
422
- }
423
- }
424
- if (lastEnabled >= 0) {
425
- activeIndex.value = lastEnabled;
419
+ const index =
420
+ event.key === 'Home'
421
+ ? firstEnabledIndex.value
422
+ : lastEnabledIndex.value;
423
+ if (index !== null) {
424
+ activeIndex.value = index;
426
425
  scrollActiveIntoView();
427
426
  }
428
427
  }
@@ -463,30 +462,6 @@ function onKeydown(event: KeyboardEvent): void {
463
462
  }
464
463
  }
465
464
 
466
- onMounted(() => {
467
- if (!listboxRef.value || !inputRef.value) return;
468
- control = mountPopover(listboxRef.value, {
469
- // Anchor the listbox to the input itself so positioning
470
- // tracks the input's box (the input is the visual reference,
471
- // not the wrapper).
472
- anchor: inputRef.value,
473
- placement: props.placement,
474
- offset: props.offset,
475
- // `modal: false` → `popover="auto"` so the browser handles
476
- // outside-click / focus-out / Escape light-dismiss. The
477
- // listbox closes naturally when the user tabs or clicks away.
478
- onOpen: () => {
479
- isOpen.value = true;
480
- emit('open-change', true);
481
- },
482
- onClose: () => {
483
- isOpen.value = false;
484
- activeIndex.value = null;
485
- emit('open-change', false);
486
- },
487
- });
488
- });
489
-
490
465
  // Watch model changes from the consumer and reset typing state so
491
466
  // the input mirrors the new selection's label cleanly.
492
467
  watch(
@@ -496,11 +471,6 @@ watch(
496
471
  query.value = '';
497
472
  },
498
473
  );
499
-
500
- onBeforeUnmount(() => {
501
- control?.teardown();
502
- control = null;
503
- });
504
474
  </script>
505
475
 
506
476
  <style>
@@ -8,7 +8,7 @@
8
8
  * disabled state match TextInput / Textarea exactly.
9
9
  *
10
10
  * The listbox is a separate element (a `<ul popover="auto">`) the
11
- * controller positions via `mountPopover`. CSS resets the
11
+ * composable positions via `usePopover`. CSS resets the
12
12
  * user-agent popover defaults (`margin: 0; border: 0; inset: auto`)
13
13
  * and supplies the surface look — the Popover API places the
14
14
  * element in the top layer; inline `left`/`top` from the controller
@@ -131,8 +131,7 @@ function handleSubmit(): void {
131
131
 
132
132
  // Validation passed. Clear any lingering client-side errors from a
133
133
  // prior failed submit so child form-primitives don't keep displaying
134
- // them while the consumer's async submit runs (mirrors the Astro
135
- // controller's `clearAllErrors(el)` between client-pass and onSubmit).
134
+ // them while the consumer's async submit runs.
136
135
  clientFieldErrors.value = {};
137
136
 
138
137
  // Surface FormData to the consumer's submit handler; consumer manages
@@ -5,7 +5,7 @@
5
5
  * use cases where the form-primitive components' built-in label is not
6
6
  * suitable (e.g., labels associated with custom groups or with multiple
7
7
  * controls). Importing this file re-imports the scaffold so a
8
- * standalone Label dragged into an Astro page that does not also use
8
+ * standalone Label on a page that does not also use
9
9
  * TextInput / Textarea / Select still gets the typography rules.
10
10
  */
11
11
  @import '../text-input/text-input.css';
@@ -143,10 +143,9 @@ function handleStep(direction: 'decrement' | 'increment'): void {
143
143
  input.stepDown();
144
144
  }
145
145
  // Dispatch native input/change events so consumer-attached DOM
146
- // listeners observe the same behavior as the Astro controller path.
146
+ // listeners observe stepper clicks the same as typed edits.
147
147
  input.dispatchEvent(new Event('input', { bubbles: true }));
148
148
  input.dispatchEvent(new Event('change', { bubbles: true }));
149
- emit('update:modelValue', input.valueAsNumber);
150
149
  }
151
150
  </script>
152
151
 
@@ -90,7 +90,7 @@
90
90
  /* Lift the focused button above its neighbours so the outline
91
91
  * isn't clipped by the adjacent button's edge. */
92
92
  position: relative;
93
- z-index: 1;
93
+ z-index: var(--layer-1);
94
94
  }
95
95
 
96
96
  .pui-number-field__step:disabled {
@@ -8,11 +8,8 @@
8
8
  :aria-describedby="describedBy"
9
9
  :aria-invalid="hasErrors ? 'true' : undefined"
10
10
  >
11
- <!-- aria-invalid lives on the <fieldset> (group-level signal) AND, when the
12
- Astro mountForm controller is wired, on the first radio. The redundancy
13
- is intentional — see ./AUDIT.md "ARIA
14
- expectations" for the rationale. Do NOT remove either binding without
15
- coordinating with the form controller. -->
11
+ <!-- aria-invalid lives on the <fieldset>: a group-level signal see
12
+ ./AUDIT.md "ARIA expectations" for the rationale. -->
16
13
  <legend class="pui-field__label">{{ label }}</legend>
17
14
  <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
18
15
  <div class="pui-radio-group" :data-orientation="orientation">
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ export type { RecordFormField } from './types.js';
3
+ </script>
4
+
5
+ <script setup lang="ts" generic="T extends object">
6
+ import { computed } from 'vue';
7
+ import TextInput from '../text-input/TextInput.vue';
8
+ import PuiTextarea from '../textarea/Textarea.vue';
9
+ import PuiSelect from '../select/Select.vue';
10
+ import Checkbox from '../checkbox/Checkbox.vue';
11
+ import {
12
+ readPath,
13
+ updatePath,
14
+ type ValidationIssue,
15
+ type ValuePath,
16
+ } from '../../../utils/cms/index.js';
17
+ import type { RecordFormField } from './types.js';
18
+ import { pathFor, messagesFor } from './fields.js';
19
+
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ modelValue: T;
23
+ fields: readonly RecordFormField[];
24
+ issues?: readonly ValidationIssue[];
25
+ busy?: boolean;
26
+ }>(),
27
+ { issues: () => [], busy: false },
28
+ );
29
+ const emit = defineEmits<{
30
+ 'update:modelValue': [value: T];
31
+ 'field-change': [path: ValuePath];
32
+ }>();
33
+ const fieldsWithErrors = computed(() =>
34
+ props.fields.map((field) => ({
35
+ field,
36
+ errors: messagesFor(field, props.issues),
37
+ })),
38
+ );
39
+
40
+ function valueFor(field: RecordFormField): unknown {
41
+ return readPath(props.modelValue, pathFor(field));
42
+ }
43
+ function textFor(field: RecordFormField): string {
44
+ const value = valueFor(field);
45
+ return typeof value === 'string' ? value : '';
46
+ }
47
+ function update(field: RecordFormField, value: unknown): void {
48
+ if (props.busy || field.disabled) return;
49
+ const path = pathFor(field);
50
+ emit('update:modelValue', updatePath(props.modelValue, path, value));
51
+ emit('field-change', path);
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <div class="pui-record-fields">
57
+ <template
58
+ v-for="{ field, errors } in fieldsWithErrors"
59
+ :key="field.name"
60
+ >
61
+ <slot name="before-field" :field="field" />
62
+ <slot
63
+ :name="`field:${field.name}`"
64
+ :field="field"
65
+ :value="valueFor(field)"
66
+ :update="(value: unknown) => update(field, value)"
67
+ :errors="errors"
68
+ :disabled="busy || !!field.disabled"
69
+ >
70
+ <PuiTextarea
71
+ v-if="field.type === 'textarea'"
72
+ :label="field.label"
73
+ :name="JSON.stringify(pathFor(field))"
74
+ :model-value="textFor(field)"
75
+ :hint="field.hint"
76
+ :required="field.required"
77
+ :disabled="busy || field.disabled"
78
+ :placeholder="field.placeholder"
79
+ :rows="field.rows"
80
+ :errors="errors"
81
+ @update:model-value="update(field, $event)"
82
+ />
83
+ <PuiSelect
84
+ v-else-if="field.type === 'select'"
85
+ :label="field.label"
86
+ :name="JSON.stringify(pathFor(field))"
87
+ :model-value="textFor(field)"
88
+ :hint="field.hint"
89
+ :required="field.required"
90
+ :disabled="busy || field.disabled"
91
+ :placeholder="field.placeholder"
92
+ :options="field.options"
93
+ :errors="errors"
94
+ @update:model-value="update(field, $event)"
95
+ />
96
+ <Checkbox
97
+ v-else-if="field.type === 'checkbox'"
98
+ :label="field.label"
99
+ :name="JSON.stringify(pathFor(field))"
100
+ :model-value="valueFor(field) === true"
101
+ :hint="field.hint"
102
+ :required="field.required"
103
+ :disabled="busy || field.disabled"
104
+ :errors="errors"
105
+ @update:model-value="update(field, $event)"
106
+ />
107
+ <TextInput
108
+ v-else-if="field.type !== 'custom'"
109
+ :label="field.label"
110
+ :name="JSON.stringify(pathFor(field))"
111
+ :model-value="textFor(field)"
112
+ :type="field.type ?? 'text'"
113
+ :hint="field.hint"
114
+ :required="field.required"
115
+ :disabled="busy || field.disabled"
116
+ :placeholder="field.placeholder"
117
+ :autocomplete="field.autocomplete"
118
+ :errors="errors"
119
+ @update:model-value="update(field, $event)"
120
+ />
121
+ </slot>
122
+ </template>
123
+ </div>
124
+ </template>
125
+
126
+ <style>
127
+ @import './record-form.css';
128
+ </style>
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ export type { RecordFormField } from './types.js';
3
+ </script>
4
+
5
+ <script setup lang="ts" generic="T extends object">
6
+ import { computed } from 'vue';
7
+ import Button from '../../action/button/Button.vue';
8
+ import Form from '../form/Form.vue';
9
+ import RecordFields from './RecordFields.vue';
10
+ import {
11
+ cloneRecord,
12
+ type ValidationIssue,
13
+ type ValuePath,
14
+ } from '../../../utils/cms/index.js';
15
+ import { pathFor, containsPath, messagesFor } from './fields.js';
16
+ import type { RecordFormField } from './types.js';
17
+
18
+ const props = withDefaults(
19
+ defineProps<{
20
+ /** Caller-owned write payload; every supplied property is retained. */
21
+ modelValue: T;
22
+ fields: readonly RecordFormField[];
23
+ issues?: readonly ValidationIssue[];
24
+ errors?: string[];
25
+ busy?: boolean;
26
+ submitLabel?: string;
27
+ statusMessage?: string;
28
+ }>(),
29
+ {
30
+ issues: () => [],
31
+ errors: () => [],
32
+ busy: false,
33
+ submitLabel: 'Save changes',
34
+ statusMessage: undefined,
35
+ },
36
+ );
37
+
38
+ const emit = defineEmits<{
39
+ 'update:modelValue': [value: T];
40
+ 'field-change': [path: ValuePath];
41
+ submit: [value: T];
42
+ }>();
43
+
44
+ const fieldErrors = computed(() =>
45
+ Object.fromEntries(
46
+ props.fields.map((field) => [
47
+ JSON.stringify(pathFor(field)),
48
+ messagesFor(field, props.issues),
49
+ ]),
50
+ ),
51
+ );
52
+
53
+ const summary = computed(() => [
54
+ ...props.errors,
55
+ ...props.issues.flatMap((issue) => {
56
+ const field = props.fields.find((field) =>
57
+ containsPath(pathFor(field), issue.path),
58
+ );
59
+ const label = field?.label ?? issue.path.join(' / ');
60
+ return issue.messages.map((message) =>
61
+ label ? `${label}: ${message}` : message,
62
+ );
63
+ }),
64
+ ]);
65
+
66
+ function submit(): void {
67
+ if (!props.busy) emit('submit', cloneRecord(props.modelValue));
68
+ }
69
+ </script>
70
+
71
+ <template>
72
+ <Form
73
+ class="pui-record-form"
74
+ :busy="busy"
75
+ :errors="summary"
76
+ :field-errors="fieldErrors"
77
+ :status-message="statusMessage"
78
+ @submit="submit"
79
+ >
80
+ <slot name="fields">
81
+ <RecordFields
82
+ class="pui-record-form__fields"
83
+ :model-value="modelValue"
84
+ :fields="fields"
85
+ :issues="issues"
86
+ :busy="busy"
87
+ @update:model-value="emit('update:modelValue', $event)"
88
+ @field-change="emit('field-change', $event)"
89
+ >
90
+ <template
91
+ v-for="name in Object.keys($slots).filter(
92
+ (name) =>
93
+ name === 'before-field' ||
94
+ name.startsWith('field:'),
95
+ )"
96
+ :key="name"
97
+ #[name]="scope"
98
+ >
99
+ <slot :name="name" v-bind="scope" />
100
+ </template>
101
+ </RecordFields>
102
+ </slot>
103
+ <slot />
104
+ <template #actions>
105
+ <slot name="actions" :busy="busy">
106
+ <Button type="submit" variant="primary" :loading="busy">
107
+ {{ submitLabel }}
108
+ </Button>
109
+ </slot>
110
+ </template>
111
+ </Form>
112
+ </template>
113
+
114
+ <style>
115
+ @import './record-form.css';
116
+ </style>
@@ -0,0 +1,20 @@
1
+ import type { ValidationIssue, ValuePath } from '../../../utils/cms/index.js';
2
+ import type { RecordFormField } from './types.js';
3
+
4
+ export function pathFor(field: RecordFormField): ValuePath {
5
+ return field.path ?? [field.name];
6
+ }
7
+ export function containsPath(parent: ValuePath, child: ValuePath): boolean {
8
+ return (
9
+ parent.length <= child.length &&
10
+ parent.every((part, index) => part === child[index])
11
+ );
12
+ }
13
+ export function messagesFor(
14
+ field: RecordFormField,
15
+ issues: readonly ValidationIssue[],
16
+ ): string[] {
17
+ return issues
18
+ .filter((issue) => containsPath(pathFor(field), issue.path))
19
+ .flatMap((issue) => issue.messages);
20
+ }