@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,75 @@
|
|
|
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
|
+
|
|
65
|
+
## 0.11.0 - 2026-09-15
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- The Vue CMS playground defines its contacts collection once and mounts it as
|
|
70
|
+
both the `/contacts` module index and the related index on a company's
|
|
71
|
+
Contacts tab, demonstrating `Index` placement and prefixed query syncing with
|
|
72
|
+
realistic consumer-owned columns, loaders, cells and navigation.
|
|
73
|
+
|
|
5
74
|
## 0.10.0 - 2026-09-15
|
|
6
75
|
|
|
7
76
|
### Changed
|
|
@@ -13,9 +82,31 @@
|
|
|
13
82
|
style that targeted them moves to `pui-page-header` and its
|
|
14
83
|
`__heading`, `__title`, `__description` and `__actions` elements. Markup,
|
|
15
84
|
spacing and type are unchanged in both placements.
|
|
85
|
+
- Bare anchors get the standard `:focus-visible` ring from the base layer,
|
|
86
|
+
and their underline offset is the `--space-3xs` token instead of a `0.14em`
|
|
87
|
+
literal, so content links inside a consumer page need no app rule for
|
|
88
|
+
colour, offset or focus. Components that style their own anchors (Button,
|
|
89
|
+
Breadcrumb, Sidebar) are unaffected: their rules sit in the components
|
|
90
|
+
layer, which outranks base.
|
|
91
|
+
- Index's page-size Select renders through the new inline field layout
|
|
92
|
+
instead of an Index-owned flex override; no visual change.
|
|
16
93
|
|
|
17
94
|
### Added
|
|
18
95
|
|
|
96
|
+
- Inline field layout: `layout="inline"` on TextInput, Textarea, Select,
|
|
97
|
+
NumberField, DateInput, Combobox and TagsInput puts the label beside the
|
|
98
|
+
control on one row, with hint and errors wrapping below across both
|
|
99
|
+
columns. It sets `data-layout="inline"` on `.pui-field`, the attribute
|
|
100
|
+
Checkbox and Switch already carry, so one scaffold rule serves both DOM
|
|
101
|
+
orders; label association, `aria-describedby` and status recolour are
|
|
102
|
+
identical to the stacked layout. Checkbox and Switch stay inline-only and
|
|
103
|
+
RadioGroup stacked-only, so neither takes the prop.
|
|
104
|
+
- `pui-index__primary` and `pui-index__subline`: a two-line primary cell for
|
|
105
|
+
an Index `cell:<key>` slot, the title (usually the record link) over a
|
|
106
|
+
muted, smaller subline such as a slug.
|
|
107
|
+
- `pui-badge-list`: put it on any element wrapping several badges and they
|
|
108
|
+
wrap with a small gap. Loaded with Badge.
|
|
109
|
+
|
|
19
110
|
- `PageHeader` at `@pienter/ui/components/PageHeader.vue`: the title,
|
|
20
111
|
description and actions band above a module index or a record page,
|
|
21
112
|
extracted from Index so detail and form pages no longer hand-roll the same
|
package/CONVENTIONS.md
CHANGED
|
@@ -96,18 +96,19 @@ list, add a token to `0-settings/` rather than hardcoding.
|
|
|
96
96
|
|
|
97
97
|
Variants and runtime states live on `data-*` attributes on the block element, never on class names.
|
|
98
98
|
|
|
99
|
-
| Attribute | Purpose
|
|
100
|
-
| ------------------ |
|
|
101
|
-
| `data-variant` | Visual variant (mutually exclusive within a component)
|
|
102
|
-
| `data-size` | Sizing scale (mutually exclusive)
|
|
103
|
-
| `data-animation` | Continuous decorative animation on the block
|
|
104
|
-
| `data-tone` | Semantic tone for non-variant components (Alert, Badge, Toast)
|
|
105
|
-
| `data-block` | Block-level layout flag (boolean)
|
|
106
|
-
| `data-state` | Runtime state controlled by code or a behavior composable
|
|
107
|
-
| `data-status` | Derived semantic status for form primitives (validation outcome)
|
|
108
|
-
| `data-layout` | Form-primitive scaffold layout selector — lives on `.pui-field`;
|
|
109
|
-
| `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container
|
|
110
|
-
| `data-placement` | Where a CMS surface is mounted, which decides its chrome and heading level — lives on `.pui-index`
|
|
99
|
+
| Attribute | Purpose | Example values |
|
|
100
|
+
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
101
|
+
| `data-variant` | Visual variant (mutually exclusive within a component) | `primary`, `secondary`, `ghost`, `danger`, `link` |
|
|
102
|
+
| `data-size` | Sizing scale (mutually exclusive) | `sm`, `md` (Button/IconButton); `sm`/`md`/`lg` (Icon); other components add their own |
|
|
103
|
+
| `data-animation` | Continuous decorative animation on the block | `spin` (Icon) |
|
|
104
|
+
| `data-tone` | Semantic tone for non-variant components (Alert, Badge, Toast) | `brand`, `success`, `warning`, `danger` |
|
|
105
|
+
| `data-block` | Block-level layout flag (boolean) | `'true'` or absent |
|
|
106
|
+
| `data-state` | Runtime state controlled by code or a behavior composable | `loading`, `open`, `closed`, `checked`, `indeterminate` |
|
|
107
|
+
| `data-status` | Derived semantic status for form primitives (validation outcome) | `error`, `success` |
|
|
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
|
+
| `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
|
|
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
|
|
|
@@ -272,15 +273,17 @@ role="status">` with one `<li>` per error message, only when errors are
|
|
|
272
273
|
typed prop through. Order matters: bind `$attrs` first, then explicit
|
|
273
274
|
props — explicit bindings win, which is what we want.
|
|
274
275
|
|
|
275
|
-
- [ ]
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
276
|
+
- [ ] `data-layout="inline"` on the `pui-field` wrapper puts label and
|
|
277
|
+
control on one row in DOM order; the `pui-field__hint` elements stay
|
|
278
|
+
after them so a single CSS rule (`grid-column: 1 / -1`) drops them onto
|
|
279
|
+
the row below. Compact primitives (Switch, Checkbox) always render it,
|
|
280
|
+
with the control **first** in DOM order so the `<label for>`
|
|
281
|
+
association is read left-to-right. Label-first primitives (TextInput,
|
|
282
|
+
Textarea, Select, NumberField, DateInput, Combobox, TagsInput) expose
|
|
283
|
+
it as `layout?: 'stacked' | 'inline'` (default `stacked`, attribute
|
|
284
|
+
omitted). Everything else (label association via `for`/`id`,
|
|
285
|
+
hint/error rendering, ARIA wiring, `aria-describedby` joining,
|
|
286
|
+
status-driven recolour) stays unchanged in both layouts.
|
|
284
287
|
|
|
285
288
|
**Worked example — TextInput**: see `packages/ui/components/form/text-input/`. The
|
|
286
289
|
wrapper is `<div class="pui-field" data-status>`, the label is
|
|
@@ -1278,9 +1281,11 @@ follow the shape of an existing one.
|
|
|
1278
1281
|
| Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
|
|
1279
1282
|
| NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
|
|
1280
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) |
|
|
1281
1285
|
| Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
|
|
1282
1286
|
| Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
|
|
1283
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) |
|
|
1284
1289
|
| Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1285
1290
|
| Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
|
|
1286
1291
|
| Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
|
|
@@ -1463,6 +1468,13 @@ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
|
|
|
1463
1468
|
subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
|
|
1464
1469
|
form element. Callers expose the relevant tab when backend issues arrive.
|
|
1465
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
|
+
|
|
1466
1478
|
BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
|
|
1467
1479
|
for edits, addition, removal and reordering. Creation factories and slot content
|
|
1468
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);
|
|
@@ -51,9 +59,19 @@
|
|
|
51
59
|
border-color: var(--bg-clr-success, var(--border-clr-brand));
|
|
52
60
|
}
|
|
53
61
|
|
|
62
|
+
/* The label is the click target for the box, so it reads as one. */
|
|
63
|
+
.pui-field:has(.pui-checkbox) .pui-field__label {
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
user-select: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
/* Mute the label + cursor when the input is disabled. */
|
|
55
69
|
.pui-field:has(.pui-checkbox:disabled) .pui-field__label {
|
|
56
70
|
color: var(--text-clr-muted);
|
|
57
71
|
cursor: not-allowed;
|
|
58
72
|
}
|
|
73
|
+
|
|
74
|
+
.pui-field[data-readonly='true']:has(.pui-checkbox) .pui-field__label {
|
|
75
|
+
cursor: default;
|
|
76
|
+
}
|
|
59
77
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
ref="fieldRef"
|
|
4
|
+
class="pui-field"
|
|
5
|
+
:data-layout="layout === 'inline' ? 'inline' : undefined"
|
|
6
|
+
:data-readonly="readonly ? 'true' : undefined"
|
|
7
|
+
:data-status="computedStatus"
|
|
8
|
+
>
|
|
3
9
|
<label v-if="label" class="pui-field__label" :for="inputId"
|
|
4
10
|
>{{ label
|
|
5
11
|
}}<span
|
|
@@ -18,8 +24,9 @@
|
|
|
18
24
|
role="combobox"
|
|
19
25
|
:name="name"
|
|
20
26
|
:value="displayValue"
|
|
21
|
-
:placeholder="placeholder"
|
|
27
|
+
:placeholder="readonly ? undefined : placeholder"
|
|
22
28
|
:disabled="disabled"
|
|
29
|
+
:readonly="readonly"
|
|
23
30
|
:required="required"
|
|
24
31
|
:autocomplete="autocomplete"
|
|
25
32
|
:popovertarget="listboxId"
|
|
@@ -142,6 +149,8 @@ const props = withDefaults(
|
|
|
142
149
|
placeholder?: string;
|
|
143
150
|
/** Disables the input (and thus the popover). */
|
|
144
151
|
disabled?: boolean;
|
|
152
|
+
/** Read-only: native `readonly` on the input, still focusable, the listbox never opens. */
|
|
153
|
+
readonly?: boolean;
|
|
145
154
|
/** Marks the input as required for form submission. */
|
|
146
155
|
required?: boolean;
|
|
147
156
|
/** Native autocomplete value forwarded to the input. */
|
|
@@ -174,6 +183,8 @@ const props = withDefaults(
|
|
|
174
183
|
placement?: Placement;
|
|
175
184
|
/** Pixel offset between input and listbox; default `4`. */
|
|
176
185
|
offset?: number;
|
|
186
|
+
/** `inline` puts the label beside the control; hint and errors wrap below. */
|
|
187
|
+
layout?: 'stacked' | 'inline';
|
|
177
188
|
}>(),
|
|
178
189
|
{
|
|
179
190
|
id: undefined,
|
|
@@ -182,6 +193,7 @@ const props = withDefaults(
|
|
|
182
193
|
name: undefined,
|
|
183
194
|
placeholder: undefined,
|
|
184
195
|
disabled: false,
|
|
196
|
+
readonly: false,
|
|
185
197
|
required: false,
|
|
186
198
|
autocomplete: undefined,
|
|
187
199
|
hint: undefined,
|
|
@@ -191,6 +203,7 @@ const props = withDefaults(
|
|
|
191
203
|
emptyMessage: 'No results',
|
|
192
204
|
placement: 'bottom-start',
|
|
193
205
|
offset: 4,
|
|
206
|
+
layout: 'stacked',
|
|
194
207
|
},
|
|
195
208
|
);
|
|
196
209
|
|
|
@@ -302,7 +315,7 @@ const activeOptionDomId = computed(() => {
|
|
|
302
315
|
});
|
|
303
316
|
|
|
304
317
|
function open(): void {
|
|
305
|
-
if (props.disabled) return;
|
|
318
|
+
if (props.disabled || props.readonly) return;
|
|
306
319
|
control.show();
|
|
307
320
|
}
|
|
308
321
|
|
|
@@ -356,6 +369,7 @@ function selectOption(opt: ComboboxOption): void {
|
|
|
356
369
|
}
|
|
357
370
|
|
|
358
371
|
function onInput(event: Event): void {
|
|
372
|
+
if (props.readonly) return;
|
|
359
373
|
const target = event.target as HTMLInputElement;
|
|
360
374
|
query.value = target.value;
|
|
361
375
|
isTyping.value = true;
|
|
@@ -398,6 +412,7 @@ function onOptionMouseEnter(idx: number): void {
|
|
|
398
412
|
}
|
|
399
413
|
|
|
400
414
|
function onKeydown(event: KeyboardEvent): void {
|
|
415
|
+
if (props.readonly) return;
|
|
401
416
|
switch (event.key) {
|
|
402
417
|
case 'ArrowDown':
|
|
403
418
|
event.preventDefault();
|