@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.
- package/CHANGELOG.md +258 -0
- package/CONVENTIONS.md +342 -385
- package/README.md +33 -18
- 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.css +1 -1
- package/components/form/block-editor/BlockEditor.vue +454 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/combobox/Combobox.vue +42 -72
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/form/Form.vue +1 -2
- package/components/form/label/label.css +1 -1
- package/components/form/number-field/NumberField.vue +1 -2
- package/components/form/number-field/number-field.css +1 -1
- package/components/form/radio-group/RadioGroup.vue +2 -5
- 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/slider/slider.css +2 -3
- package/components/form/tags-input/tags-input.css +1 -2
- package/components/form/text-input/text-input.css +2 -0
- package/components/form/textarea/textarea.css +1 -1
- package/components/layout/accordion/Accordion.vue +6 -27
- package/components/layout/collapsible/Collapsible.vue +9 -19
- package/components/layout/index/Index.vue +353 -0
- package/components/layout/index/index.css +114 -0
- package/components/layout/index/useIndex.ts +390 -0
- package/components/layout/table/table.css +5 -4
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +15 -0
- package/components/navigation/sidebar/Sidebar.vue +329 -84
- package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
- package/components/navigation/sidebar/context.ts +21 -0
- package/components/navigation/sidebar/sidebar.css +346 -78
- package/components/navigation/sidebar/types.ts +13 -1
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
- package/components/overlay/command/Command.vue +10 -38
- package/components/overlay/command/command.css +1 -1
- package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
- package/components/overlay/modal/Modal.vue +7 -28
- package/components/overlay/popover/Popover.vue +9 -31
- package/components/overlay/sheet/Sheet.vue +7 -28
- package/components/overlay/tooltip/Tooltip.vue +14 -19
- package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
- package/composables/useDisclosure.ts +113 -0
- package/composables/useEventListener.ts +16 -0
- package/composables/useMenu.ts +212 -0
- package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
- package/package.json +16 -40
- package/styles/0-settings/colors.css +10 -0
- package/styles/0-settings/layout.css +18 -0
- package/styles/0-settings/motion.css +2 -2
- package/styles/0-settings/spacing.css +3 -1
- package/utils/a11y/focus.ts +9 -3
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
- package/utils/navigation/sidebar.ts +97 -0
- package/utils/validation/form.ts +6 -9
- package/components/action/button/Button.astro +0 -95
- package/components/action/button/IconButton.astro +0 -86
- package/components/display/avatar/Avatar.astro +0 -17
- package/components/display/avatar/AvatarStack.astro +0 -9
- package/components/display/badge/Badge.astro +0 -15
- package/components/display/empty/Empty.astro +0 -9
- package/components/display/icon/Icon.astro +0 -52
- package/components/feedback/alert/Alert.astro +0 -52
- package/components/feedback/progress/Progress.astro +0 -68
- package/components/feedback/skeleton/Skeleton.astro +0 -32
- package/components/feedback/spinner/Spinner.astro +0 -25
- package/components/feedback/toast/Toast.astro +0 -50
- package/components/form/checkbox/Checkbox.astro +0 -79
- package/components/form/date-input/DateInput.astro +0 -105
- package/components/form/form/Form.astro +0 -106
- package/components/form/input-otp/InputOTP.astro +0 -147
- package/components/form/label/Label.astro +0 -13
- package/components/form/number-field/NumberField.astro +0 -142
- package/components/form/radio-group/RadioGroup.astro +0 -105
- package/components/form/select/Select.astro +0 -105
- package/components/form/slider/Slider.astro +0 -205
- package/components/form/switch/Switch.astro +0 -75
- package/components/form/tags-input/TagsInput.astro +0 -153
- package/components/form/text-input/TextInput.astro +0 -84
- package/components/form/textarea/Textarea.astro +0 -86
- package/components/layout/card/Card.astro +0 -13
- package/components/layout/separator/Separator.astro +0 -31
- package/components/layout/table/Table.astro +0 -116
- package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
- package/components/navigation/navbar/Navbar.astro +0 -62
- package/components/navigation/pagination/PaginationFooter.astro +0 -24
- package/components/navigation/sidebar/Sidebar.astro +0 -132
- package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
- package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
- package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
- package/components/overlay/modal/Modal.astro +0 -66
- package/utils/controllers/disclosure.ts +0 -117
- package/utils/controllers/form.ts +0 -524
- package/utils/controllers/index.ts +0 -39
- package/utils/controllers/menu.ts +0 -255
- package/utils/controllers/number-field.ts +0 -103
- package/utils/controllers/otp.ts +0 -252
- package/utils/controllers/sidebar.ts +0 -610
- package/utils/controllers/slider.ts +0 -336
- package/utils/controllers/tags-input.ts +0 -255
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
12
|
-
|
|
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
|
+
}
|