@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 +46 -0
- package/CONVENTIONS.md +11 -0
- package/components/form/block-editor/BlockEditor.vue +137 -7
- package/components/form/record-form/RecordForm.vue +15 -1
- package/components/navigation/sidebar/Sidebar.vue +9 -0
- package/components/navigation/sidebar/SidebarMenuItem.vue +9 -8
- package/components/navigation/sidebar/context.ts +2 -0
- package/composables/useUnsavedChanges.ts +57 -0
- package/package.json +2 -1
- package/utils/cms/index.ts +30 -0
- package/utils/navigation/sidebar.ts +28 -1
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
|
|
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)
|
|
424
|
+
if (value) {
|
|
425
|
+
stopDrag();
|
|
426
|
+
grabbed.value = undefined;
|
|
427
|
+
}
|
|
317
428
|
});
|
|
318
|
-
watch(
|
|
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="
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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",
|
package/utils/cms/index.ts
CHANGED
|
@@ -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' };
|