@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
@@ -1,8 +1,14 @@
1
1
  <template>
2
2
  <div ref="fieldRef" class="pui-field" :data-status="computedStatus">
3
- <label v-if="label" class="pui-field__label" :for="inputId">{{
4
- label
5
- }}</label>
3
+ <label v-if="label" class="pui-field__label" :for="inputId"
4
+ >{{ label
5
+ }}<span
6
+ v-if="required"
7
+ class="pui-field__required"
8
+ aria-hidden="true"
9
+ >*</span
10
+ ></label
11
+ >
6
12
  <input
7
13
  v-bind="$attrs"
8
14
  :id="inputId"
@@ -77,7 +83,7 @@
77
83
  v-if="errors.length"
78
84
  :id="errorsId"
79
85
  class="pui-field__hint"
80
- role="alert"
86
+ role="status"
81
87
  >
82
88
  <li v-for="error in errors" :key="error">{{ error }}</li>
83
89
  </ul>
@@ -144,7 +150,7 @@ const props = withDefaults(
144
150
  hint?: string;
145
151
  /**
146
152
  * Validation errors. Renders as a list under the input with
147
- * `role="alert"` and toggles `aria-invalid="true"` plus the
153
+ * `role="status"` and toggles `aria-invalid="true"` plus the
148
154
  * `data-status="error"` styling on `.pui-field`.
149
155
  */
150
156
  errors?: string[];
@@ -278,6 +284,18 @@ const filteredOptions = computed(() => {
278
284
  return props.options.filter((opt) => fn(opt, q));
279
285
  });
280
286
 
287
+ const firstEnabledIndex = computed(() => {
288
+ const index = filteredOptions.value.findIndex((option) => !option.disabled);
289
+ return index >= 0 ? index : null;
290
+ });
291
+
292
+ const lastEnabledIndex = computed(() => {
293
+ for (let index = filteredOptions.value.length - 1; index >= 0; index -= 1) {
294
+ if (!filteredOptions.value[index]?.disabled) return index;
295
+ }
296
+ return null;
297
+ });
298
+
281
299
  const activeOptionDomId = computed(() => {
282
300
  if (activeIndex.value == null) return null;
283
301
  return `${listboxId}-opt-${activeIndex.value}`;
@@ -343,8 +361,7 @@ function onInput(event: Event): void {
343
361
  isTyping.value = true;
344
362
  // First filtered match becomes the active option so Enter has
345
363
  // an obvious target. Skip disabled options.
346
- const firstEnabled = filteredOptions.value.findIndex((o) => !o.disabled);
347
- activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
364
+ activeIndex.value = firstEnabledIndex.value;
348
365
  if (!isOpen.value) open();
349
366
  }
350
367
 
@@ -386,12 +403,7 @@ function onKeydown(event: KeyboardEvent): void {
386
403
  event.preventDefault();
387
404
  if (!isOpen.value) {
388
405
  open();
389
- // Defer active-index move until after the popover opens so
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;
406
+ activeIndex.value = firstEnabledIndex.value;
395
407
  } else {
396
408
  moveActive(1);
397
409
  }
@@ -400,41 +412,22 @@ function onKeydown(event: KeyboardEvent): void {
400
412
  event.preventDefault();
401
413
  if (!isOpen.value) {
402
414
  open();
403
- const lastEnabled = [...filteredOptions.value]
404
- .map((o, i) => ({ o, i }))
405
- .reverse()
406
- .find(({ o }) => !o.disabled);
407
- activeIndex.value = lastEnabled ? lastEnabled.i : null;
415
+ activeIndex.value = lastEnabledIndex.value;
408
416
  } else {
409
417
  moveActive(-1);
410
418
  }
411
419
  break;
412
420
  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
421
  case 'End':
426
422
  if (!isOpen.value) return;
427
423
  event.preventDefault();
428
424
  {
429
- let lastEnabled = -1;
430
- for (let i = filteredOptions.value.length - 1; i >= 0; i -= 1) {
431
- if (!filteredOptions.value[i]?.disabled) {
432
- lastEnabled = i;
433
- break;
434
- }
435
- }
436
- if (lastEnabled >= 0) {
437
- activeIndex.value = lastEnabled;
425
+ const index =
426
+ event.key === 'Home'
427
+ ? firstEnabledIndex.value
428
+ : lastEnabledIndex.value;
429
+ if (index !== null) {
430
+ activeIndex.value = index;
438
431
  scrollActiveIntoView();
439
432
  }
440
433
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Combobox is built on top of the form-primitive scaffold (`.pui-field`
5
5
  * with `.pui-field__label` / `.pui-field__hint` / status-driven recolour)
6
- * owned by `text-input.css`. The input itself uses the shared
6
+ * owned by `styles/4-components/form-field.css`. The input itself uses the shared
7
7
  * `.pui-input` class so its border, padding, focus ring, and
8
8
  * disabled state match TextInput / Textarea exactly.
9
9
  *
@@ -1,6 +1,14 @@
1
1
  <template>
2
2
  <div class="pui-field" :data-status="computedStatus">
3
- <label class="pui-field__label" :for="inputId">{{ label }}</label>
3
+ <label class="pui-field__label" :for="inputId"
4
+ >{{ label
5
+ }}<span
6
+ v-if="required"
7
+ class="pui-field__required"
8
+ aria-hidden="true"
9
+ >*</span
10
+ ></label
11
+ >
4
12
  <input
5
13
  v-bind="$attrs"
6
14
  :id="inputId"
@@ -28,7 +36,7 @@
28
36
  v-if="errors.length"
29
37
  :id="errorsId"
30
38
  class="pui-field__hint"
31
- role="alert"
39
+ role="status"
32
40
  >
33
41
  <li v-for="error in errors" :key="error">{{ error }}</li>
34
42
  </ul>
@@ -1,19 +1,4 @@
1
1
  @layer components {
2
- /* DateInput reuses the shared `.pui-input` rules (sizing, padding,
3
- * border, focus ring, disabled, status-driven recolour) that live
4
- * alongside `.pui-textarea` in `components/form/text-input/text-input.css`
5
- * because the visual shell is identical to TextInput — only the
6
- * native input `type` differs (`date` / `time` / `datetime-local`
7
- * vs `text`). The browser owns the picker UI; we just style the
8
- * input chrome consistently with the rest of the form-primitive
9
- * scaffold.
10
- *
11
- * This file is intentionally a near-empty placeholder following the
12
- * Textarea pattern — every form primitive keeps its own CSS file
13
- * for layered consistency, even when there are no component-specific
14
- * rules. When the fifth-plus form primitive ships and the shared
15
- * `.pui-input` rules outgrow `text-input.css`, they'll move into a
16
- * dedicated `form-field.css` per the deferred-extraction note in
17
- * CONVENTIONS.
18
- */
2
+ /* DateInput reuses `.pui-input` from `styles/4-components/form-field.css`;
3
+ * only the native `type` differs from TextInput. */
19
4
  }
@@ -6,17 +6,15 @@
6
6
  :aria-busy="busy ? 'true' : undefined"
7
7
  @submit.prevent="handleSubmit"
8
8
  >
9
- <div
9
+ <Alert
10
10
  v-if="topErrors.length > 0"
11
- class="pui-form__error-summary"
12
- role="alert"
13
- aria-live="assertive"
11
+ tone="danger"
12
+ title="There was a problem"
14
13
  >
15
- <p class="pui-form__error-summary-title">There was a problem</p>
16
- <ul class="pui-form__error-summary-list">
14
+ <ul class="pui-form__errors">
17
15
  <li v-for="error in topErrors" :key="error">{{ error }}</li>
18
16
  </ul>
19
- </div>
17
+ </Alert>
20
18
 
21
19
  <slot />
22
20
 
@@ -40,9 +38,10 @@
40
38
  </template>
41
39
 
42
40
  <script setup lang="ts">
43
- import { computed, ref, watch } from 'vue';
41
+ import { computed, onMounted, ref, watch } from 'vue';
42
+ import Alert from '../../feedback/alert/Alert.vue';
44
43
  import { focusFirstInvalid } from '../../../utils/a11y/focus.js';
45
- import { announce } from '../../../utils/a11y/live-region.js';
44
+ import { announce, ensureLiveRegion } from '../../../utils/a11y/live-region.js';
46
45
  import { type Validator } from '../../../utils/validation/rules.js';
47
46
  import {
48
47
  validateFormData,
@@ -81,6 +80,8 @@ const emit = defineEmits<{
81
80
 
82
81
  const formRef = ref<HTMLFormElement | null>(null);
83
82
 
83
+ onMounted(ensureLiveRegion);
84
+
84
85
  // Internal client-validation errors. Merged with server-supplied
85
86
  // `fieldErrors` for display via the form-primitive children's own
86
87
  // `errors` prop pattern (consumer wires them up by name); the summary
@@ -5,29 +5,9 @@
5
5
  gap: var(--space-m);
6
6
  }
7
7
 
8
- .pui-form__error-summary {
9
- background: var(--bg-clr-danger-soft);
10
- border: var(--stroke-sm) solid var(--border-clr-danger);
11
- border-radius: var(--radius-sm);
12
- padding: var(--space-s) var(--space-m);
13
- }
14
-
15
- .pui-form__error-summary-title {
16
- font-weight: var(--fw-semibold);
17
- color: var(--text-clr-danger);
18
- margin-bottom: var(--space-xs);
19
- }
20
-
21
- .pui-form__error-summary-list {
22
- list-style: disc inside;
23
- padding: 0;
8
+ .pui-form__errors {
24
9
  margin: 0;
25
- color: var(--text-clr-danger);
26
- font-size: var(--step--1);
27
- }
28
-
29
- .pui-form__error-summary-list li {
30
- margin-block: var(--space-2xs);
10
+ padding-inline-start: var(--space-s);
31
11
  }
32
12
 
33
13
  .pui-form__status {
@@ -34,7 +34,7 @@
34
34
  v-if="errors.length"
35
35
  :id="errorsId"
36
36
  class="pui-field__hint"
37
- role="alert"
37
+ role="status"
38
38
  >
39
39
  <li v-for="error in errors" :key="error">{{ error }}</li>
40
40
  </ul>
@@ -28,7 +28,7 @@
28
28
 
29
29
  .pui-input-otp__slot {
30
30
  /* Inherits sizing, padding, focus-ring, disabled, and status
31
- * recolour from the shared .pui-input rule (text-input.css). The
31
+ * recolour from the shared .pui-input rule (form-field.css). The
32
32
  * overrides here narrow the slot to a square box and centre the
33
33
  * typed character. */
34
34
  width: 2.75rem;
@@ -1,11 +1,2 @@
1
- /*
2
- * Label is a near-empty stylesheet. The actual `.pui-field__label` rules
3
- * live in `components/form/text-input/text-input.css` (the shared form-primitive
4
- * scaffold). Label exposes the same class as a standalone component for
5
- * use cases where the form-primitive components' built-in label is not
6
- * suitable (e.g., labels associated with custom groups or with multiple
7
- * controls). Importing this file re-imports the scaffold so a
8
- * standalone Label on a page that does not also use
9
- * TextInput / Textarea / Select still gets the typography rules.
10
- */
11
- @import '../text-input/text-input.css';
1
+ /* `.pui-field__label` is defined by the scaffold in
2
+ * `styles/4-components/form-field.css`, which the styles entry loads. */
@@ -1,6 +1,14 @@
1
1
  <template>
2
2
  <div class="pui-field" :data-status="computedStatus">
3
- <label class="pui-field__label" :for="inputId">{{ label }}</label>
3
+ <label class="pui-field__label" :for="inputId"
4
+ >{{ label
5
+ }}<span
6
+ v-if="required"
7
+ class="pui-field__required"
8
+ aria-hidden="true"
9
+ >*</span
10
+ ></label
11
+ >
4
12
  <div class="pui-number-field">
5
13
  <input
6
14
  v-bind="$attrs"
@@ -49,7 +57,7 @@
49
57
  v-if="errors.length"
50
58
  :id="errorsId"
51
59
  class="pui-field__hint"
52
- role="alert"
60
+ role="status"
53
61
  >
54
62
  <li v-for="error in errors" :key="error">{{ error }}</li>
55
63
  </ul>
@@ -146,7 +154,6 @@ function handleStep(direction: 'decrement' | 'increment'): void {
146
154
  // listeners observe stepper clicks the same as typed edits.
147
155
  input.dispatchEvent(new Event('input', { bubbles: true }));
148
156
  input.dispatchEvent(new Event('change', { bubbles: true }));
149
- emit('update:modelValue', input.valueAsNumber);
150
157
  }
151
158
  </script>
152
159
 
@@ -2,7 +2,7 @@
2
2
  /* ===== pui-number-field (input + flanking step buttons) =====
3
3
  *
4
4
  * NumberField extends the locked Form-primitive scaffold (the outer
5
- * `.pui-field` wrapper from `text-input.css`) with an inline-flex
5
+ * `.pui-field` wrapper from `styles/4-components/form-field.css`) with an inline-flex
6
6
  * row that hosts the native `<input type="number">` on the left and
7
7
  * two stacked step buttons on the right. The native browser spinner
8
8
  * is suppressed via `appearance: textfield` (Webkit / Firefox)
@@ -24,7 +24,7 @@
24
24
 
25
25
  .pui-number-field__input {
26
26
  /* Inherits sizing, padding, focus-ring, disabled, and status
27
- * recolour from the shared .pui-input rule (text-input.css). The
27
+ * recolour from the shared .pui-input rule (form-field.css). The
28
28
  * overrides here only adjust the right-side border radii so the
29
29
  * input segment butts cleanly against the stepper column. */
30
30
  flex: 1;
@@ -102,7 +102,7 @@
102
102
  /* Status states inherited from .pui-field — recolour BOTH the input
103
103
  * and the step buttons so the whole control reads as errored /
104
104
  * succeeded. Mirrors the .pui-input / .pui-textarea status recolour in
105
- * text-input.css. */
105
+ * form-field.css. */
106
106
  .pui-field[data-status='error'] .pui-number-field__input,
107
107
  .pui-field[data-status='error'] .pui-number-field__step {
108
108
  border-color: var(--border-clr-danger);
@@ -40,7 +40,7 @@
40
40
  v-if="errors.length"
41
41
  :id="errorsId"
42
42
  class="pui-field__hint"
43
- role="alert"
43
+ role="status"
44
44
  >
45
45
  <li v-for="error in errors" :key="error">{{ error }}</li>
46
46
  </ul>
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ @layer components {
2
+ .pui-record-form {
3
+ gap: var(--space-s);
4
+ }
5
+
6
+ .pui-record-fields {
7
+ display: grid;
8
+ grid-template-columns: var(
9
+ --pui-record-fields-columns,
10
+ var(--pui-record-form-columns, minmax(0, 1fr))
11
+ );
12
+ gap: var(--space-s);
13
+ min-inline-size: 0;
14
+ }
15
+ }