@pienter/ui 0.5.0 → 0.8.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 +149 -0
- package/CONVENTIONS.md +91 -28
- package/README.md +66 -0
- package/components/display/record-details/RecordDetails.vue +61 -0
- package/components/display/record-details/record-details.css +37 -0
- package/components/display/record-details/types.ts +8 -0
- package/components/feedback/toast/Toast.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 +455 -0
- package/components/form/block-editor/block-editor.css +149 -0
- package/components/form/block-editor/types.ts +15 -0
- package/components/form/checkbox/Checkbox.vue +10 -2
- package/components/form/combobox/Combobox.vue +32 -39
- 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 -3
- package/components/form/number-field/number-field.css +3 -3
- package/components/form/radio-group/RadioGroup.vue +1 -1
- package/components/form/record-form/RecordFields.vue +128 -0
- package/components/form/record-form/RecordForm.vue +116 -0
- package/components/form/record-form/fields.ts +20 -0
- package/components/form/record-form/record-form.css +15 -0
- package/components/form/record-form/types.ts +28 -0
- package/components/form/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 -129
- 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 +373 -0
- package/components/layout/index/index.css +157 -0
- package/components/layout/index/useIndex.ts +407 -0
- package/components/layout/table/DataTable.vue +14 -1
- package/components/layout/table/Table.vue +12 -0
- package/components/layout/table/table.css +2 -1
- package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
- package/components/navigation/breadcrumb/breadcrumb.css +20 -0
- package/components/navigation/sidebar/Sidebar.vue +11 -8
- package/components/navigation/sidebar/sidebar.css +23 -16
- package/components/navigation/tabs/Tabs.vue +6 -0
- package/components/navigation/tabs/tabs.css +7 -7
- package/composables/useMenu.ts +20 -27
- package/package.json +17 -2
- package/styles/0-settings/colors.css +10 -0
- 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/focus.ts +9 -3
- package/utils/a11y/index.ts +5 -1
- package/utils/a11y/live-region.ts +2 -1
- package/utils/cms/index.ts +283 -0
- package/utils/cms/schema.json +126 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ValuePath } from '../../../utils/cms/index.js';
|
|
2
|
+
|
|
3
|
+
interface RecordFieldBase {
|
|
4
|
+
name: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** Defaults to the literal property name, without splitting dots. */
|
|
7
|
+
path?: ValuePath;
|
|
8
|
+
hint?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RecordFormField = RecordFieldBase &
|
|
14
|
+
(
|
|
15
|
+
| {
|
|
16
|
+
type?: 'text' | 'email';
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
autocomplete?: string;
|
|
19
|
+
}
|
|
20
|
+
| { type: 'textarea'; placeholder?: string; rows?: number }
|
|
21
|
+
| {
|
|
22
|
+
type: 'select';
|
|
23
|
+
options: { value: string; label: string }[];
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
}
|
|
26
|
+
| { type: 'checkbox' }
|
|
27
|
+
| { type: 'custom' }
|
|
28
|
+
);
|
|
@@ -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>
|
|
@@ -1,141 +1,22 @@
|
|
|
1
1
|
@layer components {
|
|
2
|
-
/*
|
|
3
|
-
|
|
4
|
-
* Default layout (`data-layout="stacked"` or unset): label above the
|
|
5
|
-
* control, hint and errors below — one column, vertical flow. Used by
|
|
6
|
-
* TextInput, Textarea, Select, NumberField, DateInput.
|
|
7
|
-
*
|
|
8
|
-
* Inline layout (`data-layout="inline"`): control left, label right
|
|
9
|
-
* (next to the control), hint and errors below the row spanning both
|
|
10
|
-
* columns. Used by compact form primitives (Switch, Checkbox,
|
|
11
|
-
* RadioGroup individual items) where the control is small enough to
|
|
12
|
-
* sit beside its label without the stacked label-above pattern. The
|
|
13
|
-
* control is the FIRST DOM child so a label-following-input source
|
|
14
|
-
* order can be styled via a grid template.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
.pui-field {
|
|
18
|
-
display: grid;
|
|
19
|
-
gap: var(--space-2xs);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/* Zero <fieldset>'s user-agent defaults so it can host the pui-field grid
|
|
23
|
-
* scaffold cleanly. Without this, browsers paint a 2px groove border, add
|
|
24
|
-
* default block padding, and apply `min-inline-size: min-content` (which
|
|
25
|
-
* forces the fieldset to never shrink below its widest child). RadioGroup
|
|
26
|
-
* is the first form-primitive built on <fieldset>; future grouped-control
|
|
27
|
-
* primitives (checkbox groups, date-range pickers) reuse this carve-out. */
|
|
28
|
-
.pui-field:where(fieldset) {
|
|
29
|
-
border: 0;
|
|
30
|
-
padding: 0;
|
|
31
|
-
margin: 0;
|
|
32
|
-
min-inline-size: 0;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/* <legend> default rendering ("float" anchor, automatic padding-inline)
|
|
36
|
-
* misaligns inside a grid <fieldset>; zero those so the legend behaves
|
|
37
|
-
* like a normal block grid item, allowing `.pui-field`'s row gap to govern
|
|
38
|
-
* the label-to-control spacing. */
|
|
39
|
-
.pui-field__label:where(legend) {
|
|
40
|
-
padding: 0;
|
|
41
|
-
float: none;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
.pui-field[data-layout='inline'] {
|
|
45
|
-
grid-template-columns: max-content 1fr;
|
|
46
|
-
column-gap: var(--space-xs);
|
|
47
|
-
align-items: center;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
.pui-field[data-layout='inline'] .pui-field__label {
|
|
51
|
-
/* Pointer affordance for clickable label paired with a small control. */
|
|
52
|
-
cursor: pointer;
|
|
53
|
-
user-select: none;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
.pui-field[data-layout='inline'] .pui-field__hint {
|
|
57
|
-
/* Hint and errors span both columns so they sit below the row. */
|
|
58
|
-
grid-column: 1 / -1;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
.pui-field__label {
|
|
62
|
-
font-size: var(--step--1);
|
|
63
|
-
font-weight: var(--fw-bold);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
.pui-field__hint {
|
|
67
|
-
font-size: var(--step--2);
|
|
68
|
-
color: var(--text-clr-muted);
|
|
69
|
-
margin: 0;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
.pui-field[data-status='error'] .pui-field__hint {
|
|
73
|
-
color: var(--text-clr-danger);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
.pui-field[data-status='success'] .pui-field__hint {
|
|
77
|
-
color: var(--text-clr-success);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/* ===== pui-input / pui-textarea (actual controls) ===== */
|
|
81
|
-
|
|
82
|
-
.pui-input,
|
|
83
|
-
.pui-textarea {
|
|
84
|
-
width: 100%;
|
|
85
|
-
padding: 0.65em 0.85em;
|
|
86
|
-
font: inherit;
|
|
87
|
-
background: var(--bg-clr-surface);
|
|
88
|
-
border: var(--stroke-sm) solid var(--border-clr-base);
|
|
89
|
-
border-radius: var(--radius-sm);
|
|
90
|
-
color: var(--text-clr-base);
|
|
91
|
-
transition:
|
|
92
|
-
border-color 0.12s ease,
|
|
93
|
-
box-shadow 0.12s ease;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
.pui-textarea {
|
|
97
|
-
min-height: 6rem;
|
|
98
|
-
resize: vertical;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
.pui-input:focus-visible,
|
|
102
|
-
.pui-textarea:focus-visible {
|
|
103
|
-
outline: none;
|
|
104
|
-
border-color: var(--border-clr-brand);
|
|
105
|
-
box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
.pui-input:disabled,
|
|
109
|
-
.pui-textarea:disabled {
|
|
110
|
-
background: var(--bg-clr-surface-2);
|
|
111
|
-
color: var(--text-clr-muted);
|
|
112
|
-
cursor: not-allowed;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/* Status states on parent .pui-field */
|
|
116
|
-
|
|
117
|
-
.pui-field[data-status='error'] .pui-input,
|
|
118
|
-
.pui-field[data-status='error'] .pui-textarea {
|
|
119
|
-
border-color: var(--border-clr-danger);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
.pui-field[data-status='success'] .pui-input,
|
|
123
|
-
.pui-field[data-status='success'] .pui-textarea {
|
|
124
|
-
border-color: var(--border-clr-success);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/* ===== pui-inputgroup (prefix / suffix addons) ===== */
|
|
2
|
+
/* The `.pui-field` scaffold and the `.pui-input` surface live in
|
|
3
|
+
* `styles/4-components/form-field.css`; only the input group is TextInput's. */
|
|
128
4
|
|
|
129
5
|
.pui-inputgroup {
|
|
130
6
|
display: flex;
|
|
131
|
-
border: var(--stroke-sm) solid var(--border-clr-
|
|
7
|
+
border: var(--stroke-sm) solid var(--border-clr-input);
|
|
132
8
|
border-radius: var(--radius-sm);
|
|
133
9
|
overflow: hidden;
|
|
134
10
|
}
|
|
135
11
|
|
|
136
12
|
.pui-inputgroup:focus-within {
|
|
137
13
|
border-color: var(--border-clr-brand);
|
|
138
|
-
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* `overflow: hidden` would clip the input's own ring, so the group wears it. */
|
|
17
|
+
.pui-inputgroup:has(.pui-input:focus-visible) {
|
|
18
|
+
outline: var(--outline-width) solid var(--outline-clr-base);
|
|
19
|
+
outline-offset: var(--outline-offset);
|
|
139
20
|
}
|
|
140
21
|
|
|
141
22
|
.pui-inputgroup .pui-input {
|
|
@@ -144,7 +25,7 @@
|
|
|
144
25
|
}
|
|
145
26
|
|
|
146
27
|
.pui-inputgroup .pui-input:focus-visible {
|
|
147
|
-
|
|
28
|
+
outline: none;
|
|
148
29
|
}
|
|
149
30
|
|
|
150
31
|
.pui-inputgroup__addon {
|
|
@@ -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
|
<textarea
|
|
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,25 +1,9 @@
|
|
|
1
1
|
@layer components {
|
|
2
|
-
/*
|
|
3
|
-
* disabled, status-driven recolour) live alongside `.pui-input` in
|
|
4
|
-
* `components/form/text-input/text-input.css` because both controls share
|
|
5
|
-
* the form-primitive scaffold (per CONVENTIONS § Component archetypes
|
|
6
|
-
* → Form-primitive wrapper). When a fifth+ primitive ships, those
|
|
7
|
-
* shared rules will move into a dedicated `form-field.css` per the
|
|
8
|
-
* deferred-extraction note in CONVENTIONS.
|
|
9
|
-
*
|
|
10
|
-
* This file only carries Textarea-specific rules that don't fit the
|
|
11
|
-
* shared scaffold — currently just the `:autoResize` opt-in styling.
|
|
12
|
-
*/
|
|
2
|
+
/* `.pui-textarea` itself is styled by `styles/4-components/form-field.css`. */
|
|
13
3
|
|
|
14
|
-
/*
|
|
15
|
-
|
|
16
|
-
* inline style is the only documented path, so the
|
|
17
|
-
* selector matches it directly. Browsers without `field-sizing`
|
|
18
|
-
* support fall back gracefully to the default fixed-rows/min-height
|
|
19
|
-
* box. */
|
|
4
|
+
/* `autoResize` sets `style="field-sizing: content"` inline; the box grows
|
|
5
|
+
* with content, so the manual resize handle is redundant. */
|
|
20
6
|
.pui-textarea[style*='field-sizing'] {
|
|
21
|
-
/* Vertical resize handle is redundant when content sizing is on;
|
|
22
|
-
* the box grows with content on its own. */
|
|
23
7
|
resize: none;
|
|
24
8
|
}
|
|
25
9
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useId, watch } from 'vue';
|
|
3
|
+
import IconButton from '../../action/button/IconButton.vue';
|
|
4
|
+
import Sidebar from '../../navigation/sidebar/Sidebar.vue';
|
|
5
|
+
import type { SidebarItem } from '../../navigation/sidebar/types.js';
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(
|
|
8
|
+
defineProps<{
|
|
9
|
+
topItems: SidebarItem[];
|
|
10
|
+
bottomItems?: SidebarItem[];
|
|
11
|
+
activeHref?: string | null;
|
|
12
|
+
collapsed?: boolean;
|
|
13
|
+
collapsedSubmenu?: 'hidden' | 'panel';
|
|
14
|
+
navigationLabel?: string;
|
|
15
|
+
drawerLabel?: string;
|
|
16
|
+
skipLabel?: string;
|
|
17
|
+
mainId?: string;
|
|
18
|
+
navigationId?: string;
|
|
19
|
+
}>(),
|
|
20
|
+
{
|
|
21
|
+
bottomItems: () => [],
|
|
22
|
+
activeHref: null,
|
|
23
|
+
collapsed: false,
|
|
24
|
+
collapsedSubmenu: 'hidden',
|
|
25
|
+
navigationLabel: 'Main navigation',
|
|
26
|
+
drawerLabel: 'Open navigation',
|
|
27
|
+
skipLabel: 'Skip to content',
|
|
28
|
+
mainId: undefined,
|
|
29
|
+
navigationId: undefined,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{ navigate: [event: MouseEvent] }>();
|
|
34
|
+
const drawerOpen = defineModel<boolean>('drawerOpen', { default: false });
|
|
35
|
+
const expandedGroups = defineModel<string[]>('expandedGroups');
|
|
36
|
+
|
|
37
|
+
const instanceId = useId();
|
|
38
|
+
const resolvedMainId = computed(() => props.mainId ?? `${instanceId}-main`);
|
|
39
|
+
const resolvedNavigationId = computed(
|
|
40
|
+
() => props.navigationId ?? `${instanceId}-navigation`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function navigate(event: MouseEvent): void {
|
|
44
|
+
emit('navigate', event);
|
|
45
|
+
const target = event.target as Element | null;
|
|
46
|
+
if (target?.closest('a[data-sidebar-link]')) drawerOpen.value = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
watch(
|
|
50
|
+
() => props.activeHref,
|
|
51
|
+
(next, previous) => {
|
|
52
|
+
if (next !== previous) drawerOpen.value = false;
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<div class="pui-app-layout">
|
|
59
|
+
<a class="pui-app-layout__skip-link" :href="`#${resolvedMainId}`">
|
|
60
|
+
{{ skipLabel }}
|
|
61
|
+
</a>
|
|
62
|
+
<header class="pui-app-layout__header">
|
|
63
|
+
<IconButton
|
|
64
|
+
class="pui-app-layout__drawer-toggle"
|
|
65
|
+
:label="drawerLabel"
|
|
66
|
+
name="menu"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="sm"
|
|
69
|
+
:aria-expanded="drawerOpen ? 'true' : undefined"
|
|
70
|
+
:aria-controls="resolvedNavigationId"
|
|
71
|
+
@click="drawerOpen = true"
|
|
72
|
+
/>
|
|
73
|
+
<div v-if="$slots.brand" class="pui-app-layout__brand">
|
|
74
|
+
<slot name="brand" />
|
|
75
|
+
</div>
|
|
76
|
+
<div v-if="$slots.tools" class="pui-app-layout__tools">
|
|
77
|
+
<slot name="tools" />
|
|
78
|
+
</div>
|
|
79
|
+
</header>
|
|
80
|
+
<div class="pui-app-layout__body">
|
|
81
|
+
<Sidebar
|
|
82
|
+
:id="resolvedNavigationId"
|
|
83
|
+
class="pui-app-layout__navigation"
|
|
84
|
+
:top-items="topItems"
|
|
85
|
+
:bottom-items="bottomItems"
|
|
86
|
+
:active-href="activeHref"
|
|
87
|
+
:collapsed="collapsed"
|
|
88
|
+
:collapsed-submenu="collapsedSubmenu"
|
|
89
|
+
:drawer-open="drawerOpen"
|
|
90
|
+
:expanded-groups="expandedGroups"
|
|
91
|
+
:aria-label="navigationLabel"
|
|
92
|
+
@click="navigate"
|
|
93
|
+
@update:drawer-open="drawerOpen = $event"
|
|
94
|
+
@update:expanded-groups="expandedGroups = $event"
|
|
95
|
+
>
|
|
96
|
+
<template v-if="$slots['navigation-brand']" #brand>
|
|
97
|
+
<slot name="navigation-brand" />
|
|
98
|
+
</template>
|
|
99
|
+
<template v-if="$slots['navigation-bottom']" #bottom>
|
|
100
|
+
<slot name="navigation-bottom" />
|
|
101
|
+
</template>
|
|
102
|
+
</Sidebar>
|
|
103
|
+
<main
|
|
104
|
+
:id="resolvedMainId"
|
|
105
|
+
class="pui-app-layout__main"
|
|
106
|
+
tabindex="-1"
|
|
107
|
+
>
|
|
108
|
+
<slot />
|
|
109
|
+
</main>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
@import './app-layout.css';
|
|
116
|
+
</style>
|