@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/CONVENTIONS.md +10 -0
  3. package/components/form/block-editor/BlockEditor.vue +61 -44
  4. package/components/form/block-editor/block-editor.css +11 -0
  5. package/components/form/checkbox/Checkbox.vue +23 -7
  6. package/components/form/checkbox/checkbox.css +12 -0
  7. package/components/form/combobox/Combobox.vue +9 -2
  8. package/components/form/date-input/DateInput.vue +5 -0
  9. package/components/form/form/Form.vue +5 -2
  10. package/components/form/number-field/NumberField.vue +8 -3
  11. package/components/form/number-field/number-field.css +6 -0
  12. package/components/form/radio-group/RadioGroup.vue +23 -1
  13. package/components/form/radio-group/radio-group.css +9 -0
  14. package/components/form/record-form/RecordFields.vue +40 -2
  15. package/components/form/record-form/RecordForm.vue +7 -1
  16. package/components/form/record-form/record-form.css +14 -0
  17. package/components/form/record-form/types.ts +6 -0
  18. package/components/form/select/Select.vue +32 -6
  19. package/components/form/select/select.css +5 -0
  20. package/components/form/switch/Switch.vue +23 -7
  21. package/components/form/switch/switch.css +8 -0
  22. package/components/form/tags-input/TagsInput.vue +11 -5
  23. package/components/form/tags-input/tags-input.css +5 -0
  24. package/components/form/text-input/TextInput.vue +6 -1
  25. package/components/form/textarea/Textarea.vue +6 -1
  26. package/components/form/textarea/textarea.css +7 -0
  27. package/components/layout/record-layout/Panel.vue +44 -0
  28. package/components/layout/record-layout/RecordLayout.vue +35 -0
  29. package/components/layout/record-layout/record-layout.css +94 -0
  30. package/components/navigation/tabs/tabs.css +2 -0
  31. package/package.json +3 -1
  32. 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
- @change="emit('update:modelValue', option.value)"
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 (!props.busy) emit('submit', cloneRecord(props.modelValue));
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
- emit(
29
- 'update:modelValue',
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>
@@ -38,6 +38,11 @@
38
38
  cursor: not-allowed;
39
39
  }
40
40
 
41
+ .pui-field[data-readonly='true'] .pui-select__control {
42
+ background: var(--bg-clr-surface-2);
43
+ cursor: default;
44
+ }
45
+
41
46
  .pui-select__caret {
42
47
  position: absolute;
43
48
  right: 0.85em;
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div class="pui-field" data-layout="inline" :data-status="computedStatus">
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
- @change="
16
- emit(
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
- :placeholder="placeholder"
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
- :placeholder="placeholder"
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>