@pienter/ui 0.22.0 → 0.24.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 CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.24.0 - 2026-09-16
6
+ ### Added
7
+
8
+ - `RecordForm` and `RecordFields` gain two built-in field types, so a price
9
+ or a sale window no longer needs a `#field:name` slot repeating label,
10
+ hint, error and id wiring by hand. `{ type: 'number', min?, max?, step?,
11
+ placeholder?, steppers? }` renders `NumberField`; only finite numbers
12
+ display, and clearing the input writes `null` into the record rather than
13
+ `NaN`, since the payload is JSON. `{ type: 'date' | 'datetime-local' |
14
+ 'time', min?, max?, step? }` renders `DateInput` with the ISO string shapes
15
+ it documents. Both honour `busy`, `disabled`, `required` and `readonly`
16
+ like every other built-in and can still be replaced through their slot.
17
+ - `shopping-cart` icon, so a commerce module or webshop entry no longer has
18
+ to borrow `layers`.
19
+
20
+ ## 0.23.0 - 2026-09-16
21
+ ### Added
22
+
23
+ - `SelectButton` (`@pienter/ui/components/SelectButton.vue`), a native
24
+ `<select>` dressed as a Button for chrome such as a header toolbar: `label`
25
+ (required, becomes the `aria-label` and is never rendered, as on
26
+ `IconButton`), `options`, `modelValue` with `update:modelValue`, `name`,
27
+ `disabled`, and Button's own `variant` and `size` unions and defaults, so
28
+ a `sm` `ghost` picker sits beside a `sm` `ghost` Button at the same height,
29
+ padding, colours and focus ring. The root is a `.pui-btn` mix and the
30
+ select lies invisibly over it, so the whole button opens the native picker
31
+ and keyboard and screen-reader behaviour stay the browser's. The form
32
+ `Select` is deliberately unchanged: it keeps no `size` prop and no hidden
33
+ label, so forms stay one consistent scaffold.
34
+
5
35
  ## 0.22.0 - 2026-09-16
6
36
  ### Added
7
37
 
package/CONVENTIONS.md CHANGED
@@ -1305,6 +1305,7 @@ follow the shape of an existing one.
1305
1305
  | RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
1306
1306
  | Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1307
1307
  | Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1308
+ | SelectButton | [`components/action/select-button/AUDIT.md`](./components/action/select-button/AUDIT.md) |
1308
1309
  | Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
1309
1310
  | Sheet | [`components/overlay/sheet/AUDIT.md`](./components/overlay/sheet/AUDIT.md) |
1310
1311
  | Sidebar | [`components/navigation/sidebar/AUDIT.md`](./components/navigation/sidebar/AUDIT.md) |
@@ -1544,7 +1545,10 @@ and request state. Index's compact row spacing uses
1544
1545
  RecordForm emits detached JSON snapshots, preserving every supplied property.
1545
1546
  Consumers construct an explicit writable object from backend records. Typed path
1546
1547
  segments identify nested fields and array indices; fields display issues for
1547
- their path and descendants. The form summary retains unmatched issues.
1548
+ their path and descendants. The form summary retains unmatched issues. Built-in
1549
+ field types map one to one onto the form primitives: text, email, textarea,
1550
+ select, checkbox, image, number (NumberField, cleared writes `null`) and
1551
+ date, datetime-local and time (DateInput, ISO strings).
1548
1552
  `--pui-record-form-columns` customizes the field grid, defaulting to one column.
1549
1553
 
