@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.13.0 - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- View mode for `BlockEditor`: `readonly` renders the block list as content.
|
|
10
|
+
The drag handle, move, remove and add controls and the reorder hint go, every
|
|
11
|
+
mutation returns early, and `#block` receives `readonly` beside `disabled` so
|
|
12
|
+
the fields render read-only. The collapse toggles stay — they are the escape
|
|
13
|
+
hatch for a long list — and blocks start expanded. Each block title now sits
|
|
14
|
+
in a heading element around its collapse button, so heading navigation works
|
|
15
|
+
in both modes; `level` (2–6, default 3) picks it. A read-only editor carries
|
|
16
|
+
`data-readonly="true"`, and `Panel` keeps its border, background and padding
|
|
17
|
+
around one instead of dropping them as it does for an editable editor.
|
|
18
|
+
- `image` field type on `RecordFormField`: a URL `type="url"` TextInput while
|
|
19
|
+
editing, the image itself when read-only, capped so a full-size asset cannot
|
|
20
|
+
push the layout, with the same em dash as `RecordDetails` when the value is
|
|
21
|
+
empty. `altName` points at a sibling field in the same list for the
|
|
22
|
+
alternative text, so it works for nested and indexed paths.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- A read-only `Textarea` grows with its value instead of clipping it inside a
|
|
27
|
+
box nobody can type in; `rows` becomes its minimum height.
|
|
28
|
+
|
|
29
|
+
## 0.12.0 - 2026-09-15
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- View mode: `readonly` on `RecordForm` renders the same form read-only. The
|
|
34
|
+
flag fans out to `RecordFields` (and its `field:<name>` slot props) and to
|
|
35
|
+
every form primitive — `TextInput`, `Textarea`, `NumberField`, `DateInput`,
|
|
36
|
+
`Select`, `Combobox`, `Checkbox`, `Switch`, `RadioGroup` and `TagsInput`
|
|
37
|
+
gain a `readonly` prop. Native inputs get the `readonly` attribute; a
|
|
38
|
+
`select`, checkbox, switch or radio group gets `aria-readonly="true"` and
|
|
39
|
+
ignores changes; `NumberField` drops its steppers, `TagsInput` its remove
|
|
40
|
+
buttons, `Combobox` keeps its listbox closed. A read-only field is never
|
|
41
|
+
disabled: it stays in the tab order with its border, text colour and focus
|
|
42
|
+
ring, on the muted `--bg-clr-surface-2` surface, without a placeholder.
|
|
43
|
+
`.pui-field` carries `data-readonly="true"` for styling. `Form` also takes
|
|
44
|
+
`readonly`: it renders no actions row and never emits `submit`, Enter
|
|
45
|
+
included. Everything is off by default, so existing consumers are
|
|
46
|
+
unaffected.
|
|
47
|
+
|
|
48
|
+
- `RecordLayout` and `Panel`: the sidebar-plus-panels shape a record page is
|
|
49
|
+
built from. `RecordLayout` places a `sidebar` slot (an `aside` named through
|
|
50
|
+
`sidebar-labelledby`) beside a main column and stacks them below 56em.
|
|
51
|
+
`Panel` is one bordered section with its heading inside it (`title`,
|
|
52
|
+
`description`, `level`, `title-id`); it honours `hidden`, so it serves as a
|
|
53
|
+
tab panel as-is, and drops its border, background and padding when its
|
|
54
|
+
direct child is a `BlockEditor`. Both set `--pui-record-fields-columns` for
|
|
55
|
+
the RecordFields inside them — one column in the sidebar, two in a panel
|
|
56
|
+
from 48em with textarea fields spanning the row — and the sidebar stacks
|
|
57
|
+
RecordDetails labels over their values, so a consumer page no longer
|
|
58
|
+
carries any of that CSS.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- `Tabs` wraps its buttons inside a narrow container instead of pushing the
|
|
63
|
+
tablist past the container's edge.
|
|
64
|
+
|
|
5
65
|
## 0.11.0 - 2026-09-15
|
|
6
66
|
|
|
7
67
|
### Added
|
package/CONVENTIONS.md
CHANGED
|
@@ -108,6 +108,7 @@ Variants and runtime states live on `data-*` attributes on the block element, ne
|
|
|
108
108
|
| `data-layout` | Form-primitive scaffold layout selector — lives on `.pui-field`; `inline` puts label and control on one row in DOM order, hint and errors below (see checklist) | `stacked` (default; attribute may be omitted), `inline` |
|
|
109
109
|
| `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
|
|
110
110
|
| `data-placement` | Where a CMS surface is mounted, which decides its chrome and heading level — lives on `.pui-index` | `module` (default), `related` |
|
|
111
|
+
| `data-readonly` | Read-only form primitive (boolean) — lives on `.pui-field` beside the control's native `readonly` / `aria-readonly`, so one selector styles every primitive | `'true'` or absent |
|
|
111
112
|
|
|
112
113
|
The example values listed for each attribute are non-exhaustive — components add states as needed (e.g. `expanded`, `selected`, `active` for upcoming Disclosure, Tabs, Sidebar). New values follow the same kebab-case rule and live on the same attribute family.
|
|
113
114
|
|
|
@@ -1280,9 +1281,11 @@ follow the shape of an existing one.
|
|
|
1280
1281
|
| Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
|
|
1281
1282
|
| NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
|
|
1282
1283
|
| Pagination | [`components/navigation/pagination/AUDIT.md`](./components/navigation/pagination/AUDIT.md) |
|
|
1284
|
+
| Panel | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
|
|
1283
1285
|
| Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
|
|
1284
1286
|
| Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
|
|
1285
1287
|
| RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
|
|
1288
|
+
| RecordLayout | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
|
|
1286
1289
|
| Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1287
1290
|
| Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1288
1291
|
| Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
|
|
@@ -1465,6 +1468,13 @@ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
|
|
|
1465
1468
|
subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
|
|
1466
1469
|
form element. Callers expose the relevant tab when backend issues arrive.
|
|
1467
1470
|
|
|
1471
|
+
RecordLayout and Panel give a record page its shape (`CONTEXT.md`): a sidebar
|
|
1472
|
+
`aside` named through `sidebar-labelledby` beside a main column that stacks
|
|
1473
|
+
below 56em, and one bordered section per field group or tab with its heading
|
|
1474
|
+
inside it. Panel honours `hidden`, so a consumer toggles tab panels with the
|
|
1475
|
+
attribute, and drops its chrome around a direct `BlockEditor` child. Both set
|
|
1476
|
+
`--pui-record-fields-columns` for the RecordFields inside them.
|
|
1477
|
+
|
|
1468
1478
|
BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
|
|
1469
1479
|
for edits, addition, removal and reordering. Creation factories and slot content
|
|
1470
1480
|
stay caller-owned. Optional `v-model:collapsed` lets pages reveal invalid blocks.
|
|
@@ -18,13 +18,19 @@ const props = withDefaults(
|
|
|
18
18
|
/** Optional controlled list of collapsed block ids. */
|
|
19
19
|
collapsed?: readonly string[];
|
|
20
20
|
disabled?: boolean;
|
|
21
|
+
/** View mode: the editing affordances go, the collapse toggles stay and the slot renders read-only. */
|
|
22
|
+
readonly?: boolean;
|
|
21
23
|
label?: string;
|
|
24
|
+
/** Heading level of each block title: `3` sits under a level-2 `Panel` title. */
|
|
25
|
+
level?: 2 | 3 | 4 | 5 | 6;
|
|
22
26
|
blockLabel?: (block: T, index: number) => string;
|
|
23
27
|
}>(),
|
|
24
28
|
{
|
|
25
29
|
disabled: false,
|
|
30
|
+
readonly: false,
|
|
26
31
|
collapsed: undefined,
|
|
27
32
|
label: 'Content blocks',
|
|
33
|
+
level: 3,
|
|
28
34
|
blockLabel: undefined,
|
|
29
35
|
},
|
|
30
36
|
);
|
|
@@ -39,12 +45,14 @@ defineSlots<{
|
|
|
39
45
|
index: number;
|
|
40
46
|
update: (patch: BlockPatch<T>) => void;
|
|
41
47
|
disabled: boolean;
|
|
48
|
+
readonly: boolean;
|
|
42
49
|
}) => unknown;
|
|
43
50
|
summary?: (scope: { block: T; index: number }) => unknown;
|
|
44
51
|
empty?: () => unknown;
|
|
45
52
|
}>();
|
|
46
53
|
|
|
47
54
|
const id = generateId('blocks');
|
|
55
|
+
const locked = computed(() => props.disabled || props.readonly);
|
|
48
56
|
const localCollapsed = ref<string[]>([]);
|
|
49
57
|
const collapsed = computed(
|
|
50
58
|
() => new Set(props.collapsed ?? localCollapsed.value),
|
|
@@ -95,7 +103,7 @@ function focusBlock(key: string) {
|
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
function add(type: BlockType<T>) {
|
|
98
|
-
if (
|
|
106
|
+
if (locked.value) return;
|
|
99
107
|
const block = type.create();
|
|
100
108
|
if (
|
|
101
109
|
!block.id ||
|
|
@@ -112,7 +120,7 @@ function add(type: BlockType<T>) {
|
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
function update(key: string, patch: BlockPatch<T>) {
|
|
115
|
-
if (
|
|
123
|
+
if (locked.value) return;
|
|
116
124
|
emit(
|
|
117
125
|
'update:modelValue',
|
|
118
126
|
props.modelValue.map((block) =>
|
|
@@ -124,7 +132,7 @@ function update(key: string, patch: BlockPatch<T>) {
|
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
function remove(key: string) {
|
|
127
|
-
if (
|
|
135
|
+
if (locked.value) return;
|
|
128
136
|
const index = props.modelValue.findIndex((block) => block.id === key);
|
|
129
137
|
const block = props.modelValue[index];
|
|
130
138
|
if (!block) return;
|
|
@@ -144,7 +152,7 @@ function remove(key: string) {
|
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
function move(key: string, target: number) {
|
|
147
|
-
if (
|
|
155
|
+
if (locked.value) return;
|
|
148
156
|
const source = props.modelValue.findIndex((block) => block.id === key);
|
|
149
157
|
if (
|
|
150
158
|
source < 0 ||
|
|
@@ -220,7 +228,7 @@ function stopDrag() {
|
|
|
220
228
|
}
|
|
221
229
|
|
|
222
230
|
function startDrag(event: PointerEvent, key: string) {
|
|
223
|
-
if (
|
|
231
|
+
if (locked.value || event.button !== 0 || props.modelValue.length < 2)
|
|
224
232
|
return;
|
|
225
233
|
stopDrag();
|
|
226
234
|
const handle = event.currentTarget as HTMLButtonElement;
|
|
@@ -295,12 +303,9 @@ function cancelWithEscape(event: KeyboardEvent) {
|
|
|
295
303
|
}
|
|
296
304
|
}
|
|
297
305
|
|
|
298
|
-
watch(
|
|
299
|
-
()
|
|
300
|
-
|
|
301
|
-
if (disabled) stopDrag();
|
|
302
|
-
},
|
|
303
|
-
);
|
|
306
|
+
watch(locked, (value) => {
|
|
307
|
+
if (value) stopDrag();
|
|
308
|
+
});
|
|
304
309
|
watch(() => props.modelValue.map((block) => block.id).join('\u0000'), stopDrag);
|
|
305
310
|
onBeforeUnmount(stopDrag);
|
|
306
311
|
</script>
|
|
@@ -309,15 +314,16 @@ onBeforeUnmount(stopDrag);
|
|
|
309
314
|
<div
|
|
310
315
|
class="pui-block-editor"
|
|
311
316
|
:data-disabled="disabled ? 'true' : undefined"
|
|
317
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
312
318
|
>
|
|
313
|
-
<p :id="`${id}-hint`" class="pui-block-editor__hint">
|
|
319
|
+
<p v-if="!readonly" :id="`${id}-hint`" class="pui-block-editor__hint">
|
|
314
320
|
Use the move buttons to reorder, or drag with a pointer.
|
|
315
321
|
</p>
|
|
316
322
|
<ol
|
|
317
323
|
v-if="modelValue.length"
|
|
318
324
|
class="pui-block-editor__list"
|
|
319
325
|
:aria-label="label"
|
|
320
|
-
:aria-describedby="`${id}-hint`"
|
|
326
|
+
:aria-describedby="readonly ? undefined : `${id}-hint`"
|
|
321
327
|
>
|
|
322
328
|
<li
|
|
323
329
|
v-for="(block, index) in modelValue"
|
|
@@ -333,6 +339,7 @@ onBeforeUnmount(stopDrag);
|
|
|
333
339
|
>
|
|
334
340
|
<div class="pui-block-editor__header">
|
|
335
341
|
<button
|
|
342
|
+
v-if="!readonly"
|
|
336
343
|
class="pui-block-editor__drag"
|
|
337
344
|
type="button"
|
|
338
345
|
tabindex="-1"
|
|
@@ -344,35 +351,42 @@ onBeforeUnmount(stopDrag);
|
|
|
344
351
|
<Icon name="arrow-up-down" size="sm" />
|
|
345
352
|
</button>
|
|
346
353
|
<div class="pui-block-editor__heading">
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
class="pui-block-
|
|
350
|
-
data-block-toggle
|
|
351
|
-
:disabled="disabled"
|
|
352
|
-
:aria-label="`${collapsed.has(block.id) ? 'Expand' : 'Collapse'} ${title(block, index)}`"
|
|
353
|
-
:aria-expanded="
|
|
354
|
-
collapsed.has(block.id) ? 'false' : 'true'
|
|
355
|
-
"
|
|
356
|
-
:aria-controls="bodyId(block)"
|
|
357
|
-
@click="toggle(block.id)"
|
|
354
|
+
<component
|
|
355
|
+
:is="`h${level}`"
|
|
356
|
+
class="pui-block-editor__heading-text"
|
|
358
357
|
>
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
class="pui-block-editor__toggle"
|
|
361
|
+
data-block-toggle
|
|
362
|
+
:disabled="disabled"
|
|
363
|
+
:aria-label="`${collapsed.has(block.id) ? 'Expand' : 'Collapse'} ${title(block, index)}`"
|
|
364
|
+
:aria-expanded="
|
|
365
|
+
collapsed.has(block.id) ? 'false' : 'true'
|
|
364
366
|
"
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
<span class="pui-block-editor__title">{{
|
|
368
|
-
title(block, index)
|
|
369
|
-
}}</span>
|
|
370
|
-
<span
|
|
371
|
-
v-if="title(block, index) !== typeLabel(block)"
|
|
372
|
-
class="pui-block-editor__type"
|
|
373
|
-
>{{ typeLabel(block) }}</span
|
|
367
|
+
:aria-controls="bodyId(block)"
|
|
368
|
+
@click="toggle(block.id)"
|
|
374
369
|
>
|
|
375
|
-
|
|
370
|
+
<Icon
|
|
371
|
+
:name="
|
|
372
|
+
collapsed.has(block.id)
|
|
373
|
+
? 'chevron-right'
|
|
374
|
+
: 'chevron-down'
|
|
375
|
+
"
|
|
376
|
+
size="sm"
|
|
377
|
+
/>
|
|
378
|
+
<span class="pui-block-editor__title">{{
|
|
379
|
+
title(block, index)
|
|
380
|
+
}}</span>
|
|
381
|
+
<span
|
|
382
|
+
v-if="
|
|
383
|
+
title(block, index) !== typeLabel(block)
|
|
384
|
+
"
|
|
385
|
+
class="pui-block-editor__type"
|
|
386
|
+
>{{ typeLabel(block) }}</span
|
|
387
|
+
>
|
|
388
|
+
</button>
|
|
389
|
+
</component>
|
|
376
390
|
<div
|
|
377
391
|
v-if="$slots.summary"
|
|
378
392
|
class="pui-block-editor__summary"
|
|
@@ -384,7 +398,7 @@ onBeforeUnmount(stopDrag);
|
|
|
384
398
|
/>
|
|
385
399
|
</div>
|
|
386
400
|
</div>
|
|
387
|
-
<div class="pui-block-editor__actions">
|
|
401
|
+
<div v-if="!readonly" class="pui-block-editor__actions">
|
|
388
402
|
<IconButton
|
|
389
403
|
name="arrow-up"
|
|
390
404
|
variant="ghost"
|
|
@@ -427,16 +441,19 @@ onBeforeUnmount(stopDrag);
|
|
|
427
441
|
(patch: BlockPatch<T>) => update(block.id, patch)
|
|
428
442
|
"
|
|
429
443
|
:disabled="disabled"
|
|
444
|
+
:readonly="readonly"
|
|
430
445
|
/>
|
|
431
446
|
</div>
|
|
432
447
|
</li>
|
|
433
448
|
</ol>
|
|
434
449
|
<div v-else class="pui-block-editor__empty">
|
|
435
|
-
<slot name="empty"
|
|
436
|
-
|
|
437
|
-
|
|
450
|
+
<slot name="empty">{{
|
|
451
|
+
readonly
|
|
452
|
+
? 'No content blocks.'
|
|
453
|
+
: 'No blocks yet. Add a block to start writing.'
|
|
454
|
+
}}</slot>
|
|
438
455
|
</div>
|
|
439
|
-
<div :id="`${id}-add`" class="pui-block-editor__add">
|
|
456
|
+
<div v-if="!readonly" :id="`${id}-add`" class="pui-block-editor__add">
|
|
440
457
|
<Button
|
|
441
458
|
v-for="type in blockTypes"
|
|
442
459
|
:key="type.type"
|
|
@@ -62,6 +62,13 @@
|
|
|
62
62
|
min-inline-size: 0;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/* A heading element so the toggle is reachable by heading navigation; it
|
|
66
|
+
* carries no type scale of its own. */
|
|
67
|
+
.pui-block-editor__heading-text {
|
|
68
|
+
margin: 0;
|
|
69
|
+
font: inherit;
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
.pui-block-editor__drag,
|
|
66
73
|
.pui-block-editor__toggle {
|
|
67
74
|
display: inline-flex;
|
|
@@ -124,6 +131,10 @@
|
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
.pui-block-editor__body {
|
|
134
|
+
/* A block body is a narrow box inside a panel; it keeps one column
|
|
135
|
+
* whatever the panel around it asks for. */
|
|
136
|
+
--pui-record-fields-columns: minmax(0, 1fr);
|
|
137
|
+
|
|
127
138
|
display: grid;
|
|
128
139
|
gap: var(--space-s);
|
|
129
140
|
padding: var(--space-s);
|
|
@@ -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
|
:required="required"
|
|
11
16
|
:disabled="disabled"
|
|
12
17
|
:aria-checked="ariaCheckedValue"
|
|
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"
|
|
23
25
|
>{{ label
|
|
@@ -54,6 +56,8 @@ const props = withDefaults(
|
|
|
54
56
|
name?: string;
|
|
55
57
|
required?: boolean;
|
|
56
58
|
disabled?: boolean;
|
|
59
|
+
/** Read-only: `aria-readonly`, still focusable, but toggling is ignored. */
|
|
60
|
+
readonly?: boolean;
|
|
57
61
|
hint?: string;
|
|
58
62
|
errors?: string[];
|
|
59
63
|
status?: 'error' | 'success';
|
|
@@ -64,6 +68,7 @@ const props = withDefaults(
|
|
|
64
68
|
name: undefined,
|
|
65
69
|
required: false,
|
|
66
70
|
disabled: false,
|
|
71
|
+
readonly: false,
|
|
67
72
|
hint: undefined,
|
|
68
73
|
errors: () => [],
|
|
69
74
|
status: undefined,
|
|
@@ -96,6 +101,17 @@ const describedBy = computed(() => {
|
|
|
96
101
|
const ariaCheckedValue = computed<'true' | 'false'>(() =>
|
|
97
102
|
props.modelValue ? 'true' : 'false',
|
|
98
103
|
);
|
|
104
|
+
|
|
105
|
+
// Cancelling `click` keeps the native checked state and fires no `change`;
|
|
106
|
+
// the label's forwarded click and the Space key arrive here too.
|
|
107
|
+
function onClick(event: MouseEvent): void {
|
|
108
|
+
if (props.readonly) event.preventDefault();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onChange(event: Event): void {
|
|
112
|
+
if (props.readonly) return;
|
|
113
|
+
emit('update:modelValue', (event.target as HTMLInputElement).checked);
|
|
114
|
+
}
|
|
99
115
|
</script>
|
|
100
116
|
|
|
101
117
|
<style>
|
|
@@ -41,6 +41,14 @@
|
|
|
41
41
|
cursor: not-allowed;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
.pui-field[data-readonly='true'] .pui-checkbox {
|
|
45
|
+
cursor: default;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.pui-field[data-readonly='true'] .pui-checkbox:not(:checked) {
|
|
49
|
+
background: var(--bg-clr-surface-2);
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
/* Status-driven recolour from the parent pui-field wrapper. */
|
|
45
53
|
.pui-field[data-status='error'] .pui-checkbox {
|
|
46
54
|
border-color: var(--border-clr-danger);
|
|
@@ -62,4 +70,8 @@
|
|
|
62
70
|
color: var(--text-clr-muted);
|
|
63
71
|
cursor: not-allowed;
|
|
64
72
|
}
|
|
73
|
+
|
|
74
|
+
.pui-field[data-readonly='true']:has(.pui-checkbox) .pui-field__label {
|
|
75
|
+
cursor: default;
|
|
76
|
+
}
|
|
65
77
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
ref="fieldRef"
|
|
4
4
|
class="pui-field"
|
|
5
5
|
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
6
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
6
7
|
:data-status="computedStatus"
|
|
7
8
|
>
|
|
8
9
|
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
@@ -23,8 +24,9 @@
|
|
|
23
24
|
role="combobox"
|
|
24
25
|
:name="name"
|
|
25
26
|
:value="displayValue"
|
|
26
|
-
:placeholder="placeholder"
|
|
27
|
+
:placeholder="readonly ? undefined : placeholder"
|
|
27
28
|
:disabled="disabled"
|
|
29
|
+
:readonly="readonly"
|
|
28
30
|
:required="required"
|
|
29
31
|
:autocomplete="autocomplete"
|
|
30
32
|
:popovertarget="listboxId"
|
|
@@ -147,6 +149,8 @@ const props = withDefaults(
|
|
|
147
149
|
placeholder?: string;
|
|
148
150
|
/** Disables the input (and thus the popover). */
|
|
149
151
|
disabled?: boolean;
|
|
152
|
+
/** Read-only: native `readonly` on the input, still focusable, the listbox never opens. */
|
|
153
|
+
readonly?: boolean;
|
|
150
154
|
/** Marks the input as required for form submission. */
|
|
151
155
|
required?: boolean;
|
|
152
156
|
/** Native autocomplete value forwarded to the input. */
|
|
@@ -189,6 +193,7 @@ const props = withDefaults(
|
|
|
189
193
|
name: undefined,
|
|
190
194
|
placeholder: undefined,
|
|
191
195
|
disabled: false,
|
|
196
|
+
readonly: false,
|
|
192
197
|
required: false,
|
|
193
198
|
autocomplete: undefined,
|
|
194
199
|
hint: undefined,
|
|
@@ -310,7 +315,7 @@ const activeOptionDomId = computed(() => {
|
|
|
310
315
|
});
|
|
311
316
|
|
|
312
317
|
function open(): void {
|
|
313
|
-
if (props.disabled) return;
|
|
318
|
+
if (props.disabled || props.readonly) return;
|
|
314
319
|
control.show();
|
|
315
320
|
}
|
|
316
321
|
|
|
@@ -364,6 +369,7 @@ function selectOption(opt: ComboboxOption): void {
|
|
|
364
369
|
}
|
|
365
370
|
|
|
366
371
|
function onInput(event: Event): void {
|
|
372
|
+
if (props.readonly) return;
|
|
367
373
|
const target = event.target as HTMLInputElement;
|
|
368
374
|
query.value = target.value;
|
|
369
375
|
isTyping.value = true;
|
|
@@ -406,6 +412,7 @@ function onOptionMouseEnter(idx: number): void {
|
|
|
406
412
|
}
|
|
407
413
|
|
|
408
414
|
function onKeydown(event: KeyboardEvent): void {
|
|
415
|
+
if (props.readonly) return;
|
|
409
416
|
switch (event.key) {
|
|
410
417
|
case 'ArrowDown':
|
|
411
418
|
event.preventDefault();
|
|
@@ -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"
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
:step="step"
|
|
26
27
|
:required="required"
|
|
27
28
|
:disabled="disabled"
|
|
29
|
+
:readonly="readonly"
|
|
28
30
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
29
31
|
:aria-describedby="describedBy"
|
|
30
32
|
@input="
|
|
@@ -84,6 +86,8 @@ const props = withDefaults(
|
|
|
84
86
|
step?: number | string;
|
|
85
87
|
required?: boolean;
|
|
86
88
|
disabled?: boolean;
|
|
89
|
+
/** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
|
|
90
|
+
readonly?: boolean;
|
|
87
91
|
hint?: string;
|
|
88
92
|
errors?: string[];
|
|
89
93
|
status?: 'error' | 'success';
|
|
@@ -99,6 +103,7 @@ const props = withDefaults(
|
|
|
99
103
|
step: undefined,
|
|
100
104
|
required: false,
|
|
101
105
|
disabled: false,
|
|
106
|
+
readonly: false,
|
|
102
107
|
hint: undefined,
|
|
103
108
|
errors: () => [],
|
|
104
109
|
status: undefined,
|
|
@@ -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
|
|
|
@@ -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"
|
|
@@ -24,16 +25,17 @@
|
|
|
24
25
|
:min="min"
|
|
25
26
|
:max="max"
|
|
26
27
|
:step="step"
|
|
27
|
-
:placeholder="placeholder"
|
|
28
|
+
:placeholder="readonly ? undefined : placeholder"
|
|
28
29
|
:required="required"
|
|
29
30
|
:disabled="disabled"
|
|
31
|
+
:readonly="readonly"
|
|
30
32
|
:value="modelValue"
|
|
31
33
|
:aria-invalid="hasErrors ? 'true' : undefined"
|
|
32
34
|
:aria-describedby="describedBy"
|
|
33
35
|
@input="onInput"
|
|
34
36
|
@blur="emit('blur')"
|
|
35
37
|
/>
|
|
36
|
-
<div class="pui-number-field__steppers">
|
|
38
|
+
<div v-if="!readonly" class="pui-number-field__steppers">
|
|
37
39
|
<button
|
|
38
40
|
type="button"
|
|
39
41
|
class="pui-number-field__step"
|
|
@@ -91,6 +93,8 @@ const props = withDefaults(
|
|
|
91
93
|
placeholder?: string;
|
|
92
94
|
required?: boolean;
|
|
93
95
|
disabled?: boolean;
|
|
96
|
+
/** Read-only: the value shows in the field chrome without steppers, stays focusable and cannot be edited. */
|
|
97
|
+
readonly?: boolean;
|
|
94
98
|
hint?: string;
|
|
95
99
|
errors?: string[];
|
|
96
100
|
status?: 'error' | 'success';
|
|
@@ -107,6 +111,7 @@ const props = withDefaults(
|
|
|
107
111
|
placeholder: undefined,
|
|
108
112
|
required: false,
|
|
109
113
|
disabled: false,
|
|
114
|
+
readonly: false,
|
|
110
115
|
hint: undefined,
|
|
111
116
|
errors: () => [],
|
|
112
117
|
status: undefined,
|
|
@@ -149,7 +154,7 @@ function onInput(event: Event): void {
|
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
function handleStep(direction: 'decrement' | 'increment'): void {
|
|
152
|
-
if (props.disabled) return;
|
|
157
|
+
if (props.disabled || props.readonly) return;
|
|
153
158
|
const input = inputRef.value;
|
|
154
159
|
if (!input) return;
|
|
155
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 {
|