@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONVENTIONS.md +33 -21
  3. package/components/display/badge/badge.css +7 -0
  4. package/components/form/block-editor/BlockEditor.vue +61 -44
  5. package/components/form/block-editor/block-editor.css +11 -0
  6. package/components/form/checkbox/Checkbox.vue +23 -7
  7. package/components/form/checkbox/checkbox.css +18 -0
  8. package/components/form/combobox/Combobox.vue +18 -3
  9. package/components/form/date-input/DateInput.vue +13 -1
  10. package/components/form/form/Form.vue +5 -2
  11. package/components/form/number-field/NumberField.vue +16 -4
  12. package/components/form/number-field/number-field.css +6 -0
  13. package/components/form/radio-group/RadioGroup.vue +23 -1
  14. package/components/form/radio-group/radio-group.css +9 -0
  15. package/components/form/record-form/RecordFields.vue +40 -2
  16. package/components/form/record-form/RecordForm.vue +7 -1
  17. package/components/form/record-form/record-form.css +14 -0
  18. package/components/form/record-form/types.ts +6 -0
  19. package/components/form/select/Select.vue +40 -7
  20. package/components/form/select/select.css +5 -0
  21. package/components/form/switch/Switch.vue +23 -7
  22. package/components/form/switch/switch.css +14 -0
  23. package/components/form/tags-input/TagsInput.vue +19 -6
  24. package/components/form/tags-input/tags-input.css +5 -0
  25. package/components/form/text-input/TextInput.vue +14 -2
  26. package/components/form/textarea/Textarea.vue +14 -2
  27. package/components/form/textarea/textarea.css +7 -0
  28. package/components/layout/index/Index.vue +1 -0
  29. package/components/layout/index/index.css +8 -7
  30. package/components/layout/record-layout/Panel.vue +44 -0
  31. package/components/layout/record-layout/RecordLayout.vue +35 -0
  32. package/components/layout/record-layout/record-layout.css +94 -0
  33. package/components/navigation/tabs/tabs.css +2 -0
  34. package/package.json +3 -1
  35. package/styles/2-base/base.css +6 -1
  36. 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 | 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`; flips between stacked label-above (default) and compact inline control-left + label-right | `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` |
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
- - [ ] Compact form primitives (Switch, Checkbox, individual Radio items) use
276
- `data-layout="inline"` on the `pui-field` wrapper to flip the layout to
277
- control-left + label-right. Everything else (label association via
278
- `for`/`id`, hint/error rendering, ARIA wiring, `aria-describedby`
279
- joining, status-driven recolour) stays unchanged. The DOM source order
280
- under `data-layout="inline"` is **control first, label second** so the
281
- `<label for>` association is read in left-to-right order, and the
282
- `pui-field__hint` elements remain after the label so a single CSS rule
283
- (`grid-column: 1 / -1`) drops them onto a row below the control + label.
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.
@@ -53,4 +53,11 @@
53
53
  border-color: transparent;
54
54
  }
55
55
  }
56
+
57
+ /* A wrapped group of badges (tags, labels). */
58
+ .pui-badge-list {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ gap: var(--space-2xs);
62
+ }
56
63
  }
@@ -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 (props.disabled) return;
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 (props.disabled) return;
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 (props.disabled) return;
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 (props.disabled) return;
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 (props.disabled || event.button !== 0 || props.modelValue.length < 2)
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
- () => props.disabled,
300
- (disabled) => {
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
- <button
348
- type="button"
349
- class="pui-block-editor__toggle"
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
- <Icon
360
- :name="
361
- collapsed.has(block.id)
362
- ? 'chevron-right'
363
- : 'chevron-down'
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
- size="sm"
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
- </button>
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
- >No blocks yet. Add a block to start writing.</slot
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 class="pui-field" data-layout="inline" :data-status="computedStatus">
2
+ <div
3
+ class="pui-field"
4
+ data-layout="inline"
5
+ :data-readonly="readonly ? 'true' : undefined"
6
+ :data-status="computedStatus"
7
+ >
3
8
  <input
4
9
  v-bind="$attrs"
5
10
  :id="inputId"
@@ -10,14 +15,11 @@
10
15
  :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
- @change="
16
- emit(
17
- 'update:modelValue',
18
- ($event.target as HTMLInputElement).checked,
19
- )
20
- "
21
+ @click="onClick"
22
+ @change="onChange"
21
23
  />
22
24
  <label class="pui-field__label" :for="inputId"
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 ref="fieldRef" class="pui-field" :data-status="computedStatus">
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();