@pienter/ui 0.2.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 +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- package/utils/validation/rules.ts +31 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './date-input.css';
|
|
3
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
id?: string;
|
|
7
|
+
label: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Native input type. `month` and `week` are intentionally out of
|
|
11
|
+
* scope for v1 because their browser support is uneven (notably
|
|
12
|
+
* Safari) — see CONVENTIONS DateInput audit entry.
|
|
13
|
+
*/
|
|
14
|
+
type?: 'date' | 'time' | 'datetime-local';
|
|
15
|
+
/**
|
|
16
|
+
* Current ISO-formatted value. The exact shape depends on `type`:
|
|
17
|
+
* - `date` → `YYYY-MM-DD`
|
|
18
|
+
* - `time` → `HH:MM` (or `HH:MM:SS` when `step` enables seconds)
|
|
19
|
+
* - `datetime-local` → `YYYY-MM-DDTHH:MM`
|
|
20
|
+
*/
|
|
21
|
+
value?: string;
|
|
22
|
+
/** Lower bound, in the same ISO shape as `value`. Forwarded to native `min`. */
|
|
23
|
+
min?: string;
|
|
24
|
+
/** Upper bound, in the same ISO shape as `value`. Forwarded to native `max`. */
|
|
25
|
+
max?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Native step granularity. Useful for `type="time"`:
|
|
28
|
+
* `step="60"` (default) gives minute precision; `step="1"`
|
|
29
|
+
* exposes seconds in the picker.
|
|
30
|
+
*/
|
|
31
|
+
step?: number | string;
|
|
32
|
+
required?: boolean;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
hint?: string;
|
|
35
|
+
errors?: string[];
|
|
36
|
+
status?: 'error' | 'success';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
id,
|
|
41
|
+
label,
|
|
42
|
+
name,
|
|
43
|
+
type = 'date',
|
|
44
|
+
value = '',
|
|
45
|
+
min,
|
|
46
|
+
max,
|
|
47
|
+
step,
|
|
48
|
+
required = false,
|
|
49
|
+
disabled = false,
|
|
50
|
+
hint,
|
|
51
|
+
errors = [],
|
|
52
|
+
status,
|
|
53
|
+
...rest
|
|
54
|
+
} = Astro.props;
|
|
55
|
+
|
|
56
|
+
const inputId = id ?? generateId('input');
|
|
57
|
+
const hintId = `${inputId}-hint`;
|
|
58
|
+
const errorsId = `${inputId}-errors`;
|
|
59
|
+
|
|
60
|
+
const hasErrors = errors.length > 0;
|
|
61
|
+
const computedStatus = status ?? (hasErrors ? 'error' : undefined);
|
|
62
|
+
|
|
63
|
+
const describedByIds: string[] = [];
|
|
64
|
+
if (hint) describedByIds.push(hintId);
|
|
65
|
+
if (hasErrors) describedByIds.push(errorsId);
|
|
66
|
+
const describedBy =
|
|
67
|
+
describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
<div class="pui-field" data-status={computedStatus}>
|
|
71
|
+
<label class="pui-field__label" for={inputId}>
|
|
72
|
+
{label}
|
|
73
|
+
</label>
|
|
74
|
+
<input
|
|
75
|
+
class="pui-input"
|
|
76
|
+
id={inputId}
|
|
77
|
+
type={type}
|
|
78
|
+
name={name}
|
|
79
|
+
value={value}
|
|
80
|
+
min={min}
|
|
81
|
+
max={max}
|
|
82
|
+
step={step}
|
|
83
|
+
required={required}
|
|
84
|
+
disabled={disabled}
|
|
85
|
+
aria-invalid={hasErrors ? 'true' : undefined}
|
|
86
|
+
aria-describedby={describedBy}
|
|
87
|
+
{...rest}
|
|
88
|
+
/>
|
|
89
|
+
{
|
|
90
|
+
hint && (
|
|
91
|
+
<p id={hintId} class="pui-field__hint">
|
|
92
|
+
{hint}
|
|
93
|
+
</p>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
{
|
|
97
|
+
hasErrors && (
|
|
98
|
+
<ul id={errorsId} class="pui-field__hint" role="alert">
|
|
99
|
+
{errors.map((error) => (
|
|
100
|
+
<li>{error}</li>
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
</div>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
+
<label class="pui-field__label" :for="inputId">{{ label }}</label>
|
|
4
|
+
<input
|
|
5
|
+
v-bind="$attrs"
|
|
6
|
+
:id="inputId"
|
|
7
|
+
class="pui-input"
|
|
8
|
+
:type="type"
|
|
9
|
+
:name="name"
|
|
10
|
+
:value="modelValue"
|
|
11
|
+
:min="min"
|
|
12
|
+
:max="max"
|
|
13
|
+
:step="step"
|
|
14
|
+
:required="required"
|
|
15
|
+
:disabled="disabled"
|
|
16
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
17
|
+
:aria-describedby="describedBy"
|
|
18
|
+
@input="
|
|
19
|
+
emit(
|
|
20
|
+
'update:modelValue',
|
|
21
|
+
($event.target as HTMLInputElement).value,
|
|
22
|
+
)
|
|
23
|
+
"
|
|
24
|
+
@blur="emit('blur')"
|
|
25
|
+
/>
|
|
26
|
+
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
27
|
+
<ul
|
|
28
|
+
v-if="errors.length"
|
|
29
|
+
:id="errorsId"
|
|
30
|
+
class="pui-field__hint"
|
|
31
|
+
role="alert"
|
|
32
|
+
>
|
|
33
|
+
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
34
|
+
</ul>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import { computed } from 'vue';
|
|
40
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
41
|
+
|
|
42
|
+
defineOptions({ inheritAttrs: false });
|
|
43
|
+
|
|
44
|
+
const props = withDefaults(
|
|
45
|
+
defineProps<{
|
|
46
|
+
id?: string;
|
|
47
|
+
label: string;
|
|
48
|
+
/**
|
|
49
|
+
* Current ISO-formatted value. The exact shape depends on `type`:
|
|
50
|
+
* - `date` → `YYYY-MM-DD`
|
|
51
|
+
* - `time` → `HH:MM` (or `HH:MM:SS` when `step` enables seconds)
|
|
52
|
+
* - `datetime-local` → `YYYY-MM-DDTHH:MM`
|
|
53
|
+
* An empty string represents an empty input.
|
|
54
|
+
*/
|
|
55
|
+
modelValue: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Native input type. `month` and `week` are intentionally out of
|
|
59
|
+
* scope for v1 because their browser support is uneven (notably
|
|
60
|
+
* Safari) — see ./AUDIT.md.
|
|
61
|
+
*/
|
|
62
|
+
type?: 'date' | 'time' | 'datetime-local';
|
|
63
|
+
/** Lower bound, in the same ISO shape as `modelValue`. Forwarded to native `min`. */
|
|
64
|
+
min?: string;
|
|
65
|
+
/** Upper bound, in the same ISO shape as `modelValue`. Forwarded to native `max`. */
|
|
66
|
+
max?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Native step granularity. Useful for `type="time"`:
|
|
69
|
+
* `step="60"` (default) gives minute precision; `step="1"`
|
|
70
|
+
* exposes seconds in the picker.
|
|
71
|
+
*/
|
|
72
|
+
step?: number | string;
|
|
73
|
+
required?: boolean;
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
hint?: string;
|
|
76
|
+
errors?: string[];
|
|
77
|
+
status?: 'error' | 'success';
|
|
78
|
+
}>(),
|
|
79
|
+
{
|
|
80
|
+
id: undefined,
|
|
81
|
+
name: undefined,
|
|
82
|
+
type: 'date',
|
|
83
|
+
min: undefined,
|
|
84
|
+
max: undefined,
|
|
85
|
+
step: undefined,
|
|
86
|
+
required: false,
|
|
87
|
+
disabled: false,
|
|
88
|
+
hint: undefined,
|
|
89
|
+
errors: () => [],
|
|
90
|
+
status: undefined,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const emit = defineEmits<{
|
|
95
|
+
'update:modelValue': [value: string];
|
|
96
|
+
blur: [];
|
|
97
|
+
}>();
|
|
98
|
+
|
|
99
|
+
const inputId = props.id ?? generateId('input');
|
|
100
|
+
const hintId = `${inputId}-hint`;
|
|
101
|
+
const errorsId = `${inputId}-errors`;
|
|
102
|
+
|
|
103
|
+
const hasErrors = computed(() => props.errors.length > 0);
|
|
104
|
+
|
|
105
|
+
const computedStatus = computed(() => {
|
|
106
|
+
if (props.status) return props.status;
|
|
107
|
+
if (hasErrors.value) return 'error';
|
|
108
|
+
return undefined;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const describedBy = computed(() => {
|
|
112
|
+
const ids: string[] = [];
|
|
113
|
+
if (props.hint) ids.push(hintId);
|
|
114
|
+
if (hasErrors.value) ids.push(errorsId);
|
|
115
|
+
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
116
|
+
});
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<style>
|
|
120
|
+
@import './date-input.css';
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
*/
|
|
19
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './form.css';
|
|
3
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Stable form ID — used by the consumer's `mountForm` script. */
|
|
7
|
+
id?: string;
|
|
8
|
+
submitLabel?: string;
|
|
9
|
+
busy?: boolean;
|
|
10
|
+
/** Top-level errors rendered in the error summary. */
|
|
11
|
+
errors?: string[];
|
|
12
|
+
/** Success/status message rendered in the polite live region. */
|
|
13
|
+
statusMessage?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
id,
|
|
18
|
+
submitLabel = 'Submit',
|
|
19
|
+
busy = false,
|
|
20
|
+
errors = [],
|
|
21
|
+
statusMessage,
|
|
22
|
+
} = Astro.props;
|
|
23
|
+
|
|
24
|
+
const formId = id ?? generateId('form');
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<form
|
|
28
|
+
class="pui-form"
|
|
29
|
+
id={formId}
|
|
30
|
+
data-pui-form
|
|
31
|
+
novalidate
|
|
32
|
+
aria-busy={busy ? 'true' : undefined}
|
|
33
|
+
>
|
|
34
|
+
{
|
|
35
|
+
errors.length > 0 && (
|
|
36
|
+
<div class="pui-form__error-summary" role="alert" aria-live="assertive">
|
|
37
|
+
<p class="pui-form__error-summary-title">There was a problem</p>
|
|
38
|
+
<ul class="pui-form__error-summary-list">
|
|
39
|
+
{errors.map((error) => (
|
|
40
|
+
<li>{error}</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
<slot />
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
statusMessage && (
|
|
51
|
+
<div class="pui-form__status" aria-live="polite">
|
|
52
|
+
{statusMessage}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
<div class="pui-form__actions">
|
|
58
|
+
<slot name="actions">
|
|
59
|
+
<button
|
|
60
|
+
class="pui-btn"
|
|
61
|
+
data-variant="primary"
|
|
62
|
+
type="submit"
|
|
63
|
+
disabled={busy}
|
|
64
|
+
>
|
|
65
|
+
{submitLabel}
|
|
66
|
+
</button>
|
|
67
|
+
</slot>
|
|
68
|
+
</div>
|
|
69
|
+
</form>
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
/*
|
|
73
|
+
Form.astro renders chrome only — it deliberately does NOT auto-mount
|
|
74
|
+
`mountForm`. The controller takes `validators` and an async `onSubmit`
|
|
75
|
+
handler, both of which are functions that can't be passed as Astro
|
|
76
|
+
props. Consumers wire the controller themselves from a `<script>`
|
|
77
|
+
block:
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
import Form from '@pienter/ui/components/Form.astro';
|
|
81
|
+
import TextInput from '@pienter/ui/components/TextInput.astro';
|
|
82
|
+
---
|
|
83
|
+
<Form id="signup" submitLabel="Create account">
|
|
84
|
+
<TextInput label="Email" name="email" type="email" />
|
|
85
|
+
</Form>
|
|
86
|
+
<script>
|
|
87
|
+
import { mountForm } from '@pienter/ui/utils/controllers/form.js';
|
|
88
|
+
import { isRequired, isEmail } from '@pienter/ui/utils/validation/rules.js';
|
|
89
|
+
const el = document.getElementById('signup');
|
|
90
|
+
if (el instanceof HTMLFormElement) {
|
|
91
|
+
mountForm(el, {
|
|
92
|
+
validators: { email: [isRequired(), isEmail()] },
|
|
93
|
+
onSubmit: async (data) => {
|
|
94
|
+
const res = await fetch('/api/signup', { method: 'POST', body: data });
|
|
95
|
+
if (res.ok) return { ok: true, statusMessage: 'Account created' };
|
|
96
|
+
const body = await res.json();
|
|
97
|
+
return { ok: false, fieldErrors: body.fieldErrors, errors: body.errors };
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
This explicit mount pattern is the locked Astro Form contract per the
|
|
104
|
+
Form audit entry in CONVENTIONS.md.
|
|
105
|
+
*/
|
|
106
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<form
|
|
3
|
+
ref="formRef"
|
|
4
|
+
class="pui-form"
|
|
5
|
+
novalidate
|
|
6
|
+
:aria-busy="busy ? 'true' : undefined"
|
|
7
|
+
@submit.prevent="handleSubmit"
|
|
8
|
+
>
|
|
9
|
+
<div
|
|
10
|
+
v-if="topErrors.length > 0"
|
|
11
|
+
class="pui-form__error-summary"
|
|
12
|
+
role="alert"
|
|
13
|
+
aria-live="assertive"
|
|
14
|
+
>
|
|
15
|
+
<p class="pui-form__error-summary-title">There was a problem</p>
|
|
16
|
+
<ul class="pui-form__error-summary-list">
|
|
17
|
+
<li v-for="error in topErrors" :key="error">{{ error }}</li>
|
|
18
|
+
</ul>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<slot />
|
|
22
|
+
|
|
23
|
+
<div v-if="statusMessage" class="pui-form__status" aria-live="polite">
|
|
24
|
+
{{ statusMessage }}
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="pui-form__actions">
|
|
28
|
+
<slot name="actions">
|
|
29
|
+
<button
|
|
30
|
+
class="pui-btn"
|
|
31
|
+
data-variant="primary"
|
|
32
|
+
type="submit"
|
|
33
|
+
:disabled="busy"
|
|
34
|
+
>
|
|
35
|
+
{{ submitLabel }}
|
|
36
|
+
</button>
|
|
37
|
+
</slot>
|
|
38
|
+
</div>
|
|
39
|
+
</form>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<script setup lang="ts">
|
|
43
|
+
import { computed, ref, watch } from 'vue';
|
|
44
|
+
import { focusFirstInvalid } from '../../../utils/a11y/focus.js';
|
|
45
|
+
import { announce } from '../../../utils/a11y/live-region.js';
|
|
46
|
+
import { type Validator } from '../../../utils/validation/rules.js';
|
|
47
|
+
import {
|
|
48
|
+
validateFormData,
|
|
49
|
+
normalizeFieldErrors,
|
|
50
|
+
} from '../../../utils/validation/form.js';
|
|
51
|
+
|
|
52
|
+
const props = withDefaults(
|
|
53
|
+
defineProps<{
|
|
54
|
+
submitLabel?: string;
|
|
55
|
+
busy?: boolean;
|
|
56
|
+
/** Top-level errors not tied to a specific field. */
|
|
57
|
+
errors?: string[];
|
|
58
|
+
/** Server-side per-field errors. Consumers SHALL replace the object reference rather than mutate in place. */
|
|
59
|
+
fieldErrors?: Record<string, string | string[]>;
|
|
60
|
+
/** Success/status message; announced via `announce()` when set or changed. */
|
|
61
|
+
statusMessage?: string;
|
|
62
|
+
/** Per-field client-side validators run on submit. */
|
|
63
|
+
validators?: Record<string, Validator[]>;
|
|
64
|
+
}>(),
|
|
65
|
+
{
|
|
66
|
+
submitLabel: 'Submit',
|
|
67
|
+
busy: false,
|
|
68
|
+
errors: () => [],
|
|
69
|
+
fieldErrors: () => ({}),
|
|
70
|
+
statusMessage: undefined,
|
|
71
|
+
validators: () => ({}),
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const emit = defineEmits<{
|
|
76
|
+
/** Fired AFTER client validation passes; consumer handles the async submit. */
|
|
77
|
+
submit: [data: FormData];
|
|
78
|
+
/** Fired when client validation fails; useful for telemetry / logging. */
|
|
79
|
+
'validation-error': [errors: Record<string, string[]>];
|
|
80
|
+
}>();
|
|
81
|
+
|
|
82
|
+
const formRef = ref<HTMLFormElement | null>(null);
|
|
83
|
+
|
|
84
|
+
// Internal client-validation errors. Merged with server-supplied
|
|
85
|
+
// `fieldErrors` for display via the form-primitive children's own
|
|
86
|
+
// `errors` prop pattern (consumer wires them up by name); the summary
|
|
87
|
+
// above renders only top-level errors.
|
|
88
|
+
const clientFieldErrors = ref<Record<string, string[]>>({});
|
|
89
|
+
|
|
90
|
+
// Combined top-level errors: consumer-supplied + a synthesized count
|
|
91
|
+
// summary when client validation fails. Keep simple for v1: just
|
|
92
|
+
// surface consumer-supplied; the announcement carries the count.
|
|
93
|
+
const topErrors = computed<string[]>(() => props.errors ?? []);
|
|
94
|
+
|
|
95
|
+
// Combined field errors (server + client) — exposed via provide for any
|
|
96
|
+
// future field-aware children, though the current TextInput pattern is
|
|
97
|
+
// for consumers to wire `:errors` themselves. Kept here for the
|
|
98
|
+
// announce + focus-first-invalid logic.
|
|
99
|
+
const allFieldErrors = computed<Record<string, string[]>>(() => {
|
|
100
|
+
const server = normalizeFieldErrors(props.fieldErrors);
|
|
101
|
+
// Server errors win when both exist for the same field.
|
|
102
|
+
return { ...clientFieldErrors.value, ...server };
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function handleSubmit(): void {
|
|
106
|
+
const formEl = formRef.value;
|
|
107
|
+
if (!formEl) return;
|
|
108
|
+
|
|
109
|
+
const formData = new FormData(formEl);
|
|
110
|
+
|
|
111
|
+
// Run client-side validators.
|
|
112
|
+
const errors = validateFormData(formData, props.validators);
|
|
113
|
+
clientFieldErrors.value = errors;
|
|
114
|
+
|
|
115
|
+
const errorCount = Object.keys(errors).length;
|
|
116
|
+
if (errorCount > 0) {
|
|
117
|
+
emit('validation-error', errors);
|
|
118
|
+
// Wait a tick for the consumer to reflect errors back into the
|
|
119
|
+
// child form-primitives' `:errors` prop (which sets aria-invalid),
|
|
120
|
+
// then move focus + announce.
|
|
121
|
+
requestAnimationFrame(() => {
|
|
122
|
+
focusFirstInvalid(formEl);
|
|
123
|
+
const message =
|
|
124
|
+
errorCount === 1
|
|
125
|
+
? '1 error found. Please review the form.'
|
|
126
|
+
: `${errorCount} errors found. Please review the form.`;
|
|
127
|
+
announce(message, 'assertive');
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validation passed. Clear any lingering client-side errors from a
|
|
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).
|
|
136
|
+
clientFieldErrors.value = {};
|
|
137
|
+
|
|
138
|
+
// Surface FormData to the consumer's submit handler; consumer manages
|
|
139
|
+
// the busy/response lifecycle via the `busy`, `errors`, `fieldErrors`,
|
|
140
|
+
// and `statusMessage` props.
|
|
141
|
+
emit('submit', formData);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Announce the success / status message whenever it changes. Server
|
|
145
|
+
// errors arriving via `fieldErrors` should also focus the first
|
|
146
|
+
// invalid + announce, mirroring the controller behavior.
|
|
147
|
+
watch(
|
|
148
|
+
() => props.statusMessage,
|
|
149
|
+
(msg) => {
|
|
150
|
+
if (msg) announce(msg, 'polite');
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
watch(
|
|
155
|
+
() => props.fieldErrors,
|
|
156
|
+
(newServerErrors) => {
|
|
157
|
+
const normalized = normalizeFieldErrors(newServerErrors);
|
|
158
|
+
const count = Object.keys(normalized).length;
|
|
159
|
+
if (count === 0) return;
|
|
160
|
+
requestAnimationFrame(() => {
|
|
161
|
+
const formEl = formRef.value;
|
|
162
|
+
if (!formEl) return;
|
|
163
|
+
focusFirstInvalid(formEl);
|
|
164
|
+
const message =
|
|
165
|
+
count === 1
|
|
166
|
+
? '1 error found. Please review the form.'
|
|
167
|
+
: `${count} errors found. Please review the form.`;
|
|
168
|
+
announce(message, 'assertive');
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
defineExpose({
|
|
174
|
+
/** Field error map (client + server) — read-only view for consumers. */
|
|
175
|
+
fieldErrors: allFieldErrors,
|
|
176
|
+
});
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<style>
|
|
180
|
+
@import './form.css';
|
|
181
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.pui-form {
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
gap: var(--space-m);
|
|
6
|
+
}
|
|
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;
|
|
24
|
+
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);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.pui-form__status {
|
|
34
|
+
padding: var(--space-s) var(--space-m);
|
|
35
|
+
border-radius: var(--radius-sm);
|
|
36
|
+
background: var(--bg-clr-success-soft);
|
|
37
|
+
color: var(--text-clr-success);
|
|
38
|
+
font-weight: var(--fw-semibold);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.pui-form__actions {
|
|
42
|
+
display: flex;
|
|
43
|
+
gap: var(--space-s);
|
|
44
|
+
align-items: center;
|
|
45
|
+
}
|
|
46
|
+
}
|