1550
1554
  RecordForm's `fields` slot replaces its default RecordFields rendering for
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <span
3
+ class="pui-btn pui-select-btn"
4
+ :data-variant="variant"
5
+ :data-size="size"
6
+ >
7
+ <span class="pui-select-btn__label" aria-hidden="true">
8
+ <span
9
+ v-for="opt in options"
10
+ :key="opt.value"
11
+ class="pui-select-btn__option"
12
+ :data-state="opt.value === modelValue ? 'selected' : undefined"
13
+ >{{ opt.label }}</span
14
+ >
15
+ </span>
16
+ <span class="pui-btn__icon" aria-hidden="true">
17
+ <Icon name="chevron-down" size="sm" />
18
+ </span>
19
+ <select
20
+ v-bind="$attrs"
21
+ class="pui-select-btn__control"
22
+ :name="name"
23
+ :disabled="disabled"
24
+ :value="modelValue"
25
+ :aria-label="label"
26
+ @change="onChange"
27
+ >
28
+ <option v-for="opt in options" :key="opt.value" :value="opt.value">
29
+ {{ opt.label }}
30
+ </option>
31
+ </select>
32
+ </span>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import Icon from '../../display/icon/Icon.vue';
37
+
38
+ defineOptions({ inheritAttrs: false });
39
+
40
+ withDefaults(
41
+ defineProps<{
42
+ /** Accessible name for the control (required — the label is never rendered). */
43
+ label: string;
44
+ modelValue?: string;
45
+ options: { value: string; label: string }[];
46
+ name?: string;
47
+ disabled?: boolean;
48
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
49
+ size?: 'sm' | 'md';
50
+ }>(),
51
+ {
52
+ modelValue: undefined,
53
+ name: undefined,
54
+ disabled: false,
55
+ variant: 'secondary',
56
+ size: 'md',
57
+ },
58
+ );
59
+
60
+ const emit = defineEmits<{
61
+ 'update:modelValue': [value: string];
62
+ }>();
63
+
64
+ function onChange(event: Event): void {
65
+ emit('update:modelValue', (event.target as HTMLSelectElement).value);
66
+ }
67
+ </script>
68
+
69
+ <style>
70
+ @import './select-button.css';
71
+ </style>
@@ -0,0 +1,45 @@
1
+ @layer components {
2
+ /* The block is a `.pui-btn` mix: button.css draws the box, this file only
3
+ lays the native select over it as the hit area and focus target. */
4
+ .pui-select-btn {
5
+ /* the select is transparent, so the wrapper wears its ring */
6
+ &:has(.pui-select-btn__control:focus-visible) {
7
+ outline: var(--outline-width) solid
8
+ var(--btn-ring, var(--border-clr-strong));
9
+ outline-offset: var(--outline-offset);
10
+ }
11
+
12
+ /* a span never matches :disabled; no pointer events keeps the shared hover and active rules off */
13
+ &:has(.pui-select-btn__control:disabled) {
14
+ opacity: var(--opacity-disabled);
15
+ pointer-events: none;
16
+ }
17
+
18
+ /* every option label stacked in one cell holds the widest width, as a native select does */
19
+ .pui-select-btn__label {
20
+ display: inline-grid;
21
+ }
22
+
23
+ .pui-select-btn__option {
24
+ grid-area: 1 / 1;
25
+ visibility: hidden;
26
+
27
+ &[data-state='selected'] {
28
+ visibility: visible;
29
+ }
30
+ }
31
+
32
+ .pui-select-btn__control {
33
+ position: absolute;
34
+ inset: 0;
35
+ inline-size: 100%;
36
+ block-size: 100%;
37
+ margin: 0;
38
+ padding: 0;
39
+ border: 0;
40
+ appearance: none;
41
+ opacity: 0;
42
+ cursor: pointer;
43
+ }
44
+ }
45
+ }
@@ -8,6 +8,8 @@ import TextInput from '../text-input/TextInput.vue';
8
8
  import PuiTextarea from '../textarea/Textarea.vue';
9
9
  import PuiSelect from '../select/Select.vue';
10
10
  import Checkbox from '../checkbox/Checkbox.vue';
