@pienter/ui 0.11.0 → 0.14.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 +96 -0
- package/CONVENTIONS.md +17 -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/app-layout/app-layout.css +15 -13
- 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/icons/index.ts +4 -0
- package/icons/sidebar-left.ts +1 -0
- package/icons/sidebar-right.ts +1 -0
- package/package.json +3 -1
- package/styles/4-components/form-field.css +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,102 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.14.0 - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- `AppLayout`'s main region is now the scrollport. The shell is one viewport
|
|
10
|
+
tall (`100dvh`), `.pui-app-layout__main` takes the height left under the
|
|
11
|
+
header and scrolls with `overflow: auto`, and the document never scrolls.
|
|
12
|
+
The header and navigation rail lose their `position: sticky`; they simply
|
|
13
|
+
stay put. This gives a page a definite height to work against: a sticky
|
|
14
|
+
pagination or action band pins to the region's bottom edge, a `1fr` row
|
|
15
|
+
takes the real leftover space, and a short page no longer grows a scrollbar
|
|
16
|
+
to fit a sticky aside. `overscroll-behavior-block: contain` on the region
|
|
17
|
+
keeps an iOS rubber-band from dragging the whole shell, and a print rule
|
|
18
|
+
(`block-size: auto`, `overflow: visible`) prints the whole page rather than
|
|
19
|
+
one screenful.
|
|
20
|
+
|
|
21
|
+
Consumer-visible: anything that scrolled the window must now scroll the
|
|
22
|
+
region. Vue Router's `scrollBehavior` only scrolls the window, so return
|
|
23
|
+
`false` from it and set `main.scrollTop` yourself on route change (and on
|
|
24
|
+
back, if you restore); move a `scroll-padding-block-end` for sticky bands
|
|
25
|
+
from `html` to `<main>`, and drop any `scroll-padding-block-start` for the
|
|
26
|
+
header, which is now outside the scrollport. A sticky band pins at the
|
|
27
|
+
region's content edge, inside its padding, so a page that ends in one
|
|
28
|
+
sets `--pui-app-layout-main-padding` with a `0` block-end instead of
|
|
29
|
+
pulling the band down with a negative margin. Fragment links to targets
|
|
30
|
+
inside the region keep working natively. Escape hatch, one line:
|
|
31
|
+
`.pui-app-layout { block-size: auto; min-block-size: 100dvh }` makes the
|
|
32
|
+
document the scrollport again (pin the header yourself if you want it
|
|
33
|
+
fixed).
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `sidebar-left` and `sidebar-right` icons: a panel outline with the divider on
|
|
37
|
+
the left or the right, for toggling a side column. `sidebar-left` is
|
|
38
|
+
Feather's `sidebar`; `sidebar-right` is its mirror on the same 24px grid.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## 0.13.0 - 2026-09-15
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- View mode for `BlockEditor`: `readonly` renders the block list as content.
|
|
46
|
+
The drag handle, move, remove and add controls and the reorder hint go, every
|
|
47
|
+
mutation returns early, and `#block` receives `readonly` beside `disabled` so
|
|
48
|
+
the fields render read-only. The collapse toggles stay — they are the escape
|
|
49
|
+
hatch for a long list — and blocks start expanded. Each block title now sits
|
|
50
|
+
in a heading element around its collapse button, so heading navigation works
|
|
51
|
+
in both modes; `level` (2–6, default 3) picks it. A read-only editor carries
|
|
52
|
+
`data-readonly="true"`, and `Panel` keeps its border, background and padding
|
|
53
|
+
around one instead of dropping them as it does for an editable editor.
|
|
54
|
+
- `image` field type on `RecordFormField`: a URL `type="url"` TextInput while
|
|
55
|
+
editing, the image itself when read-only, capped so a full-size asset cannot
|
|
56
|
+
push the layout, with the same em dash as `RecordDetails` when the value is
|
|
57
|
+
empty. `altName` points at a sibling field in the same list for the
|
|
58
|
+
alternative text, so it works for nested and indexed paths.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- A read-only `Textarea` grows with its value instead of clipping it inside a
|
|
63
|
+
box nobody can type in; `rows` becomes its minimum height.
|
|
64
|
+
|
|
65
|
+
## 0.12.0 - 2026-09-15
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- View mode: `readonly` on `RecordForm` renders the same form read-only. The
|
|
70
|
+
flag fans out to `RecordFields` (and its `field:<name>` slot props) and to
|
|
71
|
+
every form primitive — `TextInput`, `Textarea`, `NumberField`, `DateInput`,
|
|
72
|
+
`Select`, `Combobox`, `Checkbox`, `Switch`, `RadioGroup` and `TagsInput`
|
|
73
|
+
gain a `readonly` prop. Native inputs get the `readonly` attribute; a
|
|
74
|
+
`select`, checkbox, switch or radio group gets `aria-readonly="true"` and
|
|
75
|
+
ignores changes; `NumberField` drops its steppers, `TagsInput` its remove
|
|
76
|
+
buttons, `Combobox` keeps its listbox closed. A read-only field is never
|
|
77
|
+
disabled: it stays in the tab order with its border, text colour and focus
|
|
78
|
+
ring, on the muted `--bg-clr-surface-2` surface, without a placeholder.
|
|
79
|
+
`.pui-field` carries `data-readonly="true"` for styling. `Form` also takes
|
|
80
|
+
`readonly`: it renders no actions row and never emits `submit`, Enter
|
|
81
|
+
included. Everything is off by default, so existing consumers are
|
|
82
|
+
unaffected.
|
|
83
|
+
|
|
84
|
+
- `RecordLayout` and `Panel`: the sidebar-plus-panels shape a record page is
|
|
85
|
+
built from. `RecordLayout` places a `sidebar` slot (an `aside` named through
|
|
86
|
+
`sidebar-labelledby`) beside a main column and stacks them below 56em.
|
|
87
|
+
`Panel` is one bordered section with its heading inside it (`title`,
|
|
88
|
+
`description`, `level`, `title-id`); it honours `hidden`, so it serves as a
|
|
89
|
+
tab panel as-is, and drops its border, background and padding when its
|
|
90
|
+
direct child is a `BlockEditor`. Both set `--pui-record-fields-columns` for
|
|
91
|
+
the RecordFields inside them — one column in the sidebar, two in a panel
|
|
92
|
+
from 48em with textarea fields spanning the row — and the sidebar stacks
|
|
93
|
+
RecordDetails labels over their values, so a consumer page no longer
|
|
94
|
+
carries any of that CSS.
|
|
95
|
+
|
|
96
|
+
### Fixed
|
|
97
|
+
|
|
98
|
+
- `Tabs` wraps its buttons inside a narrow container instead of pushing the
|
|
99
|
+
tablist past the container's edge.
|
|
100
|
+
|
|
5
101
|
## 0.11.0 - 2026-09-15
|
|
6
102
|
|
|
7
103
|
### 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) |
|
|
@@ -1421,6 +1424,13 @@ Pinned upstream SHAs used across the table:
|
|
|
1421
1424
|
`Index`, `RecordForm`, and `RecordDetails` compose existing primitives. Consumers
|
|
1422
1425
|
register routes and supply endpoint-specific data functions, rendering and actions.
|
|
1423
1426
|
|
|
1427
|
+
They render inside `AppLayout`, whose main region is the scrollport: the shell
|
|
1428
|
+
is `100dvh` tall, `<main>` scrolls, the document does not. Page-level bands
|
|
1429
|
+
(`position: sticky`) and leftover-height rows (`1fr`) inside a page therefore
|
|
1430
|
+
resolve against a definite height. Scroll-to-top on navigation,
|
|
1431
|
+
`scroll-padding` for sticky bands and scroll restoration target that region,
|
|
1432
|
+
not the window; the AppLayout audit lists what a consumer wires.
|
|
1433
|
+
|
|
1424
1434
|
Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
|
|
1425
1435
|
query state, rows, metadata, loading, failures and retry. Search is debounced;
|
|
1426
1436
|
search/filter/sort/page-size changes reset page to 1. New requests and unmount
|
|
@@ -1465,6 +1475,13 @@ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
|
|
|
1465
1475
|
subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
|
|
1466
1476
|
form element. Callers expose the relevant tab when backend issues arrive.
|
|
1467
1477
|
|
|
1478
|
+
RecordLayout and Panel give a record page its shape (`CONTEXT.md`): a sidebar
|
|
1479
|
+
`aside` named through `sidebar-labelledby` beside a main column that stacks
|
|
1480
|
+
below 56em, and one bordered section per field group or tab with its heading
|
|
1481
|
+
inside it. Panel honours `hidden`, so a consumer toggles tab panels with the
|
|
1482
|
+
attribute, and drops its chrome around a direct `BlockEditor` child. Both set
|
|
1483
|
+
`--pui-record-fields-columns` for the RecordFields inside them.
|
|
1484
|
+
|
|
1468
1485
|
BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
|
|
1469
1486
|
for edits, addition, removal and reordering. Creation factories and slot content
|
|
1470
1487
|
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') {
|