@pienter/ui 0.11.0 → 0.13.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 +60 -0
- package/CONVENTIONS.md +10 -0
- package/components/form/block-editor/BlockEditor.vue +61 -44
- package/components/form/block-editor/block-editor.css +11 -0
- package/components/form/checkbox/Checkbox.vue +23 -7
- package/components/form/checkbox/checkbox.css +12 -0
- package/components/form/combobox/Combobox.vue +9 -2
- package/components/form/date-input/DateInput.vue +5 -0
- package/components/form/form/Form.vue +5 -2
- package/components/form/number-field/NumberField.vue +8 -3
- package/components/form/number-field/number-field.css +6 -0
- package/components/form/radio-group/RadioGroup.vue +23 -1
- package/components/form/radio-group/radio-group.css +9 -0
- package/components/form/record-form/RecordFields.vue +40 -2
- package/components/form/record-form/RecordForm.vue +7 -1
- package/components/form/record-form/record-form.css +14 -0
- package/components/form/record-form/types.ts +6 -0
- package/components/form/select/Select.vue +32 -6
- package/components/form/select/select.css +5 -0
- package/components/form/switch/Switch.vue +23 -7
- package/components/form/switch/switch.css +8 -0
- package/components/form/tags-input/TagsInput.vue +11 -5
- package/components/form/tags-input/tags-input.css +5 -0
- package/components/form/text-input/TextInput.vue +6 -1
- package/components/form/textarea/Textarea.vue +6 -1
- package/components/form/textarea/textarea.css +7 -0
- package/components/layout/record-layout/Panel.vue +44 -0
- package/components/layout/record-layout/RecordLayout.vue +35 -0
- package/components/layout/record-layout/record-layout.css +94 -0
- package/components/navigation/tabs/tabs.css +2 -0
- package/package.json +3 -1
- package/styles/4-components/form-field.css +7 -0
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
v-bind="$attrs"
|
|
4
4
|
:id="fieldId"
|
|
5
5
|
class="pui-field"
|
|
6
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
6
7
|
:data-status="computedStatus"
|
|
7
8
|
role="radiogroup"
|
|
9
|
+
:aria-readonly="readonly ? 'true' : undefined"
|
|
8
10
|
:aria-describedby="describedBy"
|
|
9
11
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
10
12
|
>
|
|
@@ -31,7 +33,9 @@
|
|
|
31
33
|
:aria-checked="
|
|
32
34
|
modelValue === option.value ? 'true' : 'false'
|
|
33
35
|
"
|
|
34
|
-
@
|
|
36
|
+
@click="onClick"
|
|
37
|
+
@keydown="onKeydown"
|
|
38
|
+
@change="onChange(option.value)"
|
|
35
39
|
/>
|
|
36
40
|
<span class="pui-radio-group__label">{{ option.label }}</span>
|
|
37
41
|
</label>
|
|
@@ -62,6 +66,8 @@ const props = withDefaults(
|
|
|
62
66
|
options: RadioOption[];
|
|
63
67
|
modelValue?: string;
|
|
64
68
|
disabled?: boolean;
|
|
69
|
+
/** Read-only: `aria-readonly` on the group, the checked radio stays focusable, selection is ignored. */
|
|
70
|
+
readonly?: boolean;
|
|
65
71
|
orientation?: 'vertical' | 'horizontal';
|
|
66
72
|
hint?: string;
|
|
67
73
|
errors?: string[];
|
|
@@ -71,6 +77,7 @@ const props = withDefaults(
|
|
|
71
77
|
id: undefined,
|
|
72
78
|
modelValue: undefined,
|
|
73
79
|
disabled: false,
|
|
80
|
+
readonly: false,
|
|
74
81
|
orientation: 'vertical',
|
|
75
82
|
hint: undefined,
|
|
76
83
|
errors: () => [],
|
|
@@ -100,6 +107,21 @@ const describedBy = computed(() => {
|
|
|
100
107
|
if (hasErrors.value) ids.push(errorsId);
|
|
101
108
|
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
102
109
|
});
|
|
110
|
+
|
|
111
|
+
// Cancelling `click` keeps the native checked state and fires no `change`;
|
|
112
|
+
// arrow keys move the selection without a click, so they are cancelled too.
|
|
113
|
+
function onClick(event: MouseEvent): void {
|
|
114
|
+
if (props.readonly) event.preventDefault();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
118
|
+
if (props.readonly && event.key.startsWith('Arrow')) event.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function onChange(value: string): void {
|
|
122
|
+
if (props.readonly) return;
|
|
123
|
+
emit('update:modelValue', value);
|
|
124
|
+
}
|
|
103
125
|
</script>
|
|
104
126
|
|
|
105
127
|
<style>
|
|
@@ -102,6 +102,15 @@
|
|
|
102
102
|
cursor: not-allowed;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
.pui-field[data-readonly='true'] .pui-radio-group__item,
|
|
106
|
+
.pui-field[data-readonly='true'] .pui-radio {
|
|
107
|
+
cursor: default;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.pui-field[data-readonly='true'] .pui-radio:not(:checked) {
|
|
111
|
+
background: var(--bg-clr-surface-2);
|
|
112
|
+
}
|
|
113
|
+
|
|
105
114
|
/* Status-driven recolour from the parent pui-field wrapper. */
|
|
106
115
|
.pui-field[data-status='error'] .pui-radio {
|
|
107
116
|
border-color: var(--border-clr-danger);
|
|
@@ -23,8 +23,10 @@ const props = withDefaults(
|
|
|
23
23
|
fields: readonly RecordFormField[];
|
|
24
24
|
issues?: readonly ValidationIssue[];
|
|
25
25
|
busy?: boolean;
|
|
26
|
+
/** View mode: every field renders read-only and `update` is a no-op. */
|
|
27
|
+
readonly?: boolean;
|
|
26
28
|
}>(),
|
|
27
|
-
{ issues: () => [], busy: false },
|
|
29
|
+
{ issues: () => [], busy: false, readonly: false },
|
|
28
30
|
);
|
|
29
31
|
const emit = defineEmits<{
|
|
30
32
|
'update:modelValue': [value: T];
|
|
@@ -44,8 +46,14 @@ function textFor(field: RecordFormField): string {
|
|
|
44
46
|
const value = valueFor(field);
|
|
45
47
|
return typeof value === 'string' ? value : '';
|
|
46
48
|
}
|
|
49
|
+
function altFor(field: RecordFormField): string {
|
|
50
|
+
const name = field.type === 'image' ? field.altName : undefined;
|
|
51
|
+
if (!name) return '';
|
|
52
|
+
const sibling = props.fields.find((other) => other.name === name);
|
|
53
|
+
return textFor(sibling ?? { name, label: '' });
|
|
54
|
+
}
|
|
47
55
|
function update(field: RecordFormField, value: unknown): void {
|
|
48
|
-
if (props.busy || field.disabled) return;
|
|
56
|
+
if (props.busy || props.readonly || field.disabled) return;
|
|
49
57
|
const path = pathFor(field);
|
|
50
58
|
emit('update:modelValue', updatePath(props.modelValue, path, value));
|
|
51
59
|
emit('field-change', path);
|
|
@@ -66,6 +74,7 @@ function update(field: RecordFormField, value: unknown): void {
|
|
|
66
74
|
:update="(value: unknown) => update(field, value)"
|
|
67
75
|
:errors="errors"
|
|
68
76
|
:disabled="busy || !!field.disabled"
|
|
77
|
+
:readonly="readonly"
|
|
69
78
|
>
|
|
70
79
|
<PuiTextarea
|
|
71
80
|
v-if="field.type === 'textarea'"
|
|
@@ -75,6 +84,7 @@ function update(field: RecordFormField, value: unknown): void {
|
|
|
75
84
|
:hint="field.hint"
|
|
76
85
|
:required="field.required"
|
|
77
86
|
:disabled="busy || field.disabled"
|
|
87
|
+
:readonly="readonly"
|
|
78
88
|
:placeholder="field.placeholder"
|
|
79
89
|
:rows="field.rows"
|
|
80
90
|
:errors="errors"
|
|
@@ -88,6 +98,7 @@ function update(field: RecordFormField, value: unknown): void {
|
|
|
88
98
|
:hint="field.hint"
|
|
89
99
|
:required="field.required"
|
|
90
100
|
:disabled="busy || field.disabled"
|
|
101
|
+
:readonly="readonly"
|
|
91
102
|
:placeholder="field.placeholder"
|
|
92
103
|
:options="field.options"
|
|
93
104
|
:errors="errors"
|
|
@@ -101,9 +112,35 @@ function update(field: RecordFormField, value: unknown): void {
|
|
|
101
112
|
:hint="field.hint"
|
|
102
113
|
:required="field.required"
|
|
103
114
|
:disabled="busy || field.disabled"
|
|
115
|
+
:readonly="readonly"
|
|
104
116
|
:errors="errors"
|
|
105
117
|
@update:model-value="update(field, $event)"
|
|
106
118
|
/>
|
|
119
|
+
<template v-else-if="field.type === 'image'">
|
|
120
|
+
<div v-if="readonly" class="pui-field" data-readonly="true">
|
|
121
|
+
<span class="pui-field__label">{{ field.label }}</span>
|
|
122
|
+
<img
|
|
123
|
+
v-if="textFor(field)"
|
|
124
|
+
class="pui-record-fields__image"
|
|
125
|
+
:src="textFor(field)"
|
|
126
|
+
:alt="altFor(field)"
|
|
127
|
+
/>
|
|
128
|
+
<p v-else class="pui-record-fields__empty">—</p>
|
|
129
|
+
</div>
|
|
130
|
+
<TextInput
|
|
131
|
+
v-else
|
|
132
|
+
:label="field.label"
|
|
133
|
+
:name="JSON.stringify(pathFor(field))"
|
|
134
|
+
:model-value="textFor(field)"
|
|
135
|
+
type="url"
|
|
136
|
+
:hint="field.hint"
|
|
137
|
+
:required="field.required"
|
|
138
|
+
:disabled="busy || field.disabled"
|
|
139
|
+
:placeholder="field.placeholder"
|
|
140
|
+
:errors="errors"
|
|
141
|
+
@update:model-value="update(field, $event)"
|
|
142
|
+
/>
|
|
143
|
+
</template>
|
|
107
144
|
<TextInput
|
|
108
145
|
v-else-if="field.type !== 'custom'"
|
|
109
146
|
:label="field.label"
|
|
@@ -113,6 +150,7 @@ function update(field: RecordFormField, value: unknown): void {
|
|
|
113
150
|
:hint="field.hint"
|
|
114
151
|
:required="field.required"
|
|
115
152
|
:disabled="busy || field.disabled"
|
|
153
|
+
:readonly="readonly"
|
|
116
154
|
:placeholder="field.placeholder"
|
|
117
155
|
:autocomplete="field.autocomplete"
|
|
118
156
|
:errors="errors"
|
|
@@ -23,6 +23,8 @@ const props = withDefaults(
|
|
|
23
23
|
issues?: readonly ValidationIssue[];
|
|
24
24
|
errors?: string[];
|
|
25
25
|
busy?: boolean;
|
|
26
|
+
/** View mode: fields render read-only, the actions row is gone and `submit` never fires. */
|
|
27
|
+
readonly?: boolean;
|
|
26
28
|
submitLabel?: string;
|
|
27
29
|
statusMessage?: string;
|
|
28
30
|
}>(),
|
|
@@ -30,6 +32,7 @@ const props = withDefaults(
|
|
|
30
32
|
issues: () => [],
|
|
31
33
|
errors: () => [],
|
|
32
34
|
busy: false,
|
|
35
|
+
readonly: false,
|
|
33
36
|
submitLabel: 'Save changes',
|
|
34
37
|
statusMessage: undefined,
|
|
35
38
|
},
|
|
@@ -64,7 +67,8 @@ const summary = computed(() => [
|
|
|
64
67
|
]);
|
|
65
68
|
|
|
66
69
|
function submit(): void {
|
|
67
|
-
if (
|
|
70
|
+
if (props.busy || props.readonly) return;
|
|
71
|
+
emit('submit', cloneRecord(props.modelValue));
|
|
68
72
|
}
|
|
69
73
|
</script>
|
|
70
74
|
|
|
@@ -72,6 +76,7 @@ function submit(): void {
|
|
|
72
76
|
<Form
|
|
73
77
|
class="pui-record-form"
|
|
74
78
|
:busy="busy"
|
|
79
|
+
:readonly="readonly"
|
|
75
80
|
:errors="summary"
|
|
76
81
|
:field-errors="fieldErrors"
|
|
77
82
|
:status-message="statusMessage"
|
|
@@ -84,6 +89,7 @@ function submit(): void {
|
|
|
84
89
|
:fields="fields"
|
|
85
90
|
:issues="issues"
|
|
86
91
|
:busy="busy"
|
|
92
|
+
:readonly="readonly"
|
|
87
93
|
@update:model-value="emit('update:modelValue', $event)"
|
|
88
94
|
@field-change="emit('field-change', $event)"
|
|
89
95
|
>
|
|
@@ -12,4 +12,18 @@
|
|
|
12
12
|
gap: var(--space-s);
|
|
13
13
|
min-inline-size: 0;
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
.pui-record-fields__image {
|
|
17
|
+
justify-self: start;
|
|
18
|
+
max-inline-size: 100%;
|
|
19
|
+
max-block-size: var(--space-3xl);
|
|
20
|
+
border: var(--stroke-sm) solid var(--border-clr-subtle);
|
|
21
|
+
border-radius: var(--radius-sm);
|
|
22
|
+
background: var(--bg-clr-surface-2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.pui-record-fields__empty {
|
|
26
|
+
margin: 0;
|
|
27
|
+
color: var(--text-clr-muted);
|
|
28
|
+
}
|
|
15
29
|
}
|
|
@@ -23,6 +23,12 @@ export type RecordFormField = RecordFieldBase &
|
|
|
23
23
|
options: { value: string; label: string }[];
|
|
24
24
|
placeholder?: string;
|
|
25
25
|
}
|
|
26
|
+
| {
|
|
27
|
+
type: 'image';
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** `name` of the field in this list holding the alternative text. */
|
|
30
|
+
altName?: string;
|
|
31
|
+
}
|
|
26
32
|
| { type: 'checkbox' }
|
|
27
33
|
| { type: 'custom' }
|
|
28
34
|
);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<div
|
|
3
3
|
class="pui-field"
|
|
4
4
|
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
5
6
|
:data-status="computedStatus"
|
|
6
7
|
>
|
|
7
8
|
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
@@ -22,14 +23,12 @@
|
|
|
22
23
|
:required="required"
|
|
23
24
|
:disabled="disabled"
|
|
24
25
|
:value="modelValue"
|
|
26
|
+
:aria-readonly="readonly ? 'true' : undefined"
|
|
25
27
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
26
28
|
:aria-describedby="describedBy"
|
|
27
|
-
@change="
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
($event.target as HTMLSelectElement).value,
|
|
31
|
-
)
|
|
32
|
-
"
|
|
29
|
+
@change="onChange"
|
|
30
|
+
@mousedown="onMousedown"
|
|
31
|
+
@keydown="onKeydown"
|
|
33
32
|
>
|
|
34
33
|
<option v-if="placeholder" value="" disabled hidden>
|
|
35
34
|
{{ placeholder }}
|
|
@@ -73,6 +72,8 @@ const props = withDefaults(
|
|
|
73
72
|
name?: string;
|
|
74
73
|
required?: boolean;
|
|
75
74
|
disabled?: boolean;
|
|
75
|
+
/** Read-only: `aria-readonly`, still focusable, but the list does not open and changes are ignored. */
|
|
76
|
+
readonly?: boolean;
|
|
76
77
|
options: { value: string; label: string }[];
|
|
77
78
|
placeholder?: string;
|
|
78
79
|
hint?: string;
|
|
@@ -88,6 +89,7 @@ const props = withDefaults(
|
|
|
88
89
|
name: undefined,
|
|
89
90
|
required: false,
|
|
90
91
|
disabled: false,
|
|
92
|
+
readonly: false,
|
|
91
93
|
placeholder: undefined,
|
|
92
94
|
hint: undefined,
|
|
93
95
|
errors: () => [],
|
|
@@ -118,6 +120,30 @@ const describedBy = computed(() => {
|
|
|
118
120
|
if (hasErrors.value) ids.push(errorsId);
|
|
119
121
|
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
120
122
|
});
|
|
123
|
+
|
|
124
|
+
function onChange(event: Event): void {
|
|
125
|
+
const target = event.target as HTMLSelectElement;
|
|
126
|
+
if (props.readonly) {
|
|
127
|
+
target.value = props.modelValue ?? '';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
emit('update:modelValue', target.value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A `<select>` has no `readonly`: keep it focusable but stop the picker
|
|
134
|
+
// from opening and the keys that change the value without opening it.
|
|
135
|
+
function onMousedown(event: MouseEvent): void {
|
|
136
|
+
if (!props.readonly) return;
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
(event.currentTarget as HTMLSelectElement).focus();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
142
|
+
if (!props.readonly) return;
|
|
143
|
+
if (event.key === 'Tab' || event.key === 'Escape') return;
|
|
144
|
+
if (event.metaKey || event.ctrlKey) return;
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
}
|
|
121
147
|
</script>
|
|
122
148
|
|
|
123
149
|
<style>
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
class="pui-field"
|
|
4
|
+
data-layout="inline"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
6
|
+
:data-status="computedStatus"
|
|
7
|
+
>
|
|
3
8
|
<input
|
|
4
9
|
v-bind="$attrs"
|
|
5
10
|
:id="inputId"
|
|
@@ -10,14 +15,11 @@
|
|
|
10
15
|
:checked="modelValue"
|
|
11
16
|
:disabled="disabled"
|
|
12
17
|
:aria-checked="modelValue ? 'true' : 'false'"
|
|
18
|
+
:aria-readonly="readonly ? 'true' : undefined"
|
|
13
19
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
14
20
|
:aria-describedby="describedBy"
|
|
15
|
-
@
|
|
16
|
-
|
|
17
|
-
'update:modelValue',
|
|
18
|
-
($event.target as HTMLInputElement).checked,
|
|
19
|
-
)
|
|
20
|
-
"
|
|
21
|
+
@click="onClick"
|
|
22
|
+
@change="onChange"
|
|
21
23
|
/>
|
|
22
24
|
<label class="pui-field__label" :for="inputId">{{ label }}</label>
|
|
23
25
|
<p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
|
|
@@ -45,6 +47,8 @@ const props = withDefaults(
|
|
|
45
47
|
modelValue?: boolean;
|
|
46
48
|
name?: string;
|
|
47
49
|
disabled?: boolean;
|
|
50
|
+
/** Read-only: `aria-readonly`, still focusable, but toggling is ignored. */
|
|
51
|
+
readonly?: boolean;
|
|
48
52
|
hint?: string;
|
|
49
53
|
errors?: string[];
|
|
50
54
|
status?: 'error' | 'success';
|
|
@@ -54,6 +58,7 @@ const props = withDefaults(
|
|
|
54
58
|
modelValue: false,
|
|
55
59
|
name: undefined,
|
|
56
60
|
disabled: false,
|
|
61
|
+
readonly: false,
|
|
57
62
|
hint: undefined,
|
|
58
63
|
errors: () => [],
|
|
59
64
|
status: undefined,
|
|
@@ -82,6 +87,17 @@ const describedBy = computed(() => {
|
|
|
82
87
|
if (hasErrors.value) ids.push(errorsId);
|
|
83
88
|
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
84
89
|
});
|
|
90
|
+
|
|
91
|
+
// Cancelling `click` keeps the native checked state and fires no `change`;
|
|
92
|
+
// the label's forwarded click and the Space key arrive here too.
|
|
93
|
+
function onClick(event: MouseEvent): void {
|
|
94
|
+
if (props.readonly) event.preventDefault();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function onChange(event: Event): void {
|
|
98
|
+
if (props.readonly) return;
|
|
99
|
+
emit('update:modelValue', (event.target as HTMLInputElement).checked);
|
|
100
|
+
}
|
|
85
101
|
</script>
|
|
86
102
|
|
|
87
103
|
<style>
|
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
cursor: not-allowed;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
.pui-field[data-readonly='true'] .pui-switch {
|
|
51
|
+
cursor: default;
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
/* Status-driven recolour from the parent pui-field wrapper. */
|
|
51
55
|
.pui-field[data-status='error'] .pui-switch {
|
|
52
56
|
border-color: var(--border-clr-danger);
|
|
@@ -67,4 +71,8 @@
|
|
|
67
71
|
color: var(--text-clr-muted);
|
|
68
72
|
cursor: not-allowed;
|
|
69
73
|
}
|
|
74
|
+
|
|
75
|
+
.pui-field[data-readonly='true']:has(.pui-switch) .pui-field__label {
|
|
76
|
+
cursor: default;
|
|
77
|
+
}
|
|
70
78
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<div
|
|
3
3
|
class="pui-field"
|
|
4
4
|
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
5
6
|
:data-status="computedStatus"
|
|
6
7
|
>
|
|
7
8
|
<label class="pui-field__label" :for="inputId"
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
>
|
|
36
37
|
<span class="pui-tags-input__tag-label">{{ tag }}</span>
|
|
37
38
|
<button
|
|
39
|
+
v-if="!readonly"
|
|
38
40
|
type="button"
|
|
39
41
|
class="pui-tags-input__tag-remove"
|
|
40
42
|
:aria-label="`Remove tag ${tag}`"
|
|
@@ -52,8 +54,9 @@
|
|
|
52
54
|
class="pui-tags-input__entry"
|
|
53
55
|
type="text"
|
|
54
56
|
:name="name"
|
|
55
|
-
:placeholder="placeholder"
|
|
56
|
-
:disabled="disabled || maxReached"
|
|
57
|
+
:placeholder="readonly ? undefined : placeholder"
|
|
58
|
+
:disabled="disabled || (!readonly && maxReached)"
|
|
59
|
+
:readonly="readonly"
|
|
57
60
|
:aria-required="required ? 'true' : undefined"
|
|
58
61
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
59
62
|
:aria-describedby="describedBy"
|
|
@@ -91,6 +94,8 @@ const props = withDefaults(
|
|
|
91
94
|
/** Marks the field; set as `aria-required` on the entry input since the value is the tag list, not the typed text. */
|
|
92
95
|
required?: boolean;
|
|
93
96
|
disabled?: boolean;
|
|
97
|
+
/** Read-only: tags show without remove buttons and the entry is a focusable native `readonly` input. */
|
|
98
|
+
readonly?: boolean;
|
|
94
99
|
hint?: string;
|
|
95
100
|
errors?: string[];
|
|
96
101
|
status?: 'error' | 'success';
|
|
@@ -113,6 +118,7 @@ const props = withDefaults(
|
|
|
113
118
|
placeholder: undefined,
|
|
114
119
|
required: false,
|
|
115
120
|
disabled: false,
|
|
121
|
+
readonly: false,
|
|
116
122
|
hint: undefined,
|
|
117
123
|
errors: () => [],
|
|
118
124
|
status: undefined,
|
|
@@ -163,7 +169,7 @@ function isSeparator(event: KeyboardEvent): boolean {
|
|
|
163
169
|
|
|
164
170
|
function addTag(raw: string): boolean {
|
|
165
171
|
const value = raw.trim();
|
|
166
|
-
if (!value) return false;
|
|
172
|
+
if (!value || props.readonly) return false;
|
|
167
173
|
if (maxReached.value) return false;
|
|
168
174
|
if (props.modelValue.includes(value)) return false;
|
|
169
175
|
emit('update:modelValue', [...props.modelValue, value]);
|
|
@@ -171,7 +177,7 @@ function addTag(raw: string): boolean {
|
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
function removeTag(index: number): void {
|
|
174
|
-
if (props.disabled) return;
|
|
180
|
+
if (props.disabled || props.readonly) return;
|
|
175
181
|
if (index < 0 || index >= props.modelValue.length) return;
|
|
176
182
|
const tag = props.modelValue[index];
|
|
177
183
|
const next = props.modelValue.filter((_, i) => i !== index);
|
|
@@ -182,7 +188,7 @@ function removeTag(index: number): void {
|
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
function onKeydown(event: KeyboardEvent): void {
|
|
185
|
-
if (props.disabled) return;
|
|
191
|
+
if (props.disabled || props.readonly) return;
|
|
186
192
|
if (isSeparator(event)) {
|
|
187
193
|
if (entryText.value.trim().length > 0) {
|
|
188
194
|
event.preventDefault();
|
|
@@ -47,6 +47,11 @@
|
|
|
47
47
|
cursor: not-allowed;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
.pui-field[data-readonly='true'] .pui-tags-input {
|
|
51
|
+
background: var(--bg-clr-surface-2);
|
|
52
|
+
cursor: default;
|
|
53
|
+
}
|
|
54
|
+
|
|
50
55
|
/* Status states inherited from .pui-field — recolour to match
|
|
51
56
|
* .pui-input / .pui-textarea when the parent carries `data-status`. */
|
|
52
57
|
.pui-field[data-status='error'] .pui-tags-input {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<div
|
|
3
3
|
class="pui-field"
|
|
4
4
|
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
5
6
|
:data-status="computedStatus"
|
|
6
7
|
>
|
|
7
8
|
<label class="pui-field__label" :for="inputId"
|
|
@@ -23,7 +24,8 @@
|
|
|
23
24
|
:autocomplete="autocomplete"
|
|
24
25
|
:required="required"
|
|
25
26
|
:disabled="disabled"
|
|
26
|
-
:
|
|
27
|
+
:readonly="readonly"
|
|
28
|
+
:placeholder="readonly ? undefined : placeholder"
|
|
27
29
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
28
30
|
:aria-describedby="describedBy"
|
|
29
31
|
@input="
|
|
@@ -62,6 +64,8 @@ const props = withDefaults(
|
|
|
62
64
|
autocomplete?: string;
|
|
63
65
|
required?: boolean;
|
|
64
66
|
disabled?: boolean;
|
|
67
|
+
/** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
|
|
68
|
+
readonly?: boolean;
|
|
65
69
|
placeholder?: string;
|
|
66
70
|
hint?: string;
|
|
67
71
|
errors?: string[];
|
|
@@ -76,6 +80,7 @@ const props = withDefaults(
|
|
|
76
80
|
autocomplete: undefined,
|
|
77
81
|
required: false,
|
|
78
82
|
disabled: false,
|
|
83
|
+
readonly: false,
|
|
79
84
|
placeholder: undefined,
|
|
80
85
|
hint: undefined,
|
|
81
86
|
errors: () => [],
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
<div
|
|
3
3
|
class="pui-field"
|
|
4
4
|
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
5
6
|
:data-status="computedStatus"
|
|
6
7
|
>
|
|
7
8
|
<label class="pui-field__label" :for="inputId"
|
|
@@ -22,7 +23,8 @@
|
|
|
22
23
|
:autocomplete="autocomplete"
|
|
23
24
|
:required="required"
|
|
24
25
|
:disabled="disabled"
|
|
25
|
-
:
|
|
26
|
+
:readonly="readonly"
|
|
27
|
+
:placeholder="readonly ? undefined : placeholder"
|
|
26
28
|
:style="autoResize ? 'field-sizing: content' : undefined"
|
|
27
29
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
28
30
|
:aria-describedby="describedBy"
|
|
@@ -63,6 +65,8 @@ const props = withDefaults(
|
|
|
63
65
|
autocomplete?: string;
|
|
64
66
|
required?: boolean;
|
|
65
67
|
disabled?: boolean;
|
|
68
|
+
/** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
|
|
69
|
+
readonly?: boolean;
|
|
66
70
|
placeholder?: string;
|
|
67
71
|
hint?: string;
|
|
68
72
|
errors?: string[];
|
|
@@ -78,6 +82,7 @@ const props = withDefaults(
|
|
|
78
82
|
autocomplete: undefined,
|
|
79
83
|
required: false,
|
|
80
84
|
disabled: false,
|
|
85
|
+
readonly: false,
|
|
81
86
|
placeholder: undefined,
|
|
82
87
|
hint: undefined,
|
|
83
88
|
errors: () => [],
|
|
@@ -6,4 +6,11 @@
|
|
|
6
6
|
.pui-textarea[style*='field-sizing'] {
|
|
7
7
|
resize: none;
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
/* Read-only: `rows` becomes a minimum and the box grows, so a long value
|
|
11
|
+
* is read in place rather than scrolled inside a box nobody can type in. */
|
|
12
|
+
.pui-field[data-readonly='true'] .pui-textarea {
|
|
13
|
+
field-sizing: content;
|
|
14
|
+
resize: none;
|
|
15
|
+
}
|
|
9
16
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="pui-panel">
|
|
3
|
+
<header v-if="title || description" class="pui-panel__header">
|
|
4
|
+
<component
|
|
5
|
+
:is="`h${level}`"
|
|
6
|
+
v-if="title"
|
|
7
|
+
:id="titleId"
|
|
8
|
+
class="pui-panel__title"
|
|
9
|
+
>{{ title }}</component
|
|
10
|
+
>
|
|
11
|
+
<p v-if="description" class="pui-panel__description">
|
|
12
|
+
{{ description }}
|
|
13
|
+
</p>
|
|
14
|
+
</header>
|
|
15
|
+
<slot />
|
|
16
|
+
</section>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
withDefaults(
|
|
21
|
+
defineProps<{
|
|
22
|
+
title?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
/** Heading level of the title: `2` under a page heading, `3` under another section. */
|
|
25
|
+
level?: 2 | 3;
|
|
26
|
+
/** `id` for the title element, so the panel or a landmark can reference it through `aria-labelledby`. */
|
|
27
|
+
titleId?: string;
|
|
28
|
+
}>(),
|
|
29
|
+
{
|
|
30
|
+
title: undefined,
|
|
31
|
+
description: undefined,
|
|
32
|
+
level: 2,
|
|
33
|
+
titleId: undefined,
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
defineSlots<{
|
|
38
|
+
default?: () => unknown;
|
|
39
|
+
}>();
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
@import './record-layout.css';
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="pui-record-layout">
|
|
3
|
+
<aside
|
|
4
|
+
v-if="$slots.sidebar"
|
|
5
|
+
class="pui-record-layout__sidebar"
|
|
6
|
+
:aria-labelledby="sidebarLabelledby"
|
|
7
|
+
>
|
|
8
|
+
<slot name="sidebar" />
|
|
9
|
+
</aside>
|
|
10
|
+
<div class="pui-record-layout__main">
|
|
11
|
+
<slot />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
/** `id` of the sidebar's heading; names the `<aside>` landmark through `aria-labelledby`. */
|
|
20
|
+
sidebarLabelledby?: string;
|
|
21
|
+
}>(),
|
|
22
|
+
{
|
|
23
|
+
sidebarLabelledby: undefined,
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
defineSlots<{
|
|
28
|
+
sidebar?: () => unknown;
|
|
29
|
+
default?: () => unknown;
|
|
30
|
+
}>();
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
@import './record-layout.css';
|
|
35
|
+
</style>
|