11
+ import NumberField from '../number-field/NumberField.vue';
12
+ import DateInput from '../date-input/DateInput.vue';
11
13
  import {
12
14
  readPath,
13
15
  updatePath,
@@ -46,6 +48,21 @@ function textFor(field: RecordFormField): string {
46
48
  const value = valueFor(field);
47
49
  return typeof value === 'string' ? value : '';
48
50
  }
51
+ function numberFor(field: RecordFormField): number | undefined {
52
+ const value = valueFor(field);
53
+ return typeof value === 'number' && Number.isFinite(value)
54
+ ? value
55
+ : undefined;
56
+ }
57
+ function isDateType(
58
+ field: RecordFormField,
59
+ ): field is RecordFormField & { type: 'date' | 'datetime-local' | 'time' } {
60
+ return (
61
+ field.type === 'date' ||
62
+ field.type === 'datetime-local' ||
63
+ field.type === 'time'
64
+ );
65
+ }
49
66
  function altFor(field: RecordFormField): string {
50
67
  const name = field.type === 'image' ? field.altName : undefined;
51
68
  if (!name) return '';
@@ -116,6 +133,41 @@ function update(field: RecordFormField, value: unknown): void {
116
133
  :errors="errors"
117
134
  @update:model-value="update(field, $event)"
118
135
  />
136
+ <NumberField
137
+ v-else-if="field.type === 'number'"
138
+ :label="field.label"
139
+ :name="JSON.stringify(pathFor(field))"
140
+ :model-value="numberFor(field)"
141
+ :min="field.min"
142
+ :max="field.max"
143
+ :step="field.step"
144
+ :steppers="field.steppers"
145
+ :hint="field.hint"
146
+ :required="field.required"
147
+ :disabled="busy || field.disabled"
148
+ :readonly="readonly"
149
+ :placeholder="field.placeholder"
150
+ :errors="errors"
151
+ @update:model-value="
152
+ update(field, Number.isNaN($event) ? null : $event)
153
+ "
154
+ />
155
+ <DateInput
156
+ v-else-if="isDateType(field)"
157
+ :label="field.label"
158
+ :name="JSON.stringify(pathFor(field))"
159
+ :model-value="textFor(field)"
160
+ :type="field.type"
161
+ :min="field.min"
162
+ :max="field.max"
163
+ :step="field.step"
164
+ :hint="field.hint"
165
+ :required="field.required"
166
+ :disabled="busy || field.disabled"
167
+ :readonly="readonly"
168
+ :errors="errors"
169
+ @update:model-value="update(field, $event)"
170
+ />
119
171
  <template v-else-if="field.type === 'image'">
120
172
  <div v-if="readonly" class="pui-field" data-readonly="true">
121
173
  <span class="pui-field__label">{{ field.label }}</span>
@@ -30,5 +30,21 @@ export type RecordFormField = RecordFieldBase &
30
30
  altName?: string;
31
31
  }
32
32
  | { type: 'checkbox' }
33
+ | {
34
+ type: 'number';
35
+ min?: number;
36
+ max?: number;
37
+ step?: number | string;
38
+ placeholder?: string;
39
+ /** NumberField's +/− buttons; on unless set to `false`. */
40
+ steppers?: boolean;
41
+ }
42
+ | {
43
+ type: 'date' | 'datetime-local' | 'time';
44
+ /** Bounds in the ISO shape of the value, as on DateInput. */
45
+ min?: string;
46
+ max?: string;
47
+ step?: number | string;
48
+ }
33
49
  | { type: 'custom' }
34
50
  );
package/icons/index.ts CHANGED
@@ -43,6 +43,7 @@ import tool from './tool.js';
43
43
  import trash from './trash.js';
44
44
  import sidebarLeft from './sidebar-left.js';
45
45
  import sidebarRight from './sidebar-right.js';
46
+ import shoppingCart from './shopping-cart.js';
46
47
 
47
48
  export const icons = {
48
49
  info: info,
@@ -90,6 +91,7 @@ export const icons = {
90
91
  trash: trash,
91
92
  'sidebar-left': sidebarLeft,
92
93
  'sidebar-right': sidebarRight,
94
+ 'shopping-cart': shoppingCart,
93
95
  } as const;
94
96
 
95
97
  export type IconName = keyof typeof icons;
@@ -0,0 +1 @@
1
+ export default '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="var(--pui-icon-clr, currentColor)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/></svg>';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Shared Pienter UI components, styles, icons, and browser utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "./styles/*": "./styles/*",
30
30
  "./components/Button.vue": "./components/action/button/Button.vue",
31
31
  "./components/IconButton.vue": "./components/action/button/IconButton.vue",
32
+ "./components/SelectButton.vue": "./components/action/select-button/SelectButton.vue",
32
33
  "./components/Toggle.vue": "./components/action/toggle/Toggle.vue",
33
34
  "./components/ToggleGroup.vue": "./components/action/toggle-group/ToggleGroup.vue",
34
35
  "./components/Avatar.vue": "./components/display/avatar/Avatar.vue",