@pienter/ui 0.10.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 +91 -0
- package/CONVENTIONS.md +33 -21
- package/components/display/badge/badge.css +7 -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 +18 -0
- package/components/form/combobox/Combobox.vue +18 -3
- package/components/form/date-input/DateInput.vue +13 -1
- package/components/form/form/Form.vue +5 -2
- package/components/form/number-field/NumberField.vue +16 -4
- 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 +40 -7
- package/components/form/select/select.css +5 -0
- package/components/form/switch/Switch.vue +23 -7
- package/components/form/switch/switch.css +14 -0
- package/components/form/tags-input/TagsInput.vue +19 -6
- package/components/form/tags-input/tags-input.css +5 -0
- package/components/form/text-input/TextInput.vue +14 -2
- package/components/form/textarea/Textarea.vue +14 -2
- package/components/form/textarea/textarea.css +7 -0
- package/components/layout/index/Index.vue +1 -0
- package/components/layout/index/index.css +8 -7
- 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/2-base/base.css +6 -1
- package/styles/4-components/form-field.css +10 -7
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
class="pui-field"
|
|
4
|
+
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
6
|
+
:data-status="computedStatus"
|
|
7
|
+
>
|
|
3
8
|
<label class="pui-field__label" :for="inputId"
|
|
4
9
|
>{{ label
|
|
5
10
|
}}<span
|
|
@@ -21,6 +26,7 @@
|
|
|
21
26
|
:step="step"
|
|
22
27
|
:required="required"
|
|
23
28
|
:disabled="disabled"
|
|
29
|
+
:readonly="readonly"
|
|
24
30
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
25
31
|
:aria-describedby="describedBy"
|
|
26
32
|
@input="
|
|
@@ -80,9 +86,13 @@ const props = withDefaults(
|
|
|
80
86
|
step?: number | string;
|
|
81
87
|
required?: boolean;
|
|
82
88
|
disabled?: boolean;
|
|
89
|
+
/** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
|
|
90
|
+
readonly?: boolean;
|
|
83
91
|
hint?: string;
|
|
84
92
|
errors?: string[];
|
|
85
93
|
status?: 'error' | 'success';
|
|
94
|
+
/** `inline` puts the label beside the control; hint and errors wrap below. */
|
|
95
|
+
layout?: 'stacked' | 'inline';
|
|
86
96
|
}>(),
|
|
87
97
|
{
|
|
88
98
|
id: undefined,
|
|
@@ -93,9 +103,11 @@ const props = withDefaults(
|
|
|
93
103
|
step: undefined,
|
|
94
104
|
required: false,
|
|
95
105
|
disabled: false,
|
|
106
|
+
readonly: false,
|
|
96
107
|
hint: undefined,
|
|
97
108
|
errors: () => [],
|
|
98
109
|
status: undefined,
|
|
110
|
+
layout: 'stacked',
|
|
99
111
|
},
|
|
100
112
|
);
|
|
101
113
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
{{ statusMessage }}
|
|
23
23
|
</div>
|
|
24
24
|
|
|
25
|
-
<div class="pui-form__actions">
|
|
25
|
+
<div v-if="!readonly" class="pui-form__actions">
|
|
26
26
|
<slot name="actions">
|
|
27
27
|
<button
|
|
28
28
|
class="pui-btn"
|
|
@@ -52,6 +52,8 @@ const props = withDefaults(
|
|
|
52
52
|
defineProps<{
|
|
53
53
|
submitLabel?: string;
|
|
54
54
|
busy?: boolean;
|
|
55
|
+
/** Read-only presentation: renders no actions row and ignores every submit, Enter included. */
|
|
56
|
+
readonly?: boolean;
|
|
55
57
|
/** Top-level errors not tied to a specific field. */
|
|
56
58
|
errors?: string[];
|
|
57
59
|
/** Server-side per-field errors. Consumers SHALL replace the object reference rather than mutate in place. */
|
|
@@ -64,6 +66,7 @@ const props = withDefaults(
|
|
|
64
66
|
{
|
|
65
67
|
submitLabel: 'Submit',
|
|
66
68
|
busy: false,
|
|
69
|
+
readonly: false,
|
|
67
70
|
errors: () => [],
|
|
68
71
|
fieldErrors: () => ({}),
|
|
69
72
|
statusMessage: undefined,
|
|
@@ -105,7 +108,7 @@ const allFieldErrors = computed<Record<string, string[]>>(() => {
|
|
|
105
108
|
|
|
106
109
|
function handleSubmit(): void {
|
|
107
110
|
const formEl = formRef.value;
|
|
108
|
-
if (!formEl) return;
|
|
111
|
+
if (!formEl || props.readonly) return;
|
|
109
112
|
|
|
110
113
|
const formData = new FormData(formEl);
|
|
111
114
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
class="pui-field"
|
|
4
|
+
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
6
|
+
:data-status="computedStatus"
|
|
7
|
+
>
|
|
3
8
|
<label class="pui-field__label" :for="inputId"
|
|
4
9
|
>{{ label
|
|
5
10
|
}}<span
|
|
@@ -20,16 +25,17 @@
|
|
|
20
25
|
:min="min"
|
|
21
26
|
:max="max"
|
|
22
27
|
:step="step"
|
|
23
|
-
:placeholder="placeholder"
|
|
28
|
+
:placeholder="readonly ? undefined : placeholder"
|
|
24
29
|
:required="required"
|
|
25
30
|
:disabled="disabled"
|
|
31
|
+
:readonly="readonly"
|
|
26
32
|
:value="modelValue"
|
|
27
33
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
28
34
|
:aria-describedby="describedBy"
|
|
29
35
|
@input="onInput"
|
|
30
36
|
@blur="emit('blur')"
|
|
31
37
|
/>
|
|
32
|
-
<div class="pui-number-field__steppers">
|
|
38
|
+
<div v-if="!readonly" class="pui-number-field__steppers">
|
|
33
39
|
<button
|
|
34
40
|
type="button"
|
|
35
41
|
class="pui-number-field__step"
|
|
@@ -87,9 +93,13 @@ const props = withDefaults(
|
|
|
87
93
|
placeholder?: string;
|
|
88
94
|
required?: boolean;
|
|
89
95
|
disabled?: boolean;
|
|
96
|
+
/** Read-only: the value shows in the field chrome without steppers, stays focusable and cannot be edited. */
|
|
97
|
+
readonly?: boolean;
|
|
90
98
|
hint?: string;
|
|
91
99
|
errors?: string[];
|
|
92
100
|
status?: 'error' | 'success';
|
|
101
|
+
/** `inline` puts the label beside the control; hint and errors wrap below. */
|
|
102
|
+
layout?: 'stacked' | 'inline';
|
|
93
103
|
}>(),
|
|
94
104
|
{
|
|
95
105
|
id: undefined,
|
|
@@ -101,9 +111,11 @@ const props = withDefaults(
|
|
|
101
111
|
placeholder: undefined,
|
|
102
112
|
required: false,
|
|
103
113
|
disabled: false,
|
|
114
|
+
readonly: false,
|
|
104
115
|
hint: undefined,
|
|
105
116
|
errors: () => [],
|
|
106
117
|
status: undefined,
|
|
118
|
+
layout: 'stacked',
|
|
107
119
|
},
|
|
108
120
|
);
|
|
109
121
|
|
|
@@ -142,7 +154,7 @@ function onInput(event: Event): void {
|
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
function handleStep(direction: 'decrement' | 'increment'): void {
|
|
145
|
-
if (props.disabled) return;
|
|
157
|
+
if (props.disabled || props.readonly) return;
|
|
146
158
|
const input = inputRef.value;
|
|
147
159
|
if (!input) return;
|
|
148
160
|
if (direction === 'increment') {
|
|
@@ -42,6 +42,12 @@
|
|
|
42
42
|
margin: 0;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/* Read-only renders no stepper column, so the input keeps its own corners. */
|
|
46
|
+
.pui-field[data-readonly='true'] .pui-number-field__input {
|
|
47
|
+
border-top-right-radius: var(--radius-sm);
|
|
48
|
+
border-bottom-right-radius: var(--radius-sm);
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
/* Stacked column for the +/- buttons. Sits flush against the
|
|
46
52
|
* input's right edge; each button takes half the column height. */
|
|
47
53
|
.pui-number-field__steppers {
|
|
@@ -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
|
);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
class="pui-field"
|
|
4
|
+
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
5
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
6
|
+
:data-status="computedStatus"
|
|
7
|
+
>
|
|
3
8
|
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
4
9
|
>{{ label
|
|
5
10
|
}}<span
|
|
@@ -18,14 +23,12 @@
|
|
|
18
23
|
:required="required"
|
|
19
24
|
:disabled="disabled"
|
|
20
25
|
:value="modelValue"
|
|
26
|
+
:aria-readonly="readonly ? 'true' : undefined"
|
|
21
27
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
22
28
|
:aria-describedby="describedBy"
|
|
23
|
-
@change="
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
($event.target as HTMLSelectElement).value,
|
|
27
|
-
)
|
|
28
|
-
"
|
|
29
|
+
@change="onChange"
|
|
30
|
+
@mousedown="onMousedown"
|
|
31
|
+
@keydown="onKeydown"
|
|
29
32
|
>
|
|
30
33
|
<option v-if="placeholder" value="" disabled hidden>
|
|
31
34
|
{{ placeholder }}
|
|
@@ -69,11 +72,15 @@ const props = withDefaults(
|
|
|
69
72
|
name?: string;
|
|
70
73
|
required?: boolean;
|
|
71
74
|
disabled?: boolean;
|
|
75
|
+
/** Read-only: `aria-readonly`, still focusable, but the list does not open and changes are ignored. */
|
|
76
|
+
readonly?: boolean;
|
|
72
77
|
options: { value: string; label: string }[];
|
|
73
78
|
placeholder?: string;
|
|
74
79
|
hint?: string;
|
|
75
80
|
errors?: string[];
|
|
76
81
|
status?: 'error' | 'success';
|
|
82
|
+
/** `inline` puts the label beside the control; hint and errors wrap below. */
|
|
83
|
+
layout?: 'stacked' | 'inline';
|
|
77
84
|
}>(),
|
|
78
85
|
{
|
|
79
86
|
id: undefined,
|
|
@@ -82,10 +89,12 @@ const props = withDefaults(
|
|
|
82
89
|
name: undefined,
|
|
83
90
|
required: false,
|
|
84
91
|
disabled: false,
|
|
92
|
+
readonly: false,
|
|
85
93
|
placeholder: undefined,
|
|
86
94
|
hint: undefined,
|
|
87
95
|
errors: () => [],
|
|
88
96
|
status: undefined,
|
|
97
|
+
layout: 'stacked',
|
|
89
98
|
},
|
|
90
99
|
);
|
|
91
100
|
|
|
@@ -111,6 +120,30 @@ const describedBy = computed(() => {
|
|
|
111
120
|
if (hasErrors.value) ids.push(errorsId);
|
|
112
121
|
return ids.length > 0 ? ids.join(' ') : undefined;
|
|
113
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
|
+
}
|
|
114
147
|
</script>
|
|
115
148
|
|
|
116
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);
|
|
@@ -56,9 +60,19 @@
|
|
|
56
60
|
background: var(--bg-clr-success, var(--bg-clr-brand));
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/* The label is the click target for the switch, so it reads as one. */
|
|
64
|
+
.pui-field:has(.pui-switch) .pui-field__label {
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
user-select: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
/* Disable the cursor + label colour when the input is disabled. */
|
|
60
70
|
.pui-field:has(.pui-switch:disabled) .pui-field__label {
|
|
61
71
|
color: var(--text-clr-muted);
|
|
62
72
|
cursor: not-allowed;
|
|
63
73
|
}
|
|
74
|
+
|
|
75
|
+
.pui-field[data-readonly='true']:has(.pui-switch) .pui-field__label {
|
|
76
|
+
cursor: default;
|
|
77
|
+
}
|
|
64
78
|
}
|