@pienter/ui 0.21.0 → 0.23.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,53 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.23.0 - 2026-09-16
6
+ ### Added
7
+
8
+ - `SelectButton` (`@pienter/ui/components/SelectButton.vue`), a native
9
+ `<select>` dressed as a Button for chrome such as a header toolbar: `label`
10
+ (required, becomes the `aria-label` and is never rendered, as on
11
+ `IconButton`), `options`, `modelValue` with `update:modelValue`, `name`,
12
+ `disabled`, and Button's own `variant` and `size` unions and defaults, so
13
+ a `sm` `ghost` picker sits beside a `sm` `ghost` Button at the same height,
14
+ padding, colours and focus ring. The root is a `.pui-btn` mix and the
15
+ select lies invisibly over it, so the whole button opens the native picker
16
+ and keyboard and screen-reader behaviour stay the browser's. The form
17
+ `Select` is deliberately unchanged: it keeps no `size` prop and no hidden
18
+ label, so forms stay one consistent scaffold.
19
+
20
+ ## 0.22.0 - 2026-09-16
21
+ ### Added
22
+
23
+ - `RecordForm` tracks whether the record has unsaved edits. `baseline` is
24
+ the last saved state; without one the first `modelValue` the form sees
25
+ stands in, so a create form is clean until the first edit. `dirty` is a
26
+ structural comparison of `modelValue` against that baseline
27
+ (`isEqualRecord` in `utils/cms`, so key order does not matter), always
28
+ `false` in `readonly` mode, emitted as `update:dirty` for `v-model:dirty`
29
+ and exposed on the component instance. Consumers set `baseline` to a clone
30
+ of the model after load and after a successful save, before navigating.
31
+ - `useUnsavedChanges(dirty)` (`@pienter/ui/composables/useUnsavedChanges`)
32
+ turns that flag into a leave guard: while dirty, `beforeunload` has the
33
+ browser confirm a reload or tab close, and a vue-router `onBeforeRouteLeave`
34
+ guard asks through `confirmLeave()`, which resolves `true` at once when
35
+ clean and otherwise raises `pending` until `resolve(leave)` answers it. The
36
+ consumer binds `pending` to an `AlertDialog` and answers with
37
+ `resolve(true)` on confirm, `resolve(false)` on cancel; a second question
38
+ while one is pending shares its promise. Requires the optional `vue-router`
39
+ peer dependency, like `useUrlTab`.
40
+ ## 0.21.1 - 2026-09-16
41
+ ### Fixed
42
+
43
+ - `Sidebar` marks one entry `aria-current="page"` instead of every entry on
44
+ the current path. On `/pages/create` the "All pages" link (`/pages`), its
45
+ group row and "New page" (`/pages/create`) all reported themselves as the
46
+ current page, so a screen reader heard three current pages and both links
47
+ wore the brand wash. The hierarchical match is unchanged — it still
48
+ drives a group's `data-active` and expand-on-mount — but `aria-current` now
49
+ goes only to the longest matching href, and when a group row and one of its
50
+ child links share that href the link wins.
51
+
5
52
  ## 0.21.0 - 2026-09-16
6
53
 
7
54
  ### Added
package/CONVENTIONS.md CHANGED
@@ -503,6 +503,17 @@ isActive(activeHref, itemHref):
503
503
  descendant items (recursively) is active. Sidebar uses this to expand
504
504
  the matching group on mount and to highlight the group header.
505
505
 
