@pienter/ui 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/CONVENTIONS.md +10 -0
  3. package/components/form/block-editor/BlockEditor.vue +61 -44
  4. package/components/form/block-editor/block-editor.css +11 -0
  5. package/components/form/checkbox/Checkbox.vue +23 -7
  6. package/components/form/checkbox/checkbox.css +12 -0
  7. package/components/form/combobox/Combobox.vue +9 -2
  8. package/components/form/date-input/DateInput.vue +5 -0
  9. package/components/form/form/Form.vue +5 -2
  10. package/components/form/number-field/NumberField.vue +8 -3
  11. package/components/form/number-field/number-field.css +6 -0
  12. package/components/form/radio-group/RadioGroup.vue +23 -1
  13. package/components/form/radio-group/radio-group.css +9 -0
  14. package/components/form/record-form/RecordFields.vue +40 -2
  15. package/components/form/record-form/RecordForm.vue +7 -1
  16. package/components/form/record-form/record-form.css +14 -0
  17. package/components/form/record-form/types.ts +6 -0
  18. package/components/form/select/Select.vue +32 -6
  19. package/components/form/select/select.css +5 -0
  20. package/components/form/switch/Switch.vue +23 -7
  21. package/components/form/switch/switch.css +8 -0
  22. package/components/form/tags-input/TagsInput.vue +11 -5
  23. package/components/form/tags-input/tags-input.css +5 -0
  24. package/components/form/text-input/TextInput.vue +6 -1
  25. package/components/form/textarea/Textarea.vue +6 -1
  26. package/components/form/textarea/textarea.css +7 -0
  27. package/components/layout/record-layout/Panel.vue +44 -0
  28. package/components/layout/record-layout/RecordLayout.vue +35 -0
  29. package/components/layout/record-layout/record-layout.css +94 -0
  30. package/components/navigation/tabs/tabs.css +2 -0
  31. package/package.json +3 -1
  32. package/styles/4-components/form-field.css +7 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,66 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.0 - 2026-09-15
6
+
7
+ ### Added
8
+
9
+ - View mode for `BlockEditor`: `readonly` renders the block list as content.
10
+ The drag handle, move, remove and add controls and the reorder hint go, every
11
+ mutation returns early, and `#block` receives `readonly` beside `disabled` so
12
+ the fields render read-only. The collapse toggles stay — they are the escape
13
+ hatch for a long list — and blocks start expanded. Each block title now sits
14
+ in a heading element around its collapse button, so heading navigation works
15
+ in both modes; `level` (2–6, default 3) picks it. A read-only editor carries
16
+ `data-readonly="true"`, and `Panel` keeps its border, background and padding
17
+ around one instead of dropping them as it does for an editable editor.
18
+ - `image` field type on `RecordFormField`: a URL `type="url"` TextInput while
19
+ editing, the image itself when read-only, capped so a full-size asset cannot
20
+ push the layout, with the same em dash as `RecordDetails` when the value is
21
+ empty. `altName` points at a sibling field in the same list for the
22
+ alternative text, so it works for nested and indexed paths.
23
+
24
+ ### Fixed
25
+
26
+ - A read-only `Textarea` grows with its value instead of clipping it inside a
27
+ box nobody can type in; `rows` becomes its minimum height.
28
+
29
+ ## 0.12.0 - 2026-09-15
30
+
31
+ ### Added
32
+
33
+ - View mode: `readonly` on `RecordForm` renders the same form read-only. The
34
+ flag fans out to `RecordFields` (and its `field:<name>` slot props) and to
35
+ every form primitive — `TextInput`, `Textarea`, `NumberField`, `DateInput`,
36
+ `Select`, `Combobox`, `Checkbox`, `Switch`, `RadioGroup` and `TagsInput`
37
+ gain a `readonly` prop. Native inputs get the `readonly` attribute; a
38
+ `select`, checkbox, switch or radio group gets `aria-readonly="true"` and
39
+ ignores changes; `NumberField` drops its steppers, `TagsInput` its remove
40
+ buttons, `Combobox` keeps its listbox closed. A read-only field is never
41
+ disabled: it stays in the tab order with its border, text colour and focus
42
+ ring, on the muted `--bg-clr-surface-2` surface, without a placeholder.
43
+ `.pui-field` carries `data-readonly="true"` for styling. `Form` also takes
44
+ `readonly`: it renders no actions row and never emits `submit`, Enter
45
+ included. Everything is off by default, so existing consumers are
46
+ unaffected.
47
+
48
+ - `RecordLayout` and `Panel`: the sidebar-plus-panels shape a record page is
49
+ built from. `RecordLayout` places a `sidebar` slot (an `aside` named through
50
+ `sidebar-labelledby`) beside a main column and stacks them below 56em.
51
+ `Panel` is one bordered section with its heading inside it (`title`,
52
+ `description`, `level`, `title-id`); it honours `hidden`, so it serves as a
53
+ tab panel as-is, and drops its border, background and padding when its
54
+ direct child is a `BlockEditor`. Both set `--pui-record-fields-columns` for
55
+ the RecordFields inside them — one column in the sidebar, two in a panel
56
+ from 48em with textarea fields spanning the row — and the sidebar stacks
57
+ RecordDetails labels over their values, so a consumer page no longer
58
+ carries any of that CSS.
59
+
60
+ ### Fixed
61
+
62
+ - `Tabs` wraps its buttons inside a narrow container instead of pushing the
63
+ tablist past the container's edge.
64
+
5
65
  ## 0.11.0 - 2026-09-15
