@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,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './tags-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
|
+
* Initial tags array. Astro renders the pill DOM and seeds the
|
|
11
|
+
* controller via `data-initial-tags` so post-mount `mountTagsInput`
|
|
12
|
+
* stays in sync without re-reading attributes.
|
|
13
|
+
*/
|
|
14
|
+
value?: string[];
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
hint?: string;
|
|
18
|
+
errors?: string[];
|
|
19
|
+
status?: 'error' | 'success';
|
|
20
|
+
maxTags?: number;
|
|
21
|
+
separators?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
id,
|
|
26
|
+
label,
|
|
27
|
+
name,
|
|
28
|
+
value = [],
|
|
29
|
+
placeholder,
|
|
30
|
+
disabled = false,
|
|
31
|
+
hint,
|
|
32
|
+
errors = [],
|
|
33
|
+
status,
|
|
34
|
+
maxTags,
|
|
35
|
+
separators,
|
|
36
|
+
...rest
|
|
37
|
+
} = Astro.props;
|
|
38
|
+
|
|
39
|
+
const inputId = id ?? generateId('input');
|
|
40
|
+
const hintId = `${inputId}-hint`;
|
|
41
|
+
const errorsId = `${inputId}-errors`;
|
|
42
|
+
|
|
43
|
+
const hasErrors = errors.length > 0;
|
|
44
|
+
const computedStatus = status ?? (hasErrors ? 'error' : undefined);
|
|
45
|
+
|
|
46
|
+
const describedByIds: string[] = [];
|
|
47
|
+
if (hint) describedByIds.push(hintId);
|
|
48
|
+
if (hasErrors) describedByIds.push(errorsId);
|
|
49
|
+
const describedBy =
|
|
50
|
+
describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
|
|
51
|
+
|
|
52
|
+
const maxReached = maxTags !== undefined && value.length >= maxTags;
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
<div class="pui-field" data-status={computedStatus}>
|
|
56
|
+
<label class="pui-field__label" for={inputId}>
|
|
57
|
+
{label}
|
|
58
|
+
</label>
|
|
59
|
+
<div
|
|
60
|
+
class="pui-tags-input"
|
|
61
|
+
data-pui-tags-input
|
|
62
|
+
data-initial-tags={JSON.stringify(value)}
|
|
63
|
+
data-max-tags={maxTags !== undefined ? String(maxTags) : undefined}
|
|
64
|
+
data-separators={separators ? JSON.stringify(separators) : undefined}
|
|
65
|
+
data-disabled={disabled ? 'true' : undefined}
|
|
66
|
+
>
|
|
67
|
+
{
|
|
68
|
+
value.map((tag) => (
|
|
69
|
+
<span class="pui-tags-input__tag" data-tag-value={tag}>
|
|
70
|
+
<span class="pui-tags-input__tag-label">{tag}</span>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
class="pui-tags-input__tag-remove"
|
|
74
|
+
aria-label={`Remove tag ${tag}`}
|
|
75
|
+
data-tags-remove
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
>
|
|
78
|
+
<span aria-hidden="true">×</span>
|
|
79
|
+
</button>
|
|
80
|
+
</span>
|
|
81
|
+
))
|
|
82
|
+
}
|
|
83
|
+
<input
|
|
84
|
+
class="pui-tags-input__entry"
|
|
85
|
+
type="text"
|
|
86
|
+
id={inputId}
|
|
87
|
+
name={name}
|
|
88
|
+
placeholder={placeholder}
|
|
89
|
+
disabled={disabled || maxReached}
|
|
90
|
+
aria-invalid={hasErrors ? 'true' : undefined}
|
|
91
|
+
aria-describedby={describedBy}
|
|
92
|
+
{...rest}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
{
|
|
96
|
+
hint && (
|
|
97
|
+
<p id={hintId} class="pui-field__hint">
|
|
98
|
+
{hint}
|
|
99
|
+
</p>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
{
|
|
103
|
+
hasErrors && (
|
|
104
|
+
<ul id={errorsId} class="pui-field__hint" role="alert">
|
|
105
|
+
{errors.map((error) => (
|
|
106
|
+
<li>{error}</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<script>
|
|
114
|
+
import { mountTagsInput } from '@pienter/ui/utils/controllers/tags-input.js';
|
|
115
|
+
|
|
116
|
+
// Hoisted module script — Astro de-dupes across instances. Mount on
|
|
117
|
+
// every [data-pui-tags-input] wrapper present at DOM-ready.
|
|
118
|
+
function mountAll(): void {
|
|
119
|
+
const wrappers = document.querySelectorAll<HTMLElement>(
|
|
120
|
+
'[data-pui-tags-input]',
|
|
121
|
+
);
|
|
122
|
+
wrappers.forEach((wrapper) => {
|
|
123
|
+
if (wrapper.dataset.vTagsInputMounted === 'true') return;
|
|
124
|
+
const maxTagsRaw = wrapper.dataset.maxTags;
|
|
125
|
+
const maxTags =
|
|
126
|
+
maxTagsRaw === undefined ? undefined : Number.parseInt(maxTagsRaw, 10);
|
|
127
|
+
let separators: string[] | undefined;
|
|
128
|
+
if (wrapper.dataset.separators) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed: unknown = JSON.parse(wrapper.dataset.separators);
|
|
131
|
+
if (Array.isArray(parsed)) {
|
|
132
|
+
separators = parsed.filter(
|
|
133
|
+
(v): v is string => typeof v === 'string',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
separators = undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
mountTagsInput(wrapper, {
|
|
141
|
+
maxTags: Number.isFinite(maxTags) ? maxTags : undefined,
|
|
142
|
+
separators,
|
|
143
|
+
});
|
|
144
|
+
wrapper.dataset.vTagsInputMounted = 'true';
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (document.readyState === 'loading') {
|
|
149
|
+
document.addEventListener('DOMContentLoaded', mountAll, { once: true });
|
|
150
|
+
} else {
|
|
151
|
+
mountAll();
|
|
152
|
+
}
|
|
153
|
+
</script>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
+
<label class="pui-field__label" :for="inputId">{{ label }}</label>
|
|
4
|
+
<!--
|
|
5
|
+
onWrapperClick only forwards a click on the empty chrome of the field
|
|
6
|
+
to the real <input> below (a larger pointer target). That input is a
|
|
7
|
+
native, keyboard-focusable control reached directly via Tab, so no
|
|
8
|
+
keyboard equivalent belongs on this non-interactive wrapper: the div
|
|
9
|
+
is a layout container, not a control, and must not take a role/handler
|
|
10
|
+
of its own. Both rules false-positive on this click-to-focus affordance.
|
|
11
|
+
-->
|
|
12
|
+
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
13
|
+
<div
|
|
14
|
+
class="pui-tags-input"
|
|
15
|
+
:data-disabled="disabled ? 'true' : undefined"
|
|
16
|
+
@click="onWrapperClick"
|
|
17
|
+
>
|
|
18
|
+
<span
|
|
19
|
+
v-for="(tag, idx) in modelValue"
|
|
20
|
+
:key="`${tag}-${idx}`"
|
|
21
|
+
class="pui-tags-input__tag"
|
|
22
|
+
:data-tag-value="tag"
|
|
23
|
+
>
|
|
24
|
+
<span class="pui-tags-input__tag-label">{{ tag }}</span>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
class="pui-tags-input__tag-remove"
|
|
28
|
+
:aria-label="`Remove tag ${tag}`"
|
|
29
|
+
:disabled="disabled"
|
|
30
|
+
@click.stop="removeTag(idx)"
|
|
31
|
+
>
|
|
32
|
+
<span aria-hidden="true">×</span>
|
|
33
|
+
</button>
|
|
34
|
+
</span>
|
|
35
|
+
<input
|
|
36
|
+
v-bind="$attrs"
|
|
37
|
+
:id="inputId"
|
|
38
|
+
ref="entryRef"
|
|
39
|
+
v-model="entryText"
|
|
40
|
+
class="pui-tags-input__entry"
|
|
41
|
+
type="text"
|
|
42
|
+
:name="name"
|
|
43
|
+
:placeholder="placeholder"
|
|
44
|
+
:disabled="disabled || maxReached"
|
|
45
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
46
|
+
:aria-describedby="describedBy"
|
|
47
|
+
@keydown="onKeydown"
|
|
48
|
+
@blur="onBlur"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
52
|
+
<ul
|
|
53
|
+
v-if="errors.length"
|
|
54
|
+
:id="errorsId"
|
|
55
|
+
class="pui-field__hint"
|
|
56
|
+
role="alert"
|
|
57
|
+
>
|
|
58
|
+
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
59
|
+
</ul>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<script setup lang="ts">
|
|
64
|
+
import { computed, ref } from 'vue';
|
|
65
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
66
|
+
|
|
67
|
+
defineOptions({ inheritAttrs: false });
|
|
68
|
+
|
|
69
|
+
const props = withDefaults(
|
|
70
|
+
defineProps<{
|
|
71
|
+
id?: string;
|
|
72
|
+
label: string;
|
|
73
|
+
/** Current tags array. Vue emits `update:modelValue` on every add / remove. */
|
|
74
|
+
modelValue: string[];
|
|
75
|
+
name?: string;
|
|
76
|
+
placeholder?: string;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
hint?: string;
|
|
79
|
+
errors?: string[];
|
|
80
|
+
status?: 'error' | 'success';
|
|
81
|
+
/**
|
|
82
|
+
* Maximum number of tags. Beyond this, the entry input is
|
|
83
|
+
* disabled (matching the controller's add-no-op behaviour).
|
|
84
|
+
*/
|
|
85
|
+
maxTags?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Characters or `event.key` names that finalize a tag. Default
|
|
88
|
+
* `[',', 'Enter']`.
|
|
89
|
+
*/
|
|
90
|
+
separators?: string[];
|
|
91
|
+
}>(),
|
|
92
|
+
{
|
|
93
|
+
id: undefined,
|
|
94
|
+
name: undefined,
|
|
95
|
+
placeholder: undefined,
|
|
96
|
+
disabled: false,
|
|
97
|
+
hint: undefined,
|
|
98
|
+
errors: () => [],
|
|
99
|
+
status: undefined,
|
|
100
|
+
maxTags: undefined,
|
|
101
|
+
separators: () => [',', 'Enter'],
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const emit = defineEmits<{
|
|
106
|
+
'update:modelValue': [tags: string[]];
|
|
107
|
+
blur: [];
|
|
108
|
+
}>();
|
|
109
|
+
|
|
110
|
+
const entryRef = ref<HTMLInputElement | null>(null);
|
|
111
|
+
const entryText = ref('');
|
|
112
|
+
|
|
113
|
+
const inputId = props.id ?? generateId('input');
|
|
114
|
+
const hintId = `${inputId}-hint`;
|
|
115
|
+
const errorsId = `${inputId}-errors`;
|
|
116
|
+
|
|
117
|
+
const hasErrors = computed(() => props.errors.length > 0);
|
|
118
|
+
|
|
119
|
+
const computedStatus = computed(() => {
|
|
120
|
+
if (props.status) return props.status;
|
|
121
|
+
if (hasErrors.value) return 'error';
|
|
122
|
+
return undefined;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const describedBy = computed(() => {
|
|
126
|
+
const ids: string[] = [];
|
|
127
|
+
if (props.hint) ids.push(hintId);
|
|
128
|
+
if (hasErrors.value) ids.push(errorsId);
|
|
129
|
+
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const maxReached = computed(
|
|
133
|
+
() =>
|
|
134
|
+
props.maxTags !== undefined && props.modelValue.length >= props.maxTags,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
function isSeparator(event: KeyboardEvent): boolean {
|
|
138
|
+
for (const sep of props.separators) {
|
|
139
|
+
if (event.key === sep) return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function addTag(raw: string): boolean {
|
|
145
|
+
const value = raw.trim();
|
|
146
|
+
if (!value) return false;
|
|
147
|
+
if (maxReached.value) return false;
|
|
148
|
+
if (props.modelValue.includes(value)) return false;
|
|
149
|
+
emit('update:modelValue', [...props.modelValue, value]);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function removeTag(index: number): void {
|
|
154
|
+
if (props.disabled) return;
|
|
155
|
+
if (index < 0 || index >= props.modelValue.length) return;
|
|
156
|
+
const next = props.modelValue.filter((_, i) => i !== index);
|
|
157
|
+
emit('update:modelValue', next);
|
|
158
|
+
// Return focus to the entry input so the keyboard user keeps typing.
|
|
159
|
+
entryRef.value?.focus();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
163
|
+
if (props.disabled) return;
|
|
164
|
+
if (isSeparator(event)) {
|
|
165
|
+
if (entryText.value.trim().length > 0) {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
if (addTag(entryText.value)) {
|
|
168
|
+
entryText.value = '';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
event.key === 'Backspace' &&
|
|
175
|
+
entryText.value === '' &&
|
|
176
|
+
props.modelValue.length > 0
|
|
177
|
+
) {
|
|
178
|
+
event.preventDefault();
|
|
179
|
+
removeTag(props.modelValue.length - 1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function onBlur(): void {
|
|
184
|
+
// Commit a pending typed tag on blur — matches the canonical
|
|
185
|
+
// Reka UI / shadcn TagsInput UX where focus-out is treated as a
|
|
186
|
+
// soft commit so the user doesn't lose what they were typing.
|
|
187
|
+
if (entryText.value.trim().length > 0) {
|
|
188
|
+
if (addTag(entryText.value)) {
|
|
189
|
+
entryText.value = '';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
emit('blur');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function onWrapperClick(event: MouseEvent): void {
|
|
196
|
+
// Click anywhere in the wrapper (but not on a remove button) lands
|
|
197
|
+
// focus on the entry input — large click target for touch users.
|
|
198
|
+
const target = event.target;
|
|
199
|
+
if (target instanceof HTMLButtonElement) return;
|
|
200
|
+
if (target instanceof HTMLInputElement) return;
|
|
201
|
+
entryRef.value?.focus();
|
|
202
|
+
}
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<style>
|
|
206
|
+
@import './tags-input.css';
|
|
207
|
+
</style>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
/* ===== pui-tags-input (flex-wrapping pills + entry input) =====
|
|
3
|
+
*
|
|
4
|
+
* TagsInput extends the locked Form-primitive scaffold (the outer
|
|
5
|
+
* `.pui-field` wrapper from `text-input.css`) with a flex-wrapping
|
|
6
|
+
* row that hosts existing tag pills followed by a text input where
|
|
7
|
+
* the user types the next tag. The whole row reads as one
|
|
8
|
+
* integrated control: clicking anywhere lands focus on the entry
|
|
9
|
+
* input (handled by Vue / via the click handler in
|
|
10
|
+
* `mountTagsInput`).
|
|
11
|
+
*
|
|
12
|
+
* Layout rationale: rejected the "fixed-height pills + horizontally-
|
|
13
|
+
* scrolling row" alternative because vertical wrapping reads better
|
|
14
|
+
* on narrow form columns and avoids hiding tags off-screen. The
|
|
15
|
+
* entry input is the LAST DOM child so flex-wrap places it after
|
|
16
|
+
* the pills and pill removal naturally re-flows.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
.pui-tags-input {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-wrap: wrap;
|
|
22
|
+
align-items: center;
|
|
23
|
+
gap: var(--space-2xs);
|
|
24
|
+
width: 100%;
|
|
25
|
+
padding: 0.4em 0.55em;
|
|
26
|
+
background: var(--bg-clr-surface);
|
|
27
|
+
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
28
|
+
border-radius: var(--radius-sm);
|
|
29
|
+
color: var(--text-clr-base);
|
|
30
|
+
transition:
|
|
31
|
+
border-color 0.12s ease,
|
|
32
|
+
box-shadow 0.12s ease;
|
|
33
|
+
cursor: text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.pui-tags-input:focus-within {
|
|
37
|
+
outline: none;
|
|
38
|
+
border-color: var(--border-clr-brand);
|
|
39
|
+
box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.pui-tags-input[data-disabled='true'] {
|
|
43
|
+
background: var(--bg-clr-surface-2);
|
|
44
|
+
color: var(--text-clr-muted);
|
|
45
|
+
cursor: not-allowed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Status states inherited from .pui-field — recolour to match
|
|
49
|
+
* .pui-input / .pui-textarea when the parent carries `data-status`. */
|
|
50
|
+
.pui-field[data-status='error'] .pui-tags-input {
|
|
51
|
+
border-color: var(--border-clr-danger);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.pui-field[data-status='success'] .pui-tags-input {
|
|
55
|
+
border-color: var(--border-clr-success);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Tag pill: rounded chip with label + remove button. */
|
|
59
|
+
.pui-tags-input__tag {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: var(--space-3xs);
|
|
63
|
+
padding: 0.15em 0.5em;
|
|
64
|
+
background: var(--bg-clr-surface-2);
|
|
65
|
+
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
66
|
+
border-radius: var(--radius-pill, 999px);
|
|
67
|
+
font-size: var(--step--1);
|
|
68
|
+
line-height: 1.2;
|
|
69
|
+
max-width: 100%;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.pui-tags-input__tag-label {
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
text-overflow: ellipsis;
|
|
75
|
+
white-space: nowrap;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.pui-tags-input__tag-remove {
|
|
79
|
+
appearance: none;
|
|
80
|
+
background: transparent;
|
|
81
|
+
border: 0;
|
|
82
|
+
padding: 0;
|
|
83
|
+
margin: 0;
|
|
84
|
+
color: inherit;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
font: inherit;
|
|
87
|
+
line-height: 1;
|
|
88
|
+
width: 1.25em;
|
|
89
|
+
height: 1.25em;
|
|
90
|
+
display: grid;
|
|
91
|
+
place-items: center;
|
|
92
|
+
border-radius: var(--radius-pill, 999px);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.pui-tags-input__tag-remove:hover:not(:disabled) {
|
|
96
|
+
background: var(--bg-clr-surface);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.pui-tags-input__tag-remove:focus-visible {
|
|
100
|
+
outline: 2px solid var(--outline-clr-base);
|
|
101
|
+
outline-offset: 1px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.pui-tags-input__tag-remove:disabled {
|
|
105
|
+
cursor: not-allowed;
|
|
106
|
+
color: var(--text-clr-muted);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Entry input: borderless, flex-grows to fill the trailing slot.
|
|
110
|
+
* Inherits the .pui-input typography rules in spirit but does NOT
|
|
111
|
+
* carry the .pui-input class — the wrapper is the visual border /
|
|
112
|
+
* focus surface. */
|
|
113
|
+
.pui-tags-input__entry {
|
|
114
|
+
appearance: none;
|
|
115
|
+
background: transparent;
|
|
116
|
+
border: 0;
|
|
117
|
+
color: inherit;
|
|
118
|
+
font: inherit;
|
|
119
|
+
flex: 1;
|
|
120
|
+
min-width: 6rem;
|
|
121
|
+
padding: 0.25em 0;
|
|
122
|
+
outline: none;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.pui-tags-input__entry:disabled {
|
|
126
|
+
cursor: not-allowed;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './text-input.css';
|
|
3
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
id?: string;
|
|
7
|
+
label: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
type?: string;
|
|
10
|
+
value?: string;
|
|
11
|
+
autocomplete?: string;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
errors?: string[];
|
|
17
|
+
status?: 'error' | 'success';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
id,
|
|
22
|
+
label,
|
|
23
|
+
name,
|
|
24
|
+
type = 'text',
|
|
25
|
+
value = '',
|
|
26
|
+
autocomplete,
|
|
27
|
+
required = false,
|
|
28
|
+
disabled = false,
|
|
29
|
+
placeholder,
|
|
30
|
+
hint,
|
|
31
|
+
errors = [],
|
|
32
|
+
status,
|
|
33
|
+
...rest
|
|
34
|
+
} = Astro.props;
|
|
35
|
+
|
|
36
|
+
const inputId = id ?? generateId('input');
|
|
37
|
+
const hintId = `${inputId}-hint`;
|
|
38
|
+
const errorsId = `${inputId}-errors`;
|
|
39
|
+
|
|
40
|
+
const hasErrors = errors.length > 0;
|
|
41
|
+
const computedStatus = status ?? (hasErrors ? 'error' : undefined);
|
|
42
|
+
|
|
43
|
+
const describedByIds: string[] = [];
|
|
44
|
+
if (hint) describedByIds.push(hintId);
|
|
45
|
+
if (hasErrors) describedByIds.push(errorsId);
|
|
46
|
+
const describedBy =
|
|
47
|
+
describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
<div class="pui-field" data-status={computedStatus}>
|
|
51
|
+
<label class="pui-field__label" for={inputId}>
|
|
52
|
+
{label}
|
|
53
|
+
</label>
|
|
54
|
+
<input
|
|
55
|
+
class="pui-input"
|
|
56
|
+
id={inputId}
|
|
57
|
+
type={type}
|
|
58
|
+
name={name}
|
|
59
|
+
value={value}
|
|
60
|
+
autocomplete={autocomplete}
|
|
61
|
+
required={required}
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
placeholder={placeholder}
|
|
64
|
+
aria-invalid={hasErrors ? 'true' : undefined}
|
|
65
|
+
aria-describedby={describedBy}
|
|
66
|
+
{...rest}
|
|
67
|
+
/>
|
|
68
|
+
{
|
|
69
|
+
hint && (
|
|
70
|
+
<p id={hintId} class="pui-field__hint">
|
|
71
|
+
{hint}
|
|
72
|
+
</p>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
{
|
|
76
|
+
hasErrors && (
|
|
77
|
+
<ul id={errorsId} class="pui-field__hint" role="alert">
|
|
78
|
+
{errors.map((error) => (
|
|
79
|
+
<li>{error}</li>
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
</div>
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
:autocomplete="autocomplete"
|
|
12
|
+
:required="required"
|
|
13
|
+
:disabled="disabled"
|
|
14
|
+
:placeholder="placeholder"
|
|
15
|
+
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
16
|
+
:aria-describedby="describedBy"
|
|
17
|
+
@input="
|
|
18
|
+
emit(
|
|
19
|
+
'update:modelValue',
|
|
20
|
+
($event.target as HTMLInputElement).value,
|
|
21
|
+
)
|
|
22
|
+
"
|
|
23
|
+
@blur="emit('blur')"
|
|
24
|
+
/>
|
|
25
|
+
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
26
|
+
<ul
|
|
27
|
+
v-if="errors.length"
|
|
28
|
+
:id="errorsId"
|
|
29
|
+
class="pui-field__hint"
|
|
30
|
+
role="alert"
|
|
31
|
+
>
|
|
32
|
+
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup lang="ts">
|
|
38
|
+
import { computed } from 'vue';
|
|
39
|
+
import { generateId } from '../../../utils/a11y/id.js';
|
|
40
|
+
|
|
41
|
+
defineOptions({ inheritAttrs: false });
|
|
42
|
+
|
|
43
|
+
const props = withDefaults(
|
|
44
|
+
defineProps<{
|
|
45
|
+
id?: string;
|
|
46
|
+
label: string;
|
|
47
|
+
modelValue: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
type?: string;
|
|
50
|
+
autocomplete?: string;
|
|
51
|
+
required?: boolean;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
placeholder?: string;
|
|
54
|
+
hint?: string;
|
|
55
|
+
errors?: string[];
|
|
56
|
+
status?: 'error' | 'success';
|
|
57
|
+
}>(),
|
|
58
|
+
{
|
|
59
|
+
id: undefined,
|
|
60
|
+
name: undefined,
|
|
61
|
+
type: 'text',
|
|
62
|
+
autocomplete: undefined,
|
|
63
|
+
required: false,
|
|
64
|
+
disabled: false,
|
|
65
|
+
placeholder: undefined,
|
|
66
|
+
hint: undefined,
|
|
67
|
+
errors: () => [],
|
|
68
|
+
status: undefined,
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const emit = defineEmits<{
|
|
73
|
+
'update:modelValue': [value: string];
|
|
74
|
+
blur: [];
|
|
75
|
+
}>();
|
|
76
|
+
|
|
77
|
+
const inputId = props.id ?? generateId('input');
|
|
78
|
+
const hintId = `${inputId}-hint`;
|
|
79
|
+
const errorsId = `${inputId}-errors`;
|
|
80
|
+
|
|
81
|
+
const hasErrors = computed(() => props.errors.length > 0);
|
|
82
|
+
|
|
83
|
+
const computedStatus = computed(() => {
|
|
84
|
+
if (props.status) return props.status;
|
|
85
|
+
if (hasErrors.value) return 'error';
|
|
86
|
+
return undefined;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const describedBy = computed(() => {
|
|
90
|
+
const ids: string[] = [];
|
|
91
|
+
if (props.hint) ids.push(hintId);
|
|
92
|
+
if (hasErrors.value) ids.push(errorsId);
|
|
93
|
+
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
94
|
+
});
|
|
95
|
+
</script>
|
|
96
|
+
|
|
97
|
+
<style>
|
|
98
|
+
@import './text-input.css';
|
|
99
|
+
</style>
|