@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 +47 -0
- package/CONVENTIONS.md +12 -0
- package/components/action/select-button/SelectButton.vue +71 -0
- package/components/action/select-button/select-button.css +45 -0
- 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 +3 -1
- package/utils/cms/index.ts +30 -0
- package/utils/navigation/sidebar.ts +28 -1
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
|
-
|
|
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.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",
|
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' };
|