6
66
 
7
67
  ### Added
package/CONVENTIONS.md CHANGED
@@ -108,6 +108,7 @@ Variants and runtime states live on `data-*` attributes on the block element, ne
108
108
  | `data-layout` | Form-primitive scaffold layout selector — lives on `.pui-field`; `inline` puts label and control on one row in DOM order, hint and errors below (see checklist) | `stacked` (default; attribute may be omitted), `inline` |
109
109
  | `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
110
110
  | `data-placement` | Where a CMS surface is mounted, which decides its chrome and heading level — lives on `.pui-index` | `module` (default), `related` |
111
+ | `data-readonly` | Read-only form primitive (boolean) — lives on `.pui-field` beside the control's native `readonly` / `aria-readonly`, so one selector styles every primitive | `'true'` or absent |
111
112
 
112
113
  The example values listed for each attribute are non-exhaustive — components add states as needed (e.g. `expanded`, `selected`, `active` for upcoming Disclosure, Tabs, Sidebar). New values follow the same kebab-case rule and live on the same attribute family.
113
114
 
@@ -1280,9 +1281,11 @@ follow the shape of an existing one.
1280
1281
  | Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
1281
1282
  | NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
1282
1283
  | Pagination | [`components/navigation/pagination/AUDIT.md`](./components/navigation/pagination/AUDIT.md) |
1284
+ | Panel | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
1283
1285
  | Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
1284
1286
  | Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
1285
1287
  | RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
1288
+ | RecordLayout | [`components/layout/record-layout/AUDIT.md`](./components/layout/record-layout/AUDIT.md) |
1286
1289
  | Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1287
1290
  | Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1288
1291
  | Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
@@ -1465,6 +1468,13 @@ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
1465
1468
  subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
1466
1469
  form element. Callers expose the relevant tab when backend issues arrive.
1467
1470
 
1471
+ RecordLayout and Panel give a record page its shape (`CONTEXT.md`): a sidebar
1472
+ `aside` named through `sidebar-labelledby` beside a main column that stacks
1473
+ below 56em, and one bordered section per field group or tab with its heading
1474
+ inside it. Panel honours `hidden`, so a consumer toggles tab panels with the
1475
+ attribute, and drops its chrome around a direct `BlockEditor` child. Both set
1476
+ `--pui-record-fields-columns` for the RecordFields inside them.
1477
+
1468
1478
  BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
1469
1479
  for edits, addition, removal and reordering. Creation factories and slot content
1470
1480
  stay caller-owned. Optional `v-model:collapsed` lets pages reveal invalid blocks.
@@ -18,13 +18,19 @@ const props = withDefaults(
18
18
  /** Optional controlled list of collapsed block ids. */
19
19
  collapsed?: readonly string[];
20
20
  disabled?: boolean;
21
+ /** View mode: the editing affordances go, the collapse toggles stay and the slot renders read-only. */
22
+ readonly?: boolean;
21
23
  label?: string;
24
+ /** Heading level of each block title: `3` sits under a level-2 `Panel` title. */
25
+ level?: 2 | 3 | 4 | 5 | 6;
22
26
  blockLabel?: (block: T, index: number) => string;
23
27
  }>(),
24
28
  {
25
29
  disabled: false,
30
+ readonly: false,
26
31
  collapsed: undefined,
27
32
  label: 'Content blocks',
33
+ level: 3,
28
34
  blockLabel: undefined,
29
35
  },
30
36
  );
@@ -39,12 +45,14 @@ defineSlots<{
39
45
  index: number;
40
46
  update: (patch: BlockPatch<T>) => void;
41
47
  disabled: boolean;
48
+ readonly: boolean;
42
49
  }) => unknown;
43
50
  summary?: (scope: { block: T; index: number }) => unknown;
44
51
  empty?: () => unknown;
45
52
  }>();
46
53
 
47
54
  const id = generateId('blocks');
55
+ const locked = computed(() => props.disabled || props.readonly);
48
56
  const localCollapsed = ref<string[]>([]);
49
57
  const collapsed = computed(
50
58
  () => new Set(props.collapsed ?? localCollapsed.value),
@@ -95,7 +103,7 @@ function focusBlock(key: string) {
95
103
  }
96
104
 
97
105
  function add(type: BlockType<T>) {
98
- if (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);
@@ -62,4 +70,8 @@
62
70
  color: var(--text-clr-muted);
63
71
  cursor: not-allowed;
64
72
  }
73
+
74
+ .pui-field[data-readonly='true']:has(.pui-checkbox) .pui-field__label {
75
+ cursor: default;
76
+ }
65
77
  }
@@ -3,6 +3,7 @@
3
3
  ref="fieldRef"
4
4
  class="pui-field"
5
5
  :data-layout="layout === 'inline' ? 'inline' : undefined"
6
+ :data-readonly="readonly ? 'true' : undefined"
6
7
  :data-status="computedStatus"
7
8
  >
8
9
  <label v-if="label" class="pui-field__label" :for="inputId"
@@ -23,8 +24,9 @@
23
24
  role="combobox"
24
25
  :name="name"
25
26
  :value="displayValue"
26
- :placeholder="placeholder"
27
+ :placeholder="readonly ? undefined : placeholder"
27
28
  :disabled="disabled"
29
+ :readonly="readonly"
28
30
  :required="required"
29
31
  :autocomplete="autocomplete"
30
32
  :popovertarget="listboxId"
@@ -147,6 +149,8 @@ const props = withDefaults(
147
149
  placeholder?: string;
148
150
  /** Disables the input (and thus the popover). */
149
151
  disabled?: boolean;
152
+ /** Read-only: native `readonly` on the input, still focusable, the listbox never opens. */
153
+ readonly?: boolean;
150
154
  /** Marks the input as required for form submission. */
151
155
  required?: boolean;
152
156
  /** Native autocomplete value forwarded to the input. */
@@ -189,6 +193,7 @@ const props = withDefaults(
189
193
  name: undefined,
190
194
  placeholder: undefined,
191
195
  disabled: false,
196
+ readonly: false,
192
197
  required: false,
193
198
  autocomplete: undefined,
194
199
  hint: undefined,
@@ -310,7 +315,7 @@ const activeOptionDomId = computed(() => {
310
315
  });
311
316
 
312
317
  function open(): void {
313
- if (props.disabled) return;
318
+ if (props.disabled || props.readonly) return;
314
319
  control.show();
315
320
  }
316
321
 
@@ -364,6 +369,7 @@ function selectOption(opt: ComboboxOption): void {
364
369
  }
365
370
 
366
371
  function onInput(event: Event): void {
372
+ if (props.readonly) return;
367
373
  const target = event.target as HTMLInputElement;
368
374
  query.value = target.value;
369
375
  isTyping.value = true;
@@ -406,6 +412,7 @@ function onOptionMouseEnter(idx: number): void {
406
412
  }
407
413
 
408
414
  function onKeydown(event: KeyboardEvent): void {
415
+ if (props.readonly) return;
409
416
  switch (event.key) {
410
417
  case 'ArrowDown':
411
418
  event.preventDefault();
@@ -2,6 +2,7 @@
2
2
  <div
3
3
  class="pui-field"
4
4
  :data-layout="layout === 'inline' ? 'inline' : undefined"
5
+ :data-readonly="readonly ? 'true' : undefined"
5
6
  :data-status="computedStatus"
6
7
  >
7
8
  <label class="pui-field__label" :for="inputId"
@@ -25,6 +26,7 @@
25
26
  :step="step"
26
27
  :required="required"
27
28
  :disabled="disabled"
29
+ :readonly="readonly"
28
30
  :aria-invalid="hasErrors ? 'true' : undefined"
29
31
  :aria-describedby="describedBy"
30
32
  @input="
@@ -84,6 +86,8 @@ const props = withDefaults(
84
86
  step?: number | string;
85
87
  required?: boolean;
86
88
  disabled?: boolean;
89
+ /** Read-only: the value shows in the field chrome, stays focusable and cannot be edited. */
90
+ readonly?: boolean;
87
91
  hint?: string;
88
92
  errors?: string[];
89
93
  status?: 'error' | 'success';
@@ -99,6 +103,7 @@ const props = withDefaults(
99
103
  step: undefined,
100
104
  required: false,
101
105
  disabled: false,
106
+ readonly: false,
102
107
  hint: undefined,
103
108
  errors: () => [],
104
109
  status: undefined,
@@ -22,7 +22,7 @@
22
22
  {{ statusMessage }}
23
23
  </div>
24
24
 
25
- <div class="pui-form__actions">
25
+ <div v-if="!readonly" class="pui-form__actions">
26
26
  <slot name="actions">
27
27
  <button
28
28
  class="pui-btn"
@@ -52,6 +52,8 @@ const props = withDefaults(
52
52
  defineProps<{
53
53
  submitLabel?: string;
54
54
  busy?: boolean;
55
+ /** Read-only presentation: renders no actions row and ignores every submit, Enter included. */
56
+ readonly?: boolean;
55
57
  /** Top-level errors not tied to a specific field. */
56
58
  errors?: string[];
57
59
  /** Server-side per-field errors. Consumers SHALL replace the object reference rather than mutate in place. */
@@ -64,6 +66,7 @@ const props = withDefaults(
64
66
  {
65
67
  submitLabel: 'Submit',
66
68
  busy: false,
69
+ readonly: false,
67
70
  errors: () => [],
68
71
  fieldErrors: () => ({}),
69
72
  statusMessage: undefined,
@@ -105,7 +108,7 @@ const allFieldErrors = computed<Record<string, string[]>>(() => {
105
108
 
106
109
  function handleSubmit(): void {
107
110
  const formEl = formRef.value;
108
- if (!formEl) return;
111
+ if (!formEl || props.readonly) return;
109
112
 
110
113
  const formData = new FormData(formEl);
111
114
 
@@ -2,6 +2,7 @@
2
2
  <div
3
3
  class="pui-field"
4
4
  :data-layout="layout === 'inline' ? 'inline' : undefined"
5
+ :data-readonly="readonly ? 'true' : undefined"
5
6
  :data-status="computedStatus"
6
7
  >
7
8
  <label class="pui-field__label" :for="inputId"
@@ -24,16 +25,17 @@
24
25
  :min="min"
25
26
  :max="max"
26
27
  :step="step"
27
- :placeholder="placeholder"
28
+ :placeholder="readonly ? undefined : placeholder"
28
29
  :required="required"
29
30
  :disabled="disabled"
31
+ :readonly="readonly"
30
32
  :value="modelValue"
31
33
  :aria-invalid="hasErrors ? 'true' : undefined"
32
34
  :aria-describedby="describedBy"
33
35
  @input="onInput"
34
36
  @blur="emit('blur')"
35
37
  />
36
- <div class="pui-number-field__steppers">
38
+ <div v-if="!readonly" class="pui-number-field__steppers">
37
39
  <button
38
40
  type="button"
39
41
  class="pui-number-field__step"
@@ -91,6 +93,8 @@ const props = withDefaults(
91
93
  placeholder?: string;
92
94
  required?: boolean;
93
95
  disabled?: boolean;
96
+ /** Read-only: the value shows in the field chrome without steppers, stays focusable and cannot be edited. */
97
+ readonly?: boolean;
94
98
  hint?: string;
95
99
  errors?: string[];
96
100
  status?: 'error' | 'success';
@@ -107,6 +111,7 @@ const props = withDefaults(
107
111
  placeholder: undefined,
108
112
  required: false,
109
113
  disabled: false,
114
+ readonly: false,
110
115
  hint: undefined,
111
116
  errors: () => [],
112
117
  status: undefined,
@@ -149,7 +154,7 @@ function onInput(event: Event): void {
149
154
  }
150
155
 
151
156
  function handleStep(direction: 'decrement' | 'increment'): void {
152
- if (props.disabled) return;
157
+ if (props.disabled || props.readonly) return;
153
158
  const input = inputRef.value;
154
159
  if (!input) return;
155
160
  if (direction === 'increment') {
@@ -42,6 +42,12 @@
42
42
  margin: 0;
43
43
  }
44
44
 
45
+ /* Read-only renders no stepper column, so the input keeps its own corners. */
46
+ .pui-field[data-readonly='true'] .pui-number-field__input {
47
+ border-top-right-radius: var(--radius-sm);
48
+ border-bottom-right-radius: var(--radius-sm);
49
+ }
50
+
45
51
  /* Stacked column for the +/- buttons. Sits flush against the
46
52
  * input's right edge; each button takes half the column height. */
47
53
  .pui-number-field__steppers {