@pienter/ui 0.7.1 → 0.9.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 +108 -0
- package/CONVENTIONS.md +47 -29
- package/README.md +40 -0
- package/components/display/record-details/record-details.css +2 -2
- package/components/feedback/toast/Toast.vue +3 -4
- package/components/feedback/toast/ToastHost.vue +165 -0
- package/components/feedback/toast/toast.ts +116 -0
- package/components/feedback/toast/types.ts +48 -0
- package/components/form/block-editor/BlockEditor.vue +2 -1
- package/components/form/checkbox/Checkbox.vue +10 -2
- package/components/form/combobox/Combobox.vue +11 -5
- package/components/form/combobox/combobox.css +1 -1
- package/components/form/date-input/DateInput.vue +10 -2
- package/components/form/date-input/date-input.css +2 -17
- package/components/form/form/Form.vue +10 -9
- package/components/form/form/form.css +2 -22
- package/components/form/input-otp/InputOTP.vue +1 -1
- package/components/form/input-otp/input-otp.css +1 -1
- package/components/form/label/label.css +2 -11
- package/components/form/number-field/NumberField.vue +10 -2
- package/components/form/number-field/number-field.css +3 -3
- package/components/form/radio-group/RadioGroup.vue +1 -1
- package/components/form/select/Select.vue +13 -4
- package/components/form/select/select.css +6 -9
- package/components/form/slider/Slider.vue +1 -1
- package/components/form/switch/Switch.vue +1 -1
- package/components/form/tags-input/TagsInput.vue +17 -2
- package/components/form/tags-input/tags-input.css +16 -12
- package/components/form/text-input/TextInput.vue +10 -2
- package/components/form/text-input/text-input.css +10 -131
- package/components/form/textarea/Textarea.vue +10 -2
- package/components/form/textarea/textarea.css +3 -19
- package/components/layout/app-layout/AppLayout.vue +116 -0
- package/components/layout/app-layout/app-layout.css +115 -0
- package/components/layout/index/Index.vue +31 -11
- package/components/layout/index/index.css +55 -12
- package/components/layout/index/useIndex.ts +31 -14
- package/components/layout/table/DataTable.vue +14 -1
- package/components/layout/table/Table.vue +12 -0
- package/components/navigation/breadcrumb/breadcrumb.css +6 -1
- package/components/navigation/sidebar/sidebar.css +23 -16
- package/components/navigation/tabs/tabs.css +7 -7
- package/composables/useUrlTab.ts +38 -0
- package/package.json +7 -1
- package/styles/4-components/form-field.css +112 -0
- package/styles/4-components/index.css +1 -0
- package/styles/main.css +1 -0
- package/utils/a11y/index.ts +5 -1
- package/utils/a11y/live-region.ts +2 -1
- package/utils/cms/index.ts +21 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { IconName } from '../../../icons/index.js';
|
|
3
|
+
|
|
4
|
+
export type ToastTone = 'brand' | 'success' | 'warning' | 'danger';
|
|
5
|
+
|
|
6
|
+
export type ToastPosition =
|
|
7
|
+
| 'bottom-right'
|
|
8
|
+
| 'bottom-center'
|
|
9
|
+
| 'bottom-left'
|
|
10
|
+
| 'top-right'
|
|
11
|
+
| 'top-center'
|
|
12
|
+
| 'top-left';
|
|
13
|
+
|
|
14
|
+
export interface ToastOptions {
|
|
15
|
+
title: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
tone?: ToastTone;
|
|
18
|
+
/** Milliseconds. `0` keeps the toast visible until it is dismissed. */
|
|
19
|
+
duration?: number;
|
|
20
|
+
dismissible?: boolean;
|
|
21
|
+
action?: { label: string; onClick: () => void };
|
|
22
|
+
icon?: IconName;
|
|
23
|
+
id?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ToastConfig {
|
|
27
|
+
maxVisible?: number;
|
|
28
|
+
defaultDuration?: number;
|
|
29
|
+
position?: ToastPosition;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToastItem extends Omit<
|
|
33
|
+
ToastOptions,
|
|
34
|
+
'duration' | 'dismissible' | 'id'
|
|
35
|
+
> {
|
|
36
|
+
id: string;
|
|
37
|
+
duration: number;
|
|
38
|
+
dismissible: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ToastController {
|
|
42
|
+
readonly visible: Readonly<Ref<readonly ToastItem[]>>;
|
|
43
|
+
readonly position: Readonly<Ref<ToastPosition>>;
|
|
44
|
+
show(options: ToastOptions): string;
|
|
45
|
+
dismiss(id: string): void;
|
|
46
|
+
dismissAll(): void;
|
|
47
|
+
configure(config: ToastConfig): void;
|
|
48
|
+
}
|
|
@@ -311,7 +311,7 @@ onBeforeUnmount(stopDrag);
|
|
|
311
311
|
:data-disabled="disabled ? 'true' : undefined"
|
|
312
312
|
>
|
|
313
313
|
<p :id="`${id}-hint`" class="pui-block-editor__hint">
|
|
314
|
-
|
|
314
|
+
Use the move buttons to reorder, or drag with a pointer.
|
|
315
315
|
</p>
|
|
316
316
|
<ol
|
|
317
317
|
v-if="modelValue.length"
|
|
@@ -335,6 +335,7 @@ onBeforeUnmount(stopDrag);
|
|
|
335
335
|
<button
|
|
336
336
|
class="pui-block-editor__drag"
|
|
337
337
|
type="button"
|
|
338
|
+
tabindex="-1"
|
|
338
339
|
:disabled="disabled || modelValue.length < 2"
|
|
339
340
|
:aria-label="`Drag ${title(block, index)}`"
|
|
340
341
|
:aria-describedby="`${id}-hint`"
|
|
@@ -19,13 +19,21 @@
|
|
|
19
19
|
)
|
|
20
20
|
"
|
|
21
21
|
/>
|
|
22
|
-
<label class="pui-field__label" :for="inputId"
|
|
22
|
+
<label class="pui-field__label" :for="inputId"
|
|
23
|
+
>{{ label
|
|
24
|
+
}}<span
|
|
25
|
+
v-if="required"
|
|
26
|
+
class="pui-field__required"
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
>*</span
|
|
29
|
+
></label
|
|
30
|
+
>
|
|
23
31
|
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
24
32
|
<ul
|
|
25
33
|
v-if="errors.length"
|
|
26
34
|
:id="errorsId"
|
|
27
35
|
class="pui-field__hint"
|
|
28
|
-
role="
|
|
36
|
+
role="status"
|
|
29
37
|
>
|
|
30
38
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
31
39
|
</ul>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div ref="fieldRef" class="pui-field" :data-status="computedStatus">
|
|
3
|
-
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
4
|
-
label
|
|
5
|
-
|
|
3
|
+
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
4
|
+
>{{ label
|
|
5
|
+
}}<span
|
|
6
|
+
v-if="required"
|
|
7
|
+
class="pui-field__required"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
>*</span
|
|
10
|
+
></label
|
|
11
|
+
>
|
|
6
12
|
<input
|
|
7
13
|
v-bind="$attrs"
|
|
8
14
|
:id="inputId"
|
|
@@ -77,7 +83,7 @@
|
|
|
77
83
|
v-if="errors.length"
|
|
78
84
|
:id="errorsId"
|
|
79
85
|
class="pui-field__hint"
|
|
80
|
-
role="
|
|
86
|
+
role="status"
|
|
81
87
|
>
|
|
82
88
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
83
89
|
</ul>
|
|
@@ -144,7 +150,7 @@ const props = withDefaults(
|
|
|
144
150
|
hint?: string;
|
|
145
151
|
/**
|
|
146
152
|
* Validation errors. Renders as a list under the input with
|
|
147
|
-
* `role="
|
|
153
|
+
* `role="status"` and toggles `aria-invalid="true"` plus the
|
|
148
154
|
* `data-status="error"` styling on `.pui-field`.
|
|
149
155
|
*/
|
|
150
156
|
errors?: string[];
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Combobox is built on top of the form-primitive scaffold (`.pui-field`
|
|
5
5
|
* with `.pui-field__label` / `.pui-field__hint` / status-driven recolour)
|
|
6
|
-
* owned by `
|
|
6
|
+
* owned by `styles/4-components/form-field.css`. The input itself uses the shared
|
|
7
7
|
* `.pui-input` class so its border, padding, focus ring, and
|
|
8
8
|
* disabled state match TextInput / Textarea exactly.
|
|
9
9
|
*
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
-
<label class="pui-field__label" :for="inputId"
|
|
3
|
+
<label class="pui-field__label" :for="inputId"
|
|
4
|
+
>{{ label
|
|
5
|
+
}}<span
|
|
6
|
+
v-if="required"
|
|
7
|
+
class="pui-field__required"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
>*</span
|
|
10
|
+
></label
|
|
11
|
+
>
|
|
4
12
|
<input
|
|
5
13
|
v-bind="$attrs"
|
|
6
14
|
:id="inputId"
|
|
@@ -28,7 +36,7 @@
|
|
|
28
36
|
v-if="errors.length"
|
|
29
37
|
:id="errorsId"
|
|
30
38
|
class="pui-field__hint"
|
|
31
|
-
role="
|
|
39
|
+
role="status"
|
|
32
40
|
>
|
|
33
41
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
34
42
|
</ul>
|
|
@@ -1,19 +1,4 @@
|
|
|
1
1
|
@layer components {
|
|
2
|
-
/* DateInput reuses
|
|
3
|
-
|
|
4
|
-
* alongside `.pui-textarea` in `components/form/text-input/text-input.css`
|
|
5
|
-
* because the visual shell is identical to TextInput — only the
|
|
6
|
-
* native input `type` differs (`date` / `time` / `datetime-local`
|
|
7
|
-
* vs `text`). The browser owns the picker UI; we just style the
|
|
8
|
-
* input chrome consistently with the rest of the form-primitive
|
|
9
|
-
* scaffold.
|
|
10
|
-
*
|
|
11
|
-
* This file is intentionally a near-empty placeholder following the
|
|
12
|
-
* Textarea pattern — every form primitive keeps its own CSS file
|
|
13
|
-
* for layered consistency, even when there are no component-specific
|
|
14
|
-
* rules. When the fifth-plus form primitive ships and the shared
|
|
15
|
-
* `.pui-input` rules outgrow `text-input.css`, they'll move into a
|
|
16
|
-
* dedicated `form-field.css` per the deferred-extraction note in
|
|
17
|
-
* CONVENTIONS.
|
|
18
|
-
*/
|
|
2
|
+
/* DateInput reuses `.pui-input` from `styles/4-components/form-field.css`;
|
|
3
|
+
* only the native `type` differs from TextInput. */
|
|
19
4
|
}
|
|
@@ -6,17 +6,15 @@
|
|
|
6
6
|
:aria-busy="busy ? 'true' : undefined"
|
|
7
7
|
@submit.prevent="handleSubmit"
|
|
8
8
|
>
|
|
9
|
-
<
|
|
9
|
+
<Alert
|
|
10
10
|
v-if="topErrors.length > 0"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
aria-live="assertive"
|
|
11
|
+
tone="danger"
|
|
12
|
+
title="There was a problem"
|
|
14
13
|
>
|
|
15
|
-
<
|
|
16
|
-
<ul class="pui-form__error-summary-list">
|
|
14
|
+
<ul class="pui-form__errors">
|
|
17
15
|
<li v-for="error in topErrors" :key="error">{{ error }}</li>
|
|
18
16
|
</ul>
|
|
19
|
-
</
|
|
17
|
+
</Alert>
|
|
20
18
|
|
|
21
19
|
<slot />
|
|
22
20
|
|
|
@@ -40,9 +38,10 @@
|
|
|
40
38
|
</template>
|
|
41
39
|
|
|
42
40
|
<script setup lang="ts">
|
|
43
|
-
import { computed, ref, watch } from 'vue';
|
|
41
|
+
import { computed, onMounted, ref, watch } from 'vue';
|
|
42
|
+
import Alert from '../../feedback/alert/Alert.vue';
|
|
44
43
|
import { focusFirstInvalid } from '../../../utils/a11y/focus.js';
|
|
45
|
-
import { announce } from '../../../utils/a11y/live-region.js';
|
|
44
|
+
import { announce, ensureLiveRegion } from '../../../utils/a11y/live-region.js';
|
|
46
45
|
import { type Validator } from '../../../utils/validation/rules.js';
|
|
47
46
|
import {
|
|
48
47
|
validateFormData,
|
|
@@ -81,6 +80,8 @@ const emit = defineEmits<{
|
|
|
81
80
|
|
|
82
81
|
const formRef = ref<HTMLFormElement | null>(null);
|
|
83
82
|
|
|
83
|
+
onMounted(ensureLiveRegion);
|
|
84
|
+
|
|
84
85
|
// Internal client-validation errors. Merged with server-supplied
|
|
85
86
|
// `fieldErrors` for display via the form-primitive children's own
|
|
86
87
|
// `errors` prop pattern (consumer wires them up by name); the summary
|
|
@@ -5,29 +5,9 @@
|
|
|
5
5
|
gap: var(--space-m);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
.pui-
|
|
9
|
-
background: var(--bg-clr-danger-soft);
|
|
10
|
-
border: var(--stroke-sm) solid var(--border-clr-danger);
|
|
11
|
-
border-radius: var(--radius-sm);
|
|
12
|
-
padding: var(--space-s) var(--space-m);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
.pui-form__error-summary-title {
|
|
16
|
-
font-weight: var(--fw-semibold);
|
|
17
|
-
color: var(--text-clr-danger);
|
|
18
|
-
margin-bottom: var(--space-xs);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
.pui-form__error-summary-list {
|
|
22
|
-
list-style: disc inside;
|
|
23
|
-
padding: 0;
|
|
8
|
+
.pui-form__errors {
|
|
24
9
|
margin: 0;
|
|
25
|
-
|
|
26
|
-
font-size: var(--step--1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
.pui-form__error-summary-list li {
|
|
30
|
-
margin-block: var(--space-2xs);
|
|
10
|
+
padding-inline-start: var(--space-s);
|
|
31
11
|
}
|
|
32
12
|
|
|
33
13
|
.pui-form__status {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
.pui-input-otp__slot {
|
|
30
30
|
/* Inherits sizing, padding, focus-ring, disabled, and status
|
|
31
|
-
* recolour from the shared .pui-input rule (
|
|
31
|
+
* recolour from the shared .pui-input rule (form-field.css). The
|
|
32
32
|
* overrides here narrow the slot to a square box and centre the
|
|
33
33
|
* typed character. */
|
|
34
34
|
width: 2.75rem;
|
|
@@ -1,11 +1,2 @@
|
|
|
1
|
-
/*
|
|
2
|
-
*
|
|
3
|
-
* live in `components/form/text-input/text-input.css` (the shared form-primitive
|
|
4
|
-
* scaffold). Label exposes the same class as a standalone component for
|
|
5
|
-
* use cases where the form-primitive components' built-in label is not
|
|
6
|
-
* suitable (e.g., labels associated with custom groups or with multiple
|
|
7
|
-
* controls). Importing this file re-imports the scaffold so a
|
|
8
|
-
* standalone Label on a page that does not also use
|
|
9
|
-
* TextInput / Textarea / Select still gets the typography rules.
|
|
10
|
-
*/
|
|
11
|
-
@import '../text-input/text-input.css';
|
|
1
|
+
/* `.pui-field__label` is defined by the scaffold in
|
|
2
|
+
* `styles/4-components/form-field.css`, which the styles entry loads. */
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
-
<label class="pui-field__label" :for="inputId"
|
|
3
|
+
<label class="pui-field__label" :for="inputId"
|
|
4
|
+
>{{ label
|
|
5
|
+
}}<span
|
|
6
|
+
v-if="required"
|
|
7
|
+
class="pui-field__required"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
>*</span
|
|
10
|
+
></label
|
|
11
|
+
>
|
|
4
12
|
<div class="pui-number-field">
|
|
5
13
|
<input
|
|
6
14
|
v-bind="$attrs"
|
|
@@ -49,7 +57,7 @@
|
|
|
49
57
|
v-if="errors.length"
|
|
50
58
|
:id="errorsId"
|
|
51
59
|
class="pui-field__hint"
|
|
52
|
-
role="
|
|
60
|
+
role="status"
|
|
53
61
|
>
|
|
54
62
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
55
63
|
</ul>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* ===== pui-number-field (input + flanking step buttons) =====
|
|
3
3
|
*
|
|
4
4
|
* NumberField extends the locked Form-primitive scaffold (the outer
|
|
5
|
-
* `.pui-field` wrapper from `
|
|
5
|
+
* `.pui-field` wrapper from `styles/4-components/form-field.css`) with an inline-flex
|
|
6
6
|
* row that hosts the native `<input type="number">` on the left and
|
|
7
7
|
* two stacked step buttons on the right. The native browser spinner
|
|
8
8
|
* is suppressed via `appearance: textfield` (Webkit / Firefox)
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
.pui-number-field__input {
|
|
26
26
|
/* Inherits sizing, padding, focus-ring, disabled, and status
|
|
27
|
-
* recolour from the shared .pui-input rule (
|
|
27
|
+
* recolour from the shared .pui-input rule (form-field.css). The
|
|
28
28
|
* overrides here only adjust the right-side border radii so the
|
|
29
29
|
* input segment butts cleanly against the stepper column. */
|
|
30
30
|
flex: 1;
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
/* Status states inherited from .pui-field — recolour BOTH the input
|
|
103
103
|
* and the step buttons so the whole control reads as errored /
|
|
104
104
|
* succeeded. Mirrors the .pui-input / .pui-textarea status recolour in
|
|
105
|
-
*
|
|
105
|
+
* form-field.css. */
|
|
106
106
|
.pui-field[data-status='error'] .pui-number-field__input,
|
|
107
107
|
.pui-field[data-status='error'] .pui-number-field__step {
|
|
108
108
|
border-color: var(--border-clr-danger);
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
-
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
4
|
-
label
|
|
5
|
-
|
|
3
|
+
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
4
|
+
>{{ label
|
|
5
|
+
}}<span
|
|
6
|
+
v-if="required"
|
|
7
|
+
class="pui-field__required"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
>*</span
|
|
10
|
+
></label
|
|
11
|
+
>
|
|
6
12
|
<div class="pui-select">
|
|
7
13
|
<select
|
|
8
14
|
v-bind="$attrs"
|
|
9
15
|
:id="inputId"
|
|
10
16
|
class="pui-select__control"
|
|
11
17
|
:name="name"
|
|
18
|
+
:required="required"
|
|
12
19
|
:disabled="disabled"
|
|
13
20
|
:value="modelValue"
|
|
14
21
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
@@ -40,7 +47,7 @@
|
|
|
40
47
|
v-if="errors.length"
|
|
41
48
|
:id="errorsId"
|
|
42
49
|
class="pui-field__hint"
|
|
43
|
-
role="
|
|
50
|
+
role="status"
|
|
44
51
|
>
|
|
45
52
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
46
53
|
</ul>
|
|
@@ -60,6 +67,7 @@ const props = withDefaults(
|
|
|
60
67
|
label?: string;
|
|
61
68
|
modelValue?: string;
|
|
62
69
|
name?: string;
|
|
70
|
+
required?: boolean;
|
|
63
71
|
disabled?: boolean;
|
|
64
72
|
options: { value: string; label: string }[];
|
|
65
73
|
placeholder?: string;
|
|
@@ -72,6 +80,7 @@ const props = withDefaults(
|
|
|
72
80
|
label: undefined,
|
|
73
81
|
modelValue: undefined,
|
|
74
82
|
name: undefined,
|
|
83
|
+
required: false,
|
|
75
84
|
disabled: false,
|
|
76
85
|
placeholder: undefined,
|
|
77
86
|
hint: undefined,
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
/* ===== pui-select (form-primitive control) =====
|
|
3
3
|
*
|
|
4
4
|
* Wrapped by `.pui-field` (the shared form-primitive scaffold owned by
|
|
5
|
-
*
|
|
5
|
+
* `styles/4-components/form-field.css`). The status-driven recolour (`error` / `success` border)
|
|
6
6
|
* is applied by the parent `.pui-field[data-status]` selector below, mirroring
|
|
7
|
-
* `.pui-input` in
|
|
8
|
-
* as `.pui-input` for visual consistency.
|
|
7
|
+
* `.pui-input` in form-field.css.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
.pui-select {
|
|
@@ -20,19 +19,17 @@
|
|
|
20
19
|
padding: 0.65em 2.4em 0.65em 0.85em;
|
|
21
20
|
font: inherit;
|
|
22
21
|
background: var(--bg-clr-surface);
|
|
23
|
-
border: var(--stroke-sm) solid var(--border-clr-
|
|
22
|
+
border: var(--stroke-sm) solid var(--border-clr-input);
|
|
24
23
|
border-radius: var(--radius-sm);
|
|
25
24
|
cursor: pointer;
|
|
26
25
|
color: var(--text-clr-base);
|
|
27
|
-
transition:
|
|
28
|
-
border-color 0.12s ease,
|
|
29
|
-
box-shadow 0.12s ease;
|
|
26
|
+
transition: border-color var(--duration-fast) var(--ease-base);
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
.pui-select__control:focus-visible {
|
|
33
|
-
outline: none;
|
|
34
30
|
border-color: var(--border-clr-brand);
|
|
35
|
-
|
|
31
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
32
|
+
outline-offset: var(--outline-offset);
|
|
36
33
|
}
|
|
37
34
|
|
|
38
35
|
.pui-select__control:disabled {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
-
<label class="pui-field__label" :for="inputId"
|
|
3
|
+
<label class="pui-field__label" :for="inputId"
|
|
4
|
+
>{{ label
|
|
5
|
+
}}<span
|
|
6
|
+
v-if="required"
|
|
7
|
+
class="pui-field__required"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
>*</span
|
|
10
|
+
></label
|
|
11
|
+
>
|
|
4
12
|
<!--
|
|
5
13
|
onWrapperClick only forwards a click on the empty chrome of the field
|
|
6
14
|
to the real <input> below (a larger pointer target). That input is a
|
|
@@ -42,6 +50,7 @@
|
|
|
42
50
|
:name="name"
|
|
43
51
|
:placeholder="placeholder"
|
|
44
52
|
:disabled="disabled || maxReached"
|
|
53
|
+
:aria-required="required ? 'true' : undefined"
|
|
45
54
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
46
55
|
:aria-describedby="describedBy"
|
|
47
56
|
@keydown="onKeydown"
|
|
@@ -53,7 +62,7 @@
|
|
|
53
62
|
v-if="errors.length"
|
|
54
63
|
:id="errorsId"
|
|
55
64
|
class="pui-field__hint"
|
|
56
|
-
role="
|
|
65
|
+
role="status"
|
|
57
66
|
>
|
|
58
67
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
59
68
|
</ul>
|
|
@@ -63,6 +72,7 @@
|
|
|
63
72
|
<script setup lang="ts">
|
|
64
73
|
import { computed, ref } from 'vue';
|
|
65
74
|
import { generateId } from '../../../utils/a11y/id.js';
|
|
75
|
+
import { announce } from '../../../utils/a11y/live-region.js';
|
|
66
76
|
|
|
67
77
|
defineOptions({ inheritAttrs: false });
|
|
68
78
|
|
|
@@ -74,6 +84,8 @@ const props = withDefaults(
|
|
|
74
84
|
modelValue: string[];
|
|
75
85
|
name?: string;
|
|
76
86
|
placeholder?: string;
|
|
87
|
+
/** Marks the field; set as `aria-required` on the entry input since the value is the tag list, not the typed text. */
|
|
88
|
+
required?: boolean;
|
|
77
89
|
disabled?: boolean;
|
|
78
90
|
hint?: string;
|
|
79
91
|
errors?: string[];
|
|
@@ -93,6 +105,7 @@ const props = withDefaults(
|
|
|
93
105
|
id: undefined,
|
|
94
106
|
name: undefined,
|
|
95
107
|
placeholder: undefined,
|
|
108
|
+
required: false,
|
|
96
109
|
disabled: false,
|
|
97
110
|
hint: undefined,
|
|
98
111
|
errors: () => [],
|
|
@@ -153,8 +166,10 @@ function addTag(raw: string): boolean {
|
|
|
153
166
|
function removeTag(index: number): void {
|
|
154
167
|
if (props.disabled) return;
|
|
155
168
|
if (index < 0 || index >= props.modelValue.length) return;
|
|
169
|
+
const tag = props.modelValue[index];
|
|
156
170
|
const next = props.modelValue.filter((_, i) => i !== index);
|
|
157
171
|
emit('update:modelValue', next);
|
|
172
|
+
announce(`Removed ${tag}`);
|
|
158
173
|
// Return focus to the entry input so the keyboard user keeps typing.
|
|
159
174
|
entryRef.value?.focus();
|
|
160
175
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* ===== pui-tags-input (flex-wrapping pills + entry input) =====
|
|
3
3
|
*
|
|
4
4
|
* TagsInput extends the locked Form-primitive scaffold (the outer
|
|
5
|
-
* `.pui-field` wrapper from `
|
|
5
|
+
* `.pui-field` wrapper from `styles/4-components/form-field.css`) with a flex-wrapping
|
|
6
6
|
* row that hosts existing tag pills followed by a text input where
|
|
7
7
|
* the user types the next tag. The whole row reads as one
|
|
8
8
|
* integrated control: clicking anywhere lands focus on the entry
|
|
@@ -23,19 +23,22 @@
|
|
|
23
23
|
width: 100%;
|
|
24
24
|
padding: 0.4em 0.55em;
|
|
25
25
|
background: var(--bg-clr-surface);
|
|
26
|
-
border: var(--stroke-sm) solid var(--border-clr-
|
|
26
|
+
border: var(--stroke-sm) solid var(--border-clr-input);
|
|
27
27
|
border-radius: var(--radius-sm);
|
|
28
28
|
color: var(--text-clr-base);
|
|
29
|
-
transition:
|
|
30
|
-
border-color 0.12s ease,
|
|
31
|
-
box-shadow 0.12s ease;
|
|
29
|
+
transition: border-color var(--duration-fast) var(--ease-base);
|
|
32
30
|
cursor: text;
|
|
33
31
|
}
|
|
34
32
|
|
|
35
33
|
.pui-tags-input:focus-within {
|
|
36
|
-
outline: none;
|
|
37
34
|
border-color: var(--border-clr-brand);
|
|
38
|
-
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* The entry is borderless, so the wrapper wears its ring; a focused
|
|
38
|
+
remove button keeps its own. */
|
|
39
|
+
.pui-tags-input:has(.pui-tags-input__entry:focus-visible) {
|
|
40
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
41
|
+
outline-offset: var(--outline-offset);
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
.pui-tags-input[data-disabled='true'] {
|
|
@@ -59,7 +62,8 @@
|
|
|
59
62
|
display: inline-flex;
|
|
60
63
|
align-items: center;
|
|
61
64
|
gap: var(--space-3xs);
|
|
62
|
-
padding: 0
|
|
65
|
+
padding-block: 0;
|
|
66
|
+
padding-inline: var(--space-2xs) var(--space-3xs);
|
|
63
67
|
background: var(--bg-clr-surface-2);
|
|
64
68
|
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
65
69
|
border-radius: var(--radius-pill, 999px);
|
|
@@ -84,8 +88,8 @@
|
|
|
84
88
|
cursor: pointer;
|
|
85
89
|
font: inherit;
|
|
86
90
|
line-height: 1;
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
min-inline-size: var(--space-m);
|
|
92
|
+
min-block-size: var(--space-m);
|
|
89
93
|
display: grid;
|
|
90
94
|
place-items: center;
|
|
91
95
|
border-radius: var(--radius-pill, 999px);
|
|
@@ -96,8 +100,8 @@
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
.pui-tags-input__tag-remove:focus-visible {
|
|
99
|
-
outline:
|
|
100
|
-
outline-offset:
|
|
103
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
104
|
+
outline-offset: var(--outline-offset);
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
.pui-tags-input__tag-remove:disabled {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="pui-field" :data-status="computedStatus">
|
|
3
|
-
<label class="pui-field__label" :for="inputId"
|
|
3
|
+
<label class="pui-field__label" :for="inputId"
|
|
4
|
+
>{{ label
|
|
5
|
+
}}<span
|
|
6
|
+
v-if="required"
|
|
7
|
+
class="pui-field__required"
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
>*</span
|
|
10
|
+
></label
|
|
11
|
+
>
|
|
4
12
|
<input
|
|
5
13
|
v-bind="$attrs"
|
|
6
14
|
:id="inputId"
|
|
@@ -27,7 +35,7 @@
|
|
|
27
35
|
v-if="errors.length"
|
|
28
36
|
:id="errorsId"
|
|
29
37
|
class="pui-field__hint"
|
|
30
|
-
role="
|
|
38
|
+
role="status"
|
|
31
39
|
>
|
|
32
40
|
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
33
41
|
</ul>
|