@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.
- package/CHANGELOG.md +149 -0
- package/CONVENTIONS.md +91 -28
- package/README.md +66 -0
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/feedback/toast/Toast.vue +3 -4
- package/components/feedback/toast/ToastHost.vue +165 -0
- package/components/feedback/toast/toast.ts +116 -0
- package/components/feedback/toast/types.ts +48 -0
- package/components/form/block-editor/BlockEditor.vue +455 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/checkbox/Checkbox.vue +10 -2
- package/components/form/combobox/Combobox.vue +32 -39
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/date-input/DateInput.vue +10 -2
- package/components/form/date-input/date-input.css +2 -17
- package/components/form/form/Form.vue +10 -9
- package/components/form/form/form.css +2 -22
- package/components/form/input-otp/InputOTP.vue +1 -1
- package/components/form/input-otp/input-otp.css +1 -1
- package/components/form/label/label.css +2 -11
- package/components/form/number-field/NumberField.vue +10 -3
- package/components/form/number-field/number-field.css +3 -3
- package/components/form/radio-group/RadioGroup.vue +1 -1
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/select/Select.vue +13 -4
- package/components/form/select/select.css +6 -9
- package/components/form/slider/Slider.vue +1 -1
- package/components/form/switch/Switch.vue +1 -1
- package/components/form/tags-input/TagsInput.vue +17 -2
- package/components/form/tags-input/tags-input.css +16 -12
- package/components/form/text-input/TextInput.vue +10 -2
- package/components/form/text-input/text-input.css +10 -129
- package/components/form/textarea/Textarea.vue +10 -2
- package/components/form/textarea/textarea.css +3 -19
- package/components/layout/app-layout/AppLayout.vue +116 -0
- package/components/layout/app-layout/app-layout.css +115 -0
- package/components/layout/index/Index.vue +373 -0
- package/components/layout/index/index.css +157 -0
- package/components/layout/index/useIndex.ts +407 -0
- package/components/layout/table/DataTable.vue +14 -1
- package/components/layout/table/Table.vue +12 -0
- package/components/layout/table/table.css +2 -1
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +20 -0
- package/components/navigation/sidebar/Sidebar.vue +11 -8
- package/components/navigation/sidebar/sidebar.css +23 -16
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/navigation/tabs/tabs.css +7 -7
- package/composables/useMenu.ts +20 -27
- package/package.json +17 -2
- package/styles/0-settings/colors.css +10 -0
- package/styles/4-components/form-field.css +112 -0
- package/styles/4-components/index.css +1 -0
- package/styles/main.css +1 -0
- package/utils/a11y/focus.ts +9 -3
- package/utils/a11y/index.ts +5 -1
- package/utils/a11y/live-region.ts +2 -1
- package/utils/cms/index.ts +283 -0
- 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
|
-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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 `
|
|
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"
|
|
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="
|
|
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
|
|
3
|
-
|
|
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
|
-
<
|
|
9
|
+
<Alert
|
|
10
10
|
v-if="topErrors.length > 0"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
aria-live="assertive"
|
|
11
|
+
tone="danger"
|
|
12
|
+
title="There was a problem"
|
|
14
13
|
>
|
|
15
|
-
<
|
|
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
|
-
</
|
|
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-
|
|
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
|
-
|
|
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 {
|
|
@@ -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 (
|
|
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
|
-
*
|
|
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"
|
|
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="
|
|
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 `
|
|
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 (
|
|
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
|
-
*
|
|
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);
|
|
@@ -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
|
+
}
|