506
+ **Rendering rule** (2026-09-16, issue #54): the match algorithm above is
507
+ unchanged and still answers "is this item on the current page's path?"
508
+ for every item. What it does _not_ decide is which entry renders
509
+ `aria-current="page"`: that goes to the single longest matching href
510
+ across the whole tree (`resolveCurrentHref`), so on `/pages/create` only
511
+ "New page" (`/pages/create`) is current, while "All pages" (`/pages`) and
512
+ its group keep their hierarchical match for `data-active` and
513
+ expand-on-mount. When a group's own `href` and one of its direct child
514
+ links share that longest href, the link is current and the group toggle
515
+ is not.
516
+
506
517
  **Edge cases — out of scope for v1**, documented for future:
507
518
 
508
519
  - Trailing slashes are NOT normalized (`/admin/members/` vs
@@ -1294,6 +1305,7 @@ follow the shape of an existing one.
1294
1305
  | RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
1295
1306
  | Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1296
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) |
1297
1309
  | Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
1298
1310
  | Sheet | [`components/overlay/sheet/AUDIT.md`](./components/overlay/sheet/AUDIT.md) |
1299
1311
  | Sidebar | [`components/navigation/sidebar/AUDIT.md`](./components/navigation/sidebar/AUDIT.md) |
@@ -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
+ }
@@ -3,12 +3,13 @@ export type { RecordFormField } from './types.js';
3
3
  </script>
4
4
 
5
5
  <script setup lang="ts" generic="T extends object">
6
- import { computed } from 'vue';
6
+ import { computed, watch } from 'vue';
7
7
  import Button from '../../action/button/Button.vue';
8
8
  import Form from '../form/Form.vue';
9
9
  import RecordFields from './RecordFields.vue';
10
10
  import {
11
11
  cloneRecord,
12
+ isEqualRecord,
12
13
  type ValidationIssue,
13
14
  type ValuePath,
14
15
  } from '../../../utils/cms/index.js';
