@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 +30 -0
- package/CONVENTIONS.md +5 -1
- package/components/action/select-button/SelectButton.vue +71 -0
- package/components/action/select-button/select-button.css +45 -0
- package/components/form/record-form/RecordFields.vue +52 -0
- package/components/form/record-form/types.ts +16 -0
- package/icons/index.ts +2 -0
- package/icons/shopping-cart.ts +1 -0
- package/package.json +2 -1
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.
|
|
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",
|