@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,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './input-otp.css';
|
|
3
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
id?: string;
|
|
7
|
+
label: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Initial OTP string. Length matches `length` (Astro pads with empty
|
|
11
|
+
* characters or trims as needed before rendering slots).
|
|
12
|
+
*/
|
|
13
|
+
value?: string;
|
|
14
|
+
/** Number of slots (default 6). */
|
|
15
|
+
length?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Per-character validation pattern as a string (regex source). Default
|
|
18
|
+
* is digits-only `^[0-9]$`. Pass an alphanumeric pattern as a regex
|
|
19
|
+
* source string when the OTP code includes letters. The string is
|
|
20
|
+
* stored on a `data-pattern` attribute so the controller can rehydrate
|
|
21
|
+
* the regex post-mount.
|
|
22
|
+
*/
|
|
23
|
+
pattern?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
hint?: string;
|
|
26
|
+
errors?: string[];
|
|
27
|
+
status?: 'error' | 'success';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
id,
|
|
32
|
+
label,
|
|
33
|
+
name,
|
|
34
|
+
value = '',
|
|
35
|
+
length = 6,
|
|
36
|
+
pattern,
|
|
37
|
+
disabled = false,
|
|
38
|
+
hint,
|
|
39
|
+
errors = [],
|
|
40
|
+
status,
|
|
41
|
+
...rest
|
|
42
|
+
} = Astro.props;
|
|
43
|
+
|
|
44
|
+
const firstSlotId = id ?? generateId('input');
|
|
45
|
+
const hintId = `${firstSlotId}-hint`;
|
|
46
|
+
const errorsId = `${firstSlotId}-errors`;
|
|
47
|
+
|
|
48
|
+
const hasErrors = errors.length > 0;
|
|
49
|
+
const computedStatus = status ?? (hasErrors ? 'error' : undefined);
|
|
50
|
+
|
|
51
|
+
const describedByIds: string[] = [];
|
|
52
|
+
if (hint) describedByIds.push(hintId);
|
|
53
|
+
if (hasErrors) describedByIds.push(errorsId);
|
|
54
|
+
const describedBy =
|
|
55
|
+
describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
|
|
56
|
+
|
|
57
|
+
// Pad / trim seed value to the slot count.
|
|
58
|
+
const slots = Array.from({ length }, (_, i) => value[i] ?? '');
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
<div class="pui-field" data-status={computedStatus}>
|
|
62
|
+
<label class="pui-field__label" for={firstSlotId}>
|
|
63
|
+
{label}
|
|
64
|
+
</label>
|
|
65
|
+
<div
|
|
66
|
+
class="pui-input-otp"
|
|
67
|
+
data-pui-input-otp
|
|
68
|
+
data-disabled={disabled ? 'true' : undefined}
|
|
69
|
+
data-pattern={pattern}
|
|
70
|
+
role="group"
|
|
71
|
+
>
|
|
72
|
+
{
|
|
73
|
+
slots.map((char, i) => (
|
|
74
|
+
<input
|
|
75
|
+
class="pui-input-otp__slot pui-input"
|
|
76
|
+
type="text"
|
|
77
|
+
inputmode="numeric"
|
|
78
|
+
autocomplete="one-time-code"
|
|
79
|
+
maxlength="1"
|
|
80
|
+
id={i === 0 ? firstSlotId : `${firstSlotId}-slot-${i + 1}`}
|
|
81
|
+
aria-label={`Digit ${i + 1} of ${length}`}
|
|
82
|
+
value={char}
|
|
83
|
+
disabled={disabled}
|
|
84
|
+
aria-invalid={i === 0 && hasErrors ? 'true' : undefined}
|
|
85
|
+
aria-describedby={i === 0 ? describedBy : undefined}
|
|
86
|
+
{...(i === 0 ? rest : {})}
|
|
87
|
+
/>
|
|
88
|
+
))
|
|
89
|
+
}
|
|
90
|
+
{
|
|
91
|
+
name && (
|
|
92
|
+
<input
|
|
93
|
+
class="pui-input-otp__hidden"
|
|
94
|
+
type="hidden"
|
|
95
|
+
name={name}
|
|
96
|
+
value={value}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
</div>
|
|
101
|
+
{
|
|
102
|
+
hint && (
|
|
103
|
+
<p id={hintId} class="pui-field__hint">
|
|
104
|
+
{hint}
|
|
105
|
+
</p>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
{
|
|
109
|
+
hasErrors && (
|
|
110
|
+
<ul id={errorsId} class="pui-field__hint" role="alert">
|
|
111
|
+
{errors.map((error) => (
|
|
112
|
+
<li>{error}</li>
|
|
113
|
+
))}
|
|
114
|
+
</ul>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<script>
|
|
120
|
+
import { mountInputOTP } from '@pienter/ui/utils/controllers/otp.js';
|
|
121
|
+
|
|
122
|
+
// Hoisted module script — Astro de-dupes across instances.
|
|
123
|
+
function mountAll(): void {
|
|
124
|
+
const wrappers = document.querySelectorAll<HTMLElement>(
|
|
125
|
+
'[data-pui-input-otp]',
|
|
126
|
+
);
|
|
127
|
+
wrappers.forEach((wrapper) => {
|
|
128
|
+
if (wrapper.dataset.vInputOtpMounted === 'true') return;
|
|
129
|
+
let pattern: RegExp | undefined;
|
|
130
|
+
if (wrapper.dataset.pattern) {
|
|
131
|
+
try {
|
|
132
|
+
pattern = new RegExp(wrapper.dataset.pattern);
|
|
133
|
+
} catch {
|
|
134
|
+
pattern = undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
mountInputOTP(wrapper, { pattern });
|
|
138
|
+
wrapper.dataset.vInputOtpMounted = 'true';
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (document.readyState === 'loading') {
|
|
143
|
+
document.addEventListener('DOMContentLoaded', mountAll, { once: true });
|
|
144
|
+
} else {
|
|
145
|
+
mountAll();
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
+
<label class="pui-field__label" :for="firstSlotId">{{ label }}</label>
|
|
4
|
+
<div
|
|
5
|
+
class="pui-input-otp"
|
|
6
|
+
:data-disabled="disabled ? 'true' : undefined"
|
|
7
|
+
role="group"
|
|
8
|
+
:aria-labelledby="undefined"
|
|
9
|
+
>
|
|
10
|
+
<input
|
|
11
|
+
v-for="i in length"
|
|
12
|
+
:id="i === 1 ? firstSlotId : `${firstSlotId}-slot-${i}`"
|
|
13
|
+
:key="i"
|
|
14
|
+
:ref="(el) => setSlotRef(el, i - 1)"
|
|
15
|
+
class="pui-input-otp__slot pui-input"
|
|
16
|
+
type="text"
|
|
17
|
+
inputmode="numeric"
|
|
18
|
+
autocomplete="one-time-code"
|
|
19
|
+
maxlength="1"
|
|
20
|
+
:aria-label="`Digit ${i} of ${length}`"
|
|
21
|
+
:value="slotChar(i - 1)"
|
|
22
|
+
:disabled="disabled"
|
|
23
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
24
|
+
:aria-describedby="i === 1 ? describedBy : undefined"
|
|
25
|
+
@input="(e) => onSlotInput(e, i - 1)"
|
|
26
|
+
@keydown="(e) => onSlotKeydown(e, i - 1)"
|
|
27
|
+
@paste="(e) => onSlotPaste(e, i - 1)"
|
|
28
|
+
@focus="(e) => onSlotFocus(e)"
|
|
29
|
+
/>
|
|
30
|
+
<input v-if="name" type="hidden" :name="name" :value="modelValue" />
|
|
31
|
+
</div>
|
|
32
|
+
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
33
|
+
<ul
|
|
34
|
+
v-if="errors.length"
|
|
35
|
+
:id="errorsId"
|
|
36
|
+
class="pui-field__hint"
|
|
37
|
+
role="alert"
|
|
38
|
+
>
|
|
39
|
+
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
40
|
+
</ul>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { computed, ref, type ComponentPublicInstance } from 'vue';
|
|
46
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
47
|
+
|
|
48
|
+
defineOptions({ inheritAttrs: false });
|
|
49
|
+
|
|
50
|
+
const props = withDefaults(
|
|
51
|
+
defineProps<{
|
|
52
|
+
id?: string;
|
|
53
|
+
label: string;
|
|
54
|
+
/** Concatenated OTP string. Length always matches `length`; unfilled slots are empty characters. */
|
|
55
|
+
modelValue: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
/** Number of slots (default 6). */
|
|
58
|
+
length?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Per-character validation pattern. Default digits-only
|
|
61
|
+
* (`/^[0-9]$/`). Pass an alphanumeric pattern for OTP codes that
|
|
62
|
+
* include letters.
|
|
63
|
+
*/
|
|
64
|
+
pattern?: RegExp;
|
|
65
|
+
disabled?: boolean;
|
|
66
|
+
hint?: string;
|
|
67
|
+
errors?: string[];
|
|
68
|
+
status?: 'error' | 'success';
|
|
69
|
+
}>(),
|
|
70
|
+
{
|
|
71
|
+
id: undefined,
|
|
72
|
+
name: undefined,
|
|
73
|
+
length: 6,
|
|
74
|
+
pattern: () => /^[0-9]$/,
|
|
75
|
+
disabled: false,
|
|
76
|
+
hint: undefined,
|
|
77
|
+
errors: () => [],
|
|
78
|
+
status: undefined,
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const emit = defineEmits<{
|
|
83
|
+
'update:modelValue': [value: string];
|
|
84
|
+
}>();
|
|
85
|
+
|
|
86
|
+
const slotRefs = ref<HTMLInputElement[]>([]);
|
|
87
|
+
|
|
88
|
+
function setSlotRef(
|
|
89
|
+
el: Element | ComponentPublicInstance | null,
|
|
90
|
+
index: number,
|
|
91
|
+
): void {
|
|
92
|
+
if (el instanceof HTMLInputElement) {
|
|
93
|
+
slotRefs.value[index] = el;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const firstSlotId = props.id ?? generateId('input');
|
|
98
|
+
const hintId = `${firstSlotId}-hint`;
|
|
99
|
+
const errorsId = `${firstSlotId}-errors`;
|
|
100
|
+
|
|
101
|
+
const hasErrors = computed(() => props.errors.length > 0);
|
|
102
|
+
|
|
103
|
+
const computedStatus = computed(() => {
|
|
104
|
+
if (props.status) return props.status;
|
|
105
|
+
if (hasErrors.value) return 'error';
|
|
106
|
+
return undefined;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const describedBy = computed(() => {
|
|
110
|
+
const ids: string[] = [];
|
|
111
|
+
if (props.hint) ids.push(hintId);
|
|
112
|
+
if (hasErrors.value) ids.push(errorsId);
|
|
113
|
+
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function slotChar(idx: number): string {
|
|
117
|
+
return props.modelValue[idx] ?? '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setChar(idx: number, ch: string): void {
|
|
121
|
+
const arr = Array.from({ length: props.length }, (_, i) => slotChar(i));
|
|
122
|
+
arr[idx] = ch;
|
|
123
|
+
emit('update:modelValue', arr.join(''));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function focusSlot(idx: number): void {
|
|
127
|
+
const target = slotRefs.value[idx];
|
|
128
|
+
if (target) {
|
|
129
|
+
target.focus();
|
|
130
|
+
target.select();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onSlotInput(event: Event, idx: number): void {
|
|
135
|
+
const target = event.target as HTMLInputElement;
|
|
136
|
+
const raw = target.value;
|
|
137
|
+
if (raw.length === 0) {
|
|
138
|
+
setChar(idx, '');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Multi-char input (paste fallback): take last char, validate.
|
|
142
|
+
const ch = raw.length > 1 ? (raw[raw.length - 1] ?? '') : raw;
|
|
143
|
+
if (!props.pattern.test(ch)) {
|
|
144
|
+
target.value = slotChar(idx); // restore previous
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
target.value = ch;
|
|
148
|
+
setChar(idx, ch);
|
|
149
|
+
if (idx < props.length - 1) {
|
|
150
|
+
focusSlot(idx + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onSlotKeydown(event: KeyboardEvent, idx: number): void {
|
|
155
|
+
const target = event.target as HTMLInputElement;
|
|
156
|
+
|
|
157
|
+
if (event.key === 'Backspace') {
|
|
158
|
+
if (target.value.length === 0 && idx > 0) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
setChar(idx - 1, '');
|
|
161
|
+
focusSlot(idx - 1);
|
|
162
|
+
} else if (target.value.length > 0) {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
setChar(idx, '');
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (event.key === 'ArrowLeft' && idx > 0) {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
focusSlot(idx - 1);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (event.key === 'ArrowRight' && idx < props.length - 1) {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
focusSlot(idx + 1);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onSlotPaste(event: ClipboardEvent, idx: number): void {
|
|
183
|
+
const data = event.clipboardData?.getData('text') ?? '';
|
|
184
|
+
if (!data) return;
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
const valid = Array.from(data).filter((ch) => props.pattern.test(ch));
|
|
187
|
+
if (valid.length === 0) return;
|
|
188
|
+
const arr = Array.from({ length: props.length }, (_, i) => slotChar(i));
|
|
189
|
+
let cursor = idx;
|
|
190
|
+
for (const ch of valid) {
|
|
191
|
+
if (cursor >= props.length) break;
|
|
192
|
+
arr[cursor] = ch;
|
|
193
|
+
cursor += 1;
|
|
194
|
+
}
|
|
195
|
+
emit('update:modelValue', arr.join(''));
|
|
196
|
+
const landingIdx = Math.min(cursor, props.length - 1);
|
|
197
|
+
focusSlot(landingIdx);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function onSlotFocus(event: FocusEvent): void {
|
|
201
|
+
if (event.target instanceof HTMLInputElement) {
|
|
202
|
+
event.target.select();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
</script>
|
|
206
|
+
|
|
207
|
+
<style>
|
|
208
|
+
@import './input-otp.css';
|
|
209
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
/* ===== pui-input-otp (row of N single-character inputs) =====
|
|
3
|
+
*
|
|
4
|
+
* InputOTP extends the locked Form-primitive scaffold with an
|
|
5
|
+
* inline-flex row of N native `<input maxlength="1">` slots. Each
|
|
6
|
+
* slot inherits the shared `.pui-input` rule (sizing / padding /
|
|
7
|
+
* focus-ring / status recolour) for visual parity with TextInput
|
|
8
|
+
* and NumberField; the per-component overrides here narrow the
|
|
9
|
+
* width to a square box and centre the typed character so the
|
|
10
|
+
* code reads as one unit.
|
|
11
|
+
*
|
|
12
|
+
* Layout rationale: rejected the "single underline-bordered row"
|
|
13
|
+
* alternative because per-slot rectangles match shadcn / Reka
|
|
14
|
+
* conventions and make focus more legible (each slot's
|
|
15
|
+
* `:focus-visible` ring is independently obvious to keyboard
|
|
16
|
+
* users).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
.pui-input-otp {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: var(--space-2xs);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.pui-input-otp[data-disabled='true'] {
|
|
26
|
+
opacity: 0.7;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.pui-input-otp__slot {
|
|
30
|
+
/* Inherits sizing, padding, focus-ring, disabled, and status
|
|
31
|
+
* recolour from the shared .pui-input rule (text-input.css). The
|
|
32
|
+
* overrides here narrow the slot to a square box and centre the
|
|
33
|
+
* typed character. */
|
|
34
|
+
width: 2.75rem;
|
|
35
|
+
text-align: center;
|
|
36
|
+
padding-inline: 0;
|
|
37
|
+
font-variant-numeric: tabular-nums;
|
|
38
|
+
font-size: var(--step-1, 1.125rem);
|
|
39
|
+
/* Slightly tighter min-height so a 6-slot row fits common form widths. */
|
|
40
|
+
line-height: 1.2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Status states inherited from .pui-field — recolour every slot when
|
|
44
|
+
* the parent carries `data-status`. */
|
|
45
|
+
.pui-field[data-status='error'] .pui-input-otp__slot {
|
|
46
|
+
border-color: var(--border-clr-danger);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.pui-field[data-status='success'] .pui-input-otp__slot {
|
|
50
|
+
border-color: var(--border-clr-success);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<label class="pui-field__label" :for="props.for">
|
|
3
|
+
<slot />
|
|
4
|
+
</label>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
const props = withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
for?: string;
|
|
11
|
+
}>(),
|
|
12
|
+
{
|
|
13
|
+
for: undefined,
|
|
14
|
+
},
|
|
15
|
+
);
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<style>
|
|
19
|
+
@import './label.css';
|
|
20
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
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 dragged into an Astro page that does not also use
|
|
9
|
+
* TextInput / Textarea / Select still gets the typography rules.
|
|
10
|
+
*/
|
|
11
|
+
@import '../text-input/text-input.css';
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './number-field.css';
|
|
3
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
id?: string;
|
|
7
|
+
label: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Initial numeric value. Astro renders the value once at SSR time;
|
|
11
|
+
* dynamic updates are handled by the consumer's own scripts (see
|
|
12
|
+
* the consumer-wires-their-own-script pattern in CONVENTIONS) or
|
|
13
|
+
* by the increment/decrement buttons via `mountNumberField`.
|
|
14
|
+
*/
|
|
15
|
+
value?: number | string;
|
|
16
|
+
min?: number;
|
|
17
|
+
max?: number;
|
|
18
|
+
step?: number | string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
hint?: string;
|
|
23
|
+
errors?: string[];
|
|
24
|
+
status?: 'error' | 'success';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
id,
|
|
29
|
+
label,
|
|
30
|
+
name,
|
|
31
|
+
value,
|
|
32
|
+
min,
|
|
33
|
+
max,
|
|
34
|
+
step,
|
|
35
|
+
placeholder,
|
|
36
|
+
required = false,
|
|
37
|
+
disabled = false,
|
|
38
|
+
hint,
|
|
39
|
+
errors = [],
|
|
40
|
+
status,
|
|
41
|
+
...rest
|
|
42
|
+
} = Astro.props;
|
|
43
|
+
|
|
44
|
+
const inputId = id ?? generateId('input');
|
|
45
|
+
const hintId = `${inputId}-hint`;
|
|
46
|
+
const errorsId = `${inputId}-errors`;
|
|
47
|
+
|
|
48
|
+
const hasErrors = errors.length > 0;
|
|
49
|
+
const computedStatus = status ?? (hasErrors ? 'error' : undefined);
|
|
50
|
+
|
|
51
|
+
const describedByIds: string[] = [];
|
|
52
|
+
if (hint) describedByIds.push(hintId);
|
|
53
|
+
if (hasErrors) describedByIds.push(errorsId);
|
|
54
|
+
const describedBy =
|
|
55
|
+
describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
<div class="pui-field" data-status={computedStatus}>
|
|
59
|
+
<label class="pui-field__label" for={inputId}>
|
|
60
|
+
{label}
|
|
61
|
+
</label>
|
|
62
|
+
<div class="pui-number-field" data-pui-number-field>
|
|
63
|
+
<input
|
|
64
|
+
class="pui-number-field__input pui-input"
|
|
65
|
+
type="number"
|
|
66
|
+
id={inputId}
|
|
67
|
+
name={name}
|
|
68
|
+
min={min}
|
|
69
|
+
max={max}
|
|
70
|
+
step={step}
|
|
71
|
+
placeholder={placeholder}
|
|
72
|
+
required={required}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
value={value}
|
|
75
|
+
aria-invalid={hasErrors ? 'true' : undefined}
|
|
76
|
+
aria-describedby={describedBy}
|
|
77
|
+
{...rest}
|
|
78
|
+
/>
|
|
79
|
+
<div class="pui-number-field__steppers">
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="pui-number-field__step"
|
|
83
|
+
data-direction="increment"
|
|
84
|
+
aria-label="Increase"
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
>
|
|
87
|
+
<span aria-hidden="true">+</span>
|
|
88
|
+
</button>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class="pui-number-field__step"
|
|
92
|
+
data-direction="decrement"
|
|
93
|
+
aria-label="Decrease"
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
>
|
|
96
|
+
<span aria-hidden="true">−</span>
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{
|
|
101
|
+
hint && (
|
|
102
|
+
<p id={hintId} class="pui-field__hint">
|
|
103
|
+
{hint}
|
|
104
|
+
</p>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
{
|
|
108
|
+
hasErrors && (
|
|
109
|
+
<ul id={errorsId} class="pui-field__hint" role="alert">
|
|
110
|
+
{errors.map((error) => (
|
|
111
|
+
<li>{error}</li>
|
|
112
|
+
))}
|
|
113
|
+
</ul>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<script>
|
|
119
|
+
import { mountNumberField } from '@pienter/ui/utils/controllers/number-field.js';
|
|
120
|
+
|
|
121
|
+
// Hoisted module script — Astro de-dupes this across every NumberField
|
|
122
|
+
// instance on the page. Mount the controller on each [data-pui-number-field]
|
|
123
|
+
// wrapper present at DOM-ready. See CONVENTIONS § hoisted-vs-inline rule.
|
|
124
|
+
function mountAll(): void {
|
|
125
|
+
const wrappers = document.querySelectorAll<HTMLElement>(
|
|
126
|
+
'[data-pui-number-field]',
|
|
127
|
+
);
|
|
128
|
+
wrappers.forEach((wrapper) => {
|
|
129
|
+
// Idempotency guard: skip wrappers that have already been mounted
|
|
130
|
+
// (e.g. on Astro view transitions / partial re-renders).
|
|
131
|
+
if (wrapper.dataset.vNumberFieldMounted === 'true') return;
|
|
132
|
+
mountNumberField(wrapper);
|
|
133
|
+
wrapper.dataset.vNumberFieldMounted = 'true';
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (document.readyState === 'loading') {
|
|
138
|
+
document.addEventListener('DOMContentLoaded', mountAll, { once: true });
|
|
139
|
+
} else {
|
|
140
|
+
mountAll();
|
|
141
|
+
}
|
|
142
|
+
</script>
|