@@ -19,6 +20,8 @@ const props = withDefaults(
19
20
  defineProps<{
20
21
  /** Caller-owned write payload; every supplied property is retained. */
21
22
  modelValue: T;
23
+ /** Last saved state `dirty` compares against; defaults to the first `modelValue` seen. */
24
+ baseline?: T;
22
25
  fields: readonly RecordFormField[];
23
26
  issues?: readonly ValidationIssue[];
24
27
  errors?: string[];
@@ -29,6 +32,7 @@ const props = withDefaults(
29
32
  statusMessage?: string;
30
33
  }>(),
31
34
  {
35
+ baseline: undefined,
32
36
  issues: () => [],
33
37
  errors: () => [],
34
38
  busy: false,
@@ -40,10 +44,20 @@ const props = withDefaults(
40
44
 
41
45
  const emit = defineEmits<{
42
46
  'update:modelValue': [value: T];
47
+ 'update:dirty': [value: boolean];
43
48
  'field-change': [path: ValuePath];
44
49
  submit: [value: T];
45
50
  }>();
46
51
 
52
+ const initial = cloneRecord(props.modelValue);
53
+ const dirty = computed(
54
+ () =>
55
+ !props.readonly &&
56
+ !isEqualRecord(props.modelValue, props.baseline ?? initial),
57
+ );
58
+ watch(dirty, (value) => emit('update:dirty', value), { immediate: true });
59
+ defineExpose({ dirty });
60
+
47
61
  const fieldErrors = computed(() =>
48
62
  Object.fromEntries(
49
63
  props.fields.map((field) => [
@@ -70,6 +70,7 @@ import {
70
70
  computeSubmenuPosition,
71
71
  deriveGroupKey,
72
72
  isSubmenuActive,
73
+ resolveCurrentHref,
73
74
  SUBMENU_CLOSE_DELAY,
74
75
  type SubmenuPosition,
75
76
  } from '../../../utils/navigation/sidebar.js';
@@ -266,8 +267,16 @@ function onEntryEnter(topLevelKey: string | null): void {
266
267
  if (!pinned.value) scheduleClose();
267
268
  }
268
269
 
270
+ const currentHref = computed(() =>
271
+ resolveCurrentHref(
272
+ [...props.topItems, ...props.bottomItems],
273
+ props.activeHref,
274
+ ),
275
+ );
276
+
269
277
  provide(SIDEBAR_CONTEXT, {
270
278
  activeHref: computed(() => props.activeHref),
279
+ currentHref,
271
280
  expandedKeys,
272
281
  openKey,
273
282
  submenuActive,
@@ -110,7 +110,6 @@ import { generateId } from '../../../utils/a11y/id.js';
110
110
  import {
111
111
  deriveGroupKey,
112
112
  groupContainsActive,
113
- isHrefActive,
114
113
  } from '../../../utils/navigation/sidebar.js';
115
114
  import { SIDEBAR_CONTEXT } from './context.js';
116
115
  import type { SidebarItem } from './types.js';
@@ -144,13 +143,15 @@ const ownKey = computed(() =>
144
143
  );
145
144
 
146
145
  const isCurrent = computed(() => {
147
- const href =
148
- props.item.type === 'link'
149
- ? props.item.href
150
- : props.item.type === 'group'
151
- ? props.item.href
152
- : undefined;
153
- return href !== undefined && isHrefActive(ctx.activeHref.value, href);
146
+ if (props.item.type === 'section') return false;
147
+ const href = props.item.href;
148
+ if (href === undefined || href !== ctx.currentHref.value) return false;
149
+ // A group whose own child link repeats its href: the link is the page.
150
+ if (props.item.type === 'group')
151
+ return !props.item.items.some(
152
+ (child) => child.type === 'link' && child.href === href,
153
+ );
154
+ return true;
154
155
  });
155
156
 
156
157
  const containsCurrent = computed(
@@ -4,6 +4,8 @@ import type { SubmenuPosition } from '../../../utils/navigation/sidebar.js';
4
4
  /** What `Sidebar.vue` hands down to the recursive `SidebarMenuItem`. */
5
5
  export interface SidebarContext {
6
6
  activeHref: ComputedRef<string | null>;
7
+ /** The one href that renders `aria-current`; see `resolveCurrentHref`. */
8
+ currentHref: ComputedRef<string | undefined>;
7
9
  expandedKeys: Ref<Set<string>>;
8
10
  /** Group key whose collapsed-rail flyout is open, or null. */
9
11
  openKey: Ref<string | null>;
@@ -0,0 +1,57 @@
1
+ import { ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
2
+ import { onBeforeRouteLeave } from 'vue-router';
3
+ import { useWindowListener } from './useEventListener.js';
4
+
5
+ export interface UnsavedChanges {
6
+ /** `true` while a leave is waiting on an answer. Bind to `AlertDialog`'s `open`. */
7
+ pending: Ref<boolean>;
8
+ /** Resolves `true` at once when clean; otherwise asks and resolves with the answer. */
9
+ confirmLeave: () => Promise<boolean>;
10
+ /** Answers the pending question: `resolve(true)` on confirm, `resolve(false)` on cancel. */
11
+ resolve: (leave: boolean) => void;
12
+ }
13
+
14
+ /**
15
+ * Guards a dirty form against leaving unsaved. Bind `dirty` to `RecordForm`'s
16
+ * `v-model:dirty`. While dirty, a `beforeunload` listener has the browser confirm
17
+ * a reload or tab close, and a route-leave guard asks through `confirmLeave()`,
18
+ * which the consumer answers from an `AlertDialog` bound to `pending` and
19
+ * `resolve`. One question at a time: a second `confirmLeave()` while pending
20
+ * returns the same promise. Requires the optional `vue-router` peer dependency.
21
+ */
22
+ export function useUnsavedChanges(
23
+ dirty: MaybeRefOrGetter<boolean>,
24
+ ): UnsavedChanges {
25
+ const pending = ref(false);
26
+ let question: {
27
+ promise: Promise<boolean>;
28
+ settle: (leave: boolean) => void;
29
+ } | null = null;
30
+
31
+ useWindowListener('beforeunload', (event) => {
32
+ if (toValue(dirty)) event.preventDefault();
33
+ });
34
+
35
+ function confirmLeave(): Promise<boolean> {
36
+ if (!toValue(dirty)) return Promise.resolve(true);
37
+ if (question) return question.promise;
38
+ let settle!: (leave: boolean) => void;
39
+ const promise = new Promise<boolean>((resolve) => {
40
+ settle = resolve;
41
+ });
42
+ question = { promise, settle };
43
+ pending.value = true;
44
+ return promise;
45
+ }
46
+
47
+ function resolve(leave: boolean): void {
48
+ const current = question;
49
+ question = null;
50
+ pending.value = false;
51
+ current?.settle(leave);
52
+ }
53
+
54
+ onBeforeRouteLeave(() => confirmLeave());
55
+
56
+ return { pending, confirmLeave, resolve };
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pienter/ui",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
@@ -92,6 +93,7 @@
92
93
  "./composables/usePopover": "./composables/usePopover.ts",
93
94
  "./composables/useUrlSort": "./composables/useUrlSort.ts",
94
95
  "./composables/useUrlTab": "./composables/useUrlTab.ts",
96
+ "./composables/useUnsavedChanges": "./composables/useUnsavedChanges.ts",
95
97
  "./icons": "./icons/index.ts",
96
98
  "./icons/*": "./icons/*",
97
99
  "./utils": "./utils/index.ts",
@@ -222,6 +222,36 @@ export function cloneRecord<T>(record: T): T {
222
222
  return JSON.parse(JSON.stringify(record)) as T;
223
223
  }
224
224
 
225
+ /** Structural equality of two JSON-shaped records; key order and `undefined` properties do not count. */
226
+ export function isEqualRecord(a: unknown, b: unknown): boolean {
227
+ if (a === b) return true;
228
+ if (
229
+ a === null ||
230
+ b === null ||
231
+ typeof a !== 'object' ||
232
+ typeof b !== 'object' ||
233
+ Array.isArray(a) !== Array.isArray(b)
234
+ )
235
+ return false;
236
+ if (Array.isArray(a) && Array.isArray(b))
237
+ return (
238
+ a.length === b.length &&
239
+ a.every((item, index) => isEqualRecord(item, b[index]))
240
+ );
241
+ const left = a as Record<string, unknown>;
242
+ const right = b as Record<string, unknown>;
243
+ const keys = Object.keys(left).filter((key) => left[key] !== undefined);
244
+ const other = Object.keys(right).filter((key) => right[key] !== undefined);
245
+ return (
246
+ keys.length === other.length &&
247
+ keys.every(
248
+ (key) =>
249
+ Object.hasOwn(right, key) &&
250
+ isEqualRecord(left[key], right[key]),
251
+ )
252
+ );
253
+ }
254
+
225
255
  export function readPath(record: unknown, path: ValuePath): unknown {
226
256
  let value = record;
227
257
  for (const key of path) {
@@ -90,8 +90,35 @@ export function groupContainsActive(
90
90
  });
91
91
  }
92
92
 
93
+ /**
94
+ * The single href that renders `aria-current="page"`: the longest one the
95
+ * current page matches, so `/pages/create` lights up "New page" and not
96
+ * "All pages" (`/pages`) as well. See CONVENTIONS.md § Rendering rule.
97
+ */
98
+ export function resolveCurrentHref(
99
+ items: readonly SidebarItemLike[],
100
+ activeHref: string | null | undefined,
101
+ ): string | undefined {
102
+ let current: string | undefined;
103
+ const visit = (list: readonly SidebarItemLike[]): void => {
104
+ for (const item of list) {
105
+ if (item.type === 'section') continue;
106
+ const href = item.href;
107
+ if (
108
+ href !== undefined &&
109
+ isHrefActive(activeHref, href) &&
110
+ (current === undefined || href.length > current.length)
111
+ )
112
+ current = href;
113
+ if (item.type === 'group') visit(item.items);
114
+ }
115
+ };
116
+ visit(items);
117
+ return current;
118
+ }
119
+
93
120
  /** The part of `SidebarItem` these primitives read; avoids a component import. */
94
121
  export type SidebarItemLike =
95
122
  | { type: 'link'; href: string }
96
- | { type: 'group'; items: readonly SidebarItemLike[] }
123
+ | { type: 'group'; href?: string; items: readonly SidebarItemLike[] }
97
124
  | { type: 'section' };