@pienter/ui 0.20.1 → 0.22.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,52 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.22.0 - 2026-09-16
6
+ ### Added
7
+
8
+ - `RecordForm` tracks whether the record has unsaved edits. `baseline` is
9
+ the last saved state; without one the first `modelValue` the form sees
10
+ stands in, so a create form is clean until the first edit. `dirty` is a
11
+ structural comparison of `modelValue` against that baseline
12
+ (`isEqualRecord` in `utils/cms`, so key order does not matter), always
13
+ `false` in `readonly` mode, emitted as `update:dirty` for `v-model:dirty`
14
+ and exposed on the component instance. Consumers set `baseline` to a clone
15
+ of the model after load and after a successful save, before navigating.
16
+ - `useUnsavedChanges(dirty)` (`@pienter/ui/composables/useUnsavedChanges`)
17
+ turns that flag into a leave guard: while dirty, `beforeunload` has the
18
+ browser confirm a reload or tab close, and a vue-router `onBeforeRouteLeave`
19
+ guard asks through `confirmLeave()`, which resolves `true` at once when
20
+ clean and otherwise raises `pending` until `resolve(leave)` answers it. The
21
+ consumer binds `pending` to an `AlertDialog` and answers with
22
+ `resolve(true)` on confirm, `resolve(false)` on cancel; a second question
23
+ while one is pending shares its promise. Requires the optional `vue-router`
24
+ peer dependency, like `useUrlTab`.
25
+ ## 0.21.1 - 2026-09-16
26
+ ### Fixed
27
+
28
+ - `Sidebar` marks one entry `aria-current="page"` instead of every entry on
29
+ the current path. On `/pages/create` the "All pages" link (`/pages`), its
30
+ group row and "New page" (`/pages/create`) all reported themselves as the
31
+ current page, so a screen reader heard three current pages and both links
32
+ wore the brand wash. The hierarchical match is unchanged — it still
33
+ drives a group's `data-active` and expand-on-mount — but `aria-current` now
34
+ goes only to the longest matching href, and when a group row and one of its
35
+ child links share that href the link wins.
36
+
37
+ ## 0.21.0 - 2026-09-16
38
+
39
+ ### Added
40
+
41
+ - `BlockEditor`'s drag handle is keyboard-operable and back in the tab
42
+ order. Space or Enter grabs a block (`aria-pressed`, `data-state="grabbed"`),
43
+ ArrowUp and ArrowDown move it one step through the same reorder the move
44
+ buttons use, Space or Enter drops it and Escape returns it to where it was
45
+ grabbed; blur while grabbed cancels too. Every step emits
46
+ `update:modelValue`, so the page sees the block move as it does with the
47
+ move buttons, and each grab, step, drop and cancel is announced through the
48
+ shared live region. The default `hint` now describes the keyboard pattern.
49
+ Pointer dragging is unchanged.
50
+
5
51
  ## 0.20.1 - 2026-09-16
6
52
 
7
53
  ### Fixed
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
@@ -39,7 +39,7 @@ const props = withDefaults(
39
39
  level: 3,
40
40
  blockLabel: undefined,
41
41
  addLabel: (typeLabel: string) => `Add ${typeLabel}`,
42
- hint: 'Use the move buttons to reorder, or drag with a pointer.',
42
+ hint: 'Use the move buttons to reorder, or drag with a pointer. On a drag handle, Space grabs the block, the arrow keys move it, Space drops it and Escape cancels.',
43
43
  emptyText: 'No blocks yet. Add a block to start writing.',
44
44
  },
45
45
  );
@@ -68,6 +68,9 @@ const collapsed = computed(
68
68
  );
69
69
  const dragging = ref<string>();
70
70
  const dropIndex = ref(0);
71
+ const grabbed = ref<string>();
72
+ let grabbedFrom = 0;
73
+ let stepping = false;
71
74
  const items = new Map<string, HTMLElement>();
72
75
  let gesture:
73
76
  | {
@@ -160,7 +163,7 @@ function remove(key: string) {
160
163
  announce(`${title(block, index)} removed. ${remaining.length} blocks.`);
161
164
  }
162
165
 
163
- function move(key: string, target: number) {
166
+ function reorder(key: string, target: number) {
164
167
  if (locked.value) return;
165
168
  const source = props.modelValue.findIndex((block) => block.id === key);
166
169
  if (
@@ -174,12 +177,116 @@ function move(key: string, target: number) {
174
177
  const block = blocks.splice(source, 1)[0]!;
175
178
  blocks.splice(target, 0, block);
176
179
  emit('update:modelValue', blocks);
180
+ return { block, source, blocks };
181
+ }
182
+
183
+ function move(key: string, target: number) {
184
+ const moved = reorder(key, target);
185
+ if (!moved) return;
177
186
  focusBlock(key);
178
187
  announce(
179
- `${title(block, source)} moved to position ${target + 1} of ${blocks.length}.`,
188
+ `${title(moved.block, moved.source)} moved to position ${target + 1} of ${moved.blocks.length}.`,
189
+ );
190
+ }
191
+
192
+ function focusHandle(key: string) {
193
+ items
194
+ .get(key)
195
+ ?.querySelector<HTMLButtonElement>('.pui-block-editor__drag')
196
+ ?.focus({ preventScroll: true });
197
+ }
198
+
199
+ function position(key: string) {
200
+ const index = props.modelValue.findIndex((block) => block.id === key);
201
+ const block = props.modelValue[index];
202
+ return block
203
+ ? {
204
+ index,
205
+ name: title(block, index),
206
+ place: `position ${index + 1} of ${props.modelValue.length}`,
207
+ }
208
+ : undefined;
209
+ }
210
+
211
+ function grab(key: string) {
212
+ const at = position(key);
213
+ if (!at || props.modelValue.length < 2) return;
214
+ grabbed.value = key;
215
+ grabbedFrom = at.index;
216
+ announce(
217
+ `Grabbed ${at.name}, ${at.place}. Use arrow keys to move, Space to drop, Escape to cancel.`,
180
218
  );
181
219
  }
182
220
 
221
+ function step(key: string, delta: -1 | 1) {
222
+ const at = position(key);
223
+ if (!at) return;
224
+ const target = at.index + delta;
225
+ if (target < 0 || target >= props.modelValue.length) {
226
+ announce(
227
+ `${at.name} cannot move further ${delta < 0 ? 'up' : 'down'}.`,
228
+ );
229
+ return;
230
+ }
231
+ // Vue moves the `<li>` in the DOM, which blurs the handle; that blur is not a cancel.
232
+ stepping = true;
233
+ reorder(key, target);
234
+ void nextTick(() => {
235
+ focusHandle(key);
236
+ stepping = false;
237
+ });
238
+ announce(
239
+ `${at.name} moved to position ${target + 1} of ${props.modelValue.length}.`,
240
+ );
241
+ }
242
+
243
+ function drop(key: string) {
244
+ const at = position(key);
245
+ grabbed.value = undefined;
246
+ if (at) announce(`Dropped ${at.name}, ${at.place}.`);
247
+ }
248
+
249
+ function cancelGrab() {
250
+ const key = grabbed.value;
251
+ if (!key) return;
252
+ grabbed.value = undefined;
253
+ stepping = true;
254
+ reorder(key, grabbedFrom);
255
+ void nextTick(() => {
256
+ stepping = false;
257
+ });
258
+ announce('Reordering cancelled.');
259
+ }
260
+
261
+ function handleKey(event: KeyboardEvent, key: string) {
262
+ if (locked.value) return;
263
+ const held = grabbed.value === key;
264
+ switch (event.key) {
265
+ case ' ':
266
+ case 'Enter':
267
+ event.preventDefault();
268
+ if (held) drop(key);
269
+ else grab(key);
270
+ return;
271
+ case 'ArrowUp':
272
+ case 'ArrowDown':
273
+ if (!held) return;
274
+ event.preventDefault();
275
+ step(key, event.key === 'ArrowUp' ? -1 : 1);
276
+ return;
277
+ case 'Escape':
278
+ if (!held) return;
279
+ event.preventDefault();
280
+ event.stopPropagation();
281
+ cancelGrab();
282
+ return;
283
+ }
284
+ }
285
+
286
+ function handleBlur(key: string) {
287
+ if (!stepping && grabbed.value === key) cancelGrab();
288
+ }
289
+
183
290
  function toggle(key: string) {
184
291
  if (props.disabled) return;
185
292
  const next = new Set(collapsed.value);
@@ -240,6 +347,7 @@ function startDrag(event: PointerEvent, key: string) {
240
347
  if (locked.value || event.button !== 0 || props.modelValue.length < 2)
241
348
  return;
242
349
  stopDrag();
350
+ grabbed.value = undefined;
243
351
  const handle = event.currentTarget as HTMLButtonElement;
244
352
  handle.focus({ preventScroll: true });
245
353
  gesture = {
@@ -313,9 +421,22 @@ function cancelWithEscape(event: KeyboardEvent) {
313
421
  }
314
422
 
315
423
  watch(locked, (value) => {
316
- if (value) stopDrag();
424
+ if (value) {
425
+ stopDrag();
426
+ grabbed.value = undefined;
427
+ }
317
428
  });
318
- watch(() => props.modelValue.map((block) => block.id).join('\u0000'), stopDrag);
429
+ watch(
430
+ () => props.modelValue.map((block) => block.id).join('\u0000'),
431
+ () => {
432
+ stopDrag();
433
+ if (
434
+ grabbed.value &&
435
+ !props.modelValue.some((block) => block.id === grabbed.value)
436
+ )
437
+ grabbed.value = undefined;
438
+ },
439
+ );
319
440
  onBeforeUnmount(stopDrag);
320
441
  </script>
321
442
 
@@ -343,7 +464,11 @@ onBeforeUnmount(stopDrag);
343
464
  "
344
465
  class="pui-block-editor__item"
345
466
  :data-block-id="block.id"
346
- :data-dragging="dragging === block.id ? 'true' : undefined"
467
+ :data-dragging="
468
+ dragging === block.id || grabbed === block.id
469
+ ? 'true'
470
+ : undefined
471
+ "
347
472
  :data-drop="indicator(block.id)"
348
473
  >
349
474
  <div class="pui-block-editor__header">
@@ -351,11 +476,16 @@ onBeforeUnmount(stopDrag);
351
476
  v-if="!readonly"
352
477
  class="pui-block-editor__drag"
353
478
  type="button"
354
- tabindex="-1"
355
479
  :disabled="disabled || modelValue.length < 2"
356
480
  :aria-label="`Drag ${title(block, index)}`"
357
481
  :aria-describedby="`${id}-hint`"
482
+ :aria-pressed="grabbed === block.id ? 'true' : 'false'"
483
+ :data-state="
484
+ grabbed === block.id ? 'grabbed' : undefined
485
+ "
358
486
  @pointerdown="startDrag($event, block.id)"
487
+ @keydown="handleKey($event, block.id)"
488
+ @blur="handleBlur(block.id)"
359
489
  >
360
490
  <Icon name="arrow-up-down" size="sm" />
361
491
  </button>
@@ -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.20.1",
3
+ "version": "0.22.0",
4
4
  "description": "Shared Pienter UI components, styles, icons, and browser utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,6 +92,7 @@
92
92
  "./composables/usePopover": "./composables/usePopover.ts",
93
93
  "./composables/useUrlSort": "./composables/useUrlSort.ts",
94
94
  "./composables/useUrlTab": "./composables/useUrlTab.ts",
95
+ "./composables/useUnsavedChanges": "./composables/useUnsavedChanges.ts",
95
96
  "./icons": "./icons/index.ts",
96
97
  "./icons/*": "./icons/*",
97
98
  "./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' };