@takazudo/zudo-doc 6.0.2 → 6.1.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 +10 -0
- package/dist/sidebar-tree-island/index.js +10 -16
- package/dist/sidebar-tree-island/scope-state.d.ts +10 -7
- package/dist/sidebar-tree-island/scope-state.js +57 -36
- package/dist/sidebar-utils/index.d.ts +6 -0
- package/dist/sidebar-utils/index.js +15 -12
- package/eject/sidebar-tree-island/index.tsx +10 -16
- package/eject/sidebar-tree-island/scope-state.ts +83 -40
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,16 @@ The format is based on Keep a Changelog, and release notes are generated from th
|
|
|
8
8
|
|
|
9
9
|
No unreleased changes.
|
|
10
10
|
|
|
11
|
+
## [6.1.0] - 2026-10-12
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
- Adds `SidebarDefinition.initialPathMode?: "children" | "node"` (default `"children"`, unchanged behavior). With `"node"`, the node selected by `initialPath` is itself the fresh/Restore baseline: a page-backed section keeps its linked overview row together with its pages, while the complete editorial tree stays available, so Broaden moves to its real parent with no duplicate or invented row. Applies equally to `buildSidebarForSection` and `buildSidebarNavigation`, and to the desktop sidebar and mobile drawer. An unknown mode fails the build. (6fc2200a4)
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
- The sidebar now saves only the reader's explicit expand/collapse choices in `sessionStorage`. Automatically opening the current page's path on soft navigation, Back/Forward or at narrow viewports is no longer persisted, so later hard loads no longer show unrelated branches expanded. The reader can collapse the branch containing the current page; it stays collapsed for that page (hash changes, filtering and same-page reload included) and the next page reveals its own path. Restore clears the selected scope and saved choices, keeps the filter text, and updates the desktop sidebar and mobile drawer together. Expansion saved by earlier versions is discarded once on first load; a saved scope and filter text are kept. (98d1d223b, 1bbaa5f26, fbd694422)
|
|
20
|
+
|
|
11
21
|
## [6.0.2] - 2026-10-12
|
|
12
22
|
|
|
13
23
|
### Other Changes
|
|
@@ -555,7 +555,7 @@ function TrayGroupNode({
|
|
|
555
555
|
() => group.value.items.some((item) => item.slug === currentSlug.value)
|
|
556
556
|
);
|
|
557
557
|
const occurrenceKey = computed(() => `${trayOccurrence.value}#${group.value.key}`);
|
|
558
|
-
const open = signal(scopeControls
|
|
558
|
+
const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items)) : containsCurrent.value);
|
|
559
559
|
const storageKey = computed(
|
|
560
560
|
() => noteTrayGroupStorageKey(traySlug.value, group.value.key)
|
|
561
561
|
);
|
|
@@ -566,7 +566,7 @@ function TrayGroupNode({
|
|
|
566
566
|
if (!scopeControls && getOpenSet().has(storageKey.value)) open.value = true;
|
|
567
567
|
});
|
|
568
568
|
scope.effect(() => {
|
|
569
|
-
if (containsCurrent.value) open.value = true;
|
|
569
|
+
if (!scopeControls && containsCurrent.value) open.value = true;
|
|
570
570
|
});
|
|
571
571
|
scope.effect(() => {
|
|
572
572
|
if (!scopeControls && open.value) {
|
|
@@ -578,20 +578,18 @@ function TrayGroupNode({
|
|
|
578
578
|
}
|
|
579
579
|
});
|
|
580
580
|
const toggle = () => {
|
|
581
|
-
open.value = !open.value;
|
|
582
581
|
if (scopeControls) {
|
|
583
|
-
scopeControls.
|
|
584
|
-
scopeControls.save();
|
|
582
|
+
scopeControls.setOpen(occurrenceKey.value, !open.value);
|
|
585
583
|
return;
|
|
586
584
|
}
|
|
585
|
+
open.value = !open.value;
|
|
587
586
|
const stored = getOpenSet();
|
|
588
587
|
if (open.value) stored.add(storageKey.value);
|
|
589
588
|
else stored.delete(storageKey.value);
|
|
590
589
|
saveOpenSet(stored);
|
|
591
590
|
};
|
|
592
591
|
scope.effect(() => {
|
|
593
|
-
|
|
594
|
-
if (scopeControls) open.value = stored ?? scopeControls.defaultGroupOpen(group.value.items);
|
|
592
|
+
if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items));
|
|
595
593
|
});
|
|
596
594
|
const expanded = computed(() => forceOpen.value || open.value);
|
|
597
595
|
const items = computed(() => group.value.items);
|
|
@@ -711,15 +709,13 @@ function CategoryNode({
|
|
|
711
709
|
);
|
|
712
710
|
const active = computed(() => node.value.slug === currentSlug.value);
|
|
713
711
|
const occurrenceKey = () => node.value.occurrenceId ?? node.value.slug;
|
|
714
|
-
const open = signal(scopeControls
|
|
712
|
+
const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value)) : containsCurrent.value || !node.value.collapsed);
|
|
715
713
|
scope.onActivate(() => {
|
|
716
714
|
if (!scopeControls && getOpenSet().has(node.value.slug)) open.value = true;
|
|
717
715
|
});
|
|
718
716
|
scope.effect(() => {
|
|
719
717
|
const slug = currentSlug.value;
|
|
720
|
-
|
|
721
|
-
if (slug !== void 0 && subtreeContainsSlug(node.value, slug)) open.value = true;
|
|
722
|
-
void revision;
|
|
718
|
+
if (!scopeControls && slug !== void 0 && subtreeContainsSlug(node.value, slug)) open.value = true;
|
|
723
719
|
});
|
|
724
720
|
scope.effect(() => {
|
|
725
721
|
if (!scopeControls && open.value) {
|
|
@@ -731,20 +727,18 @@ function CategoryNode({
|
|
|
731
727
|
}
|
|
732
728
|
});
|
|
733
729
|
const toggle = () => {
|
|
734
|
-
open.value = !open.value;
|
|
735
730
|
if (scopeControls) {
|
|
736
|
-
scopeControls.
|
|
737
|
-
scopeControls.save();
|
|
731
|
+
scopeControls.setOpen(occurrenceKey(), !open.value);
|
|
738
732
|
return;
|
|
739
733
|
}
|
|
734
|
+
open.value = !open.value;
|
|
740
735
|
const stored = getOpenSet();
|
|
741
736
|
if (open.value) stored.add(node.value.slug);
|
|
742
737
|
else stored.delete(node.value.slug);
|
|
743
738
|
saveOpenSet(stored);
|
|
744
739
|
};
|
|
745
740
|
scope.effect(() => {
|
|
746
|
-
|
|
747
|
-
if (scopeControls) open.value = stored ?? scopeControls.defaultOpen(node.value);
|
|
741
|
+
if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value));
|
|
748
742
|
});
|
|
749
743
|
const expanded = computed(() => forceOpen.value || open.value);
|
|
750
744
|
const focusButton = scopeControls ? /* @__PURE__ */ jsx(
|
|
@@ -3,16 +3,19 @@ import type { SidebarNavNode, SidebarNavigationContext } from "../sidebar/types.
|
|
|
3
3
|
export interface ScopeControls {
|
|
4
4
|
focus: (node: SidebarNavNode) => void;
|
|
5
5
|
focusLabel: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
save: () => void;
|
|
6
|
+
/** Effective disclosure: the user's explicit choice, else revealed/scope root, else `baseline`. */
|
|
7
|
+
isOpen: (key: string, baseline: boolean) => boolean;
|
|
8
|
+
/** Records an explicit user disclosure choice. */
|
|
9
|
+
setOpen: (key: string, open: boolean) => void;
|
|
11
10
|
defaultOpen: (node: SidebarNavNode) => boolean;
|
|
12
11
|
defaultGroupOpen: (items: SidebarNavNode[]) => boolean;
|
|
13
12
|
}
|
|
14
13
|
/** One controller shared by the real desktop tree and mobile drawer. SSR always
|
|
15
|
-
* starts with configured roots; session state is applied only on activation.
|
|
14
|
+
* starts with configured roots; session state is applied only on activation.
|
|
15
|
+
* `expansion` holds explicit user choices only. The active path and the
|
|
16
|
+
* visible scope's sole root are derived, other scope-change reveal stays in
|
|
17
|
+
* memory, and a reveal removes conflicting explicit collapses instead of
|
|
18
|
+
* writing automatic entries. */
|
|
16
19
|
export declare function useSidebarScope({ nodes, navigation, activeSlug, query, locale }: {
|
|
17
20
|
nodes: SidebarNavNode[];
|
|
18
21
|
navigation?: SidebarNavigationContext;
|
|
@@ -32,7 +35,7 @@ export declare function useSidebarScope({ nodes, navigation, activeSlug, query,
|
|
|
32
35
|
};
|
|
33
36
|
canRestore: ReadonlySignal<boolean>;
|
|
34
37
|
changeScope: (id: string | null) => void;
|
|
35
|
-
save: () => void;
|
|
38
|
+
save: (broadcast?: boolean) => void;
|
|
36
39
|
navRef: Ref<HTMLElement>;
|
|
37
40
|
scopeControls: ScopeControls | undefined;
|
|
38
41
|
};
|
|
@@ -2,10 +2,12 @@ import { computed, getScope, signal } from "@takazudo/zfb/zudo-react";
|
|
|
2
2
|
import { broaderSidebarScope, sidebarScopeNodes, reconcileSidebarScope, indexSidebarOccurrences, sidebarInitialExpansion, sidebarBaselineOpen, SIDEBAR_FOREST_SCOPE } from "../sidebar-utils/index.js";
|
|
3
3
|
import { getNoteTrayItems, groupItems } from "../note-tray-model/index.js";
|
|
4
4
|
import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
5
|
+
const STATE_VERSION = 2;
|
|
6
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
7
|
function useSidebarScope({ nodes, navigation, activeSlug, query, locale }) {
|
|
6
8
|
const selected = signal(null);
|
|
7
9
|
const expansion = signal({});
|
|
8
|
-
const
|
|
10
|
+
const revealed = signal(/* @__PURE__ */ new Set());
|
|
9
11
|
let lastPage = activeSlug.value;
|
|
10
12
|
const navRef = { current: null };
|
|
11
13
|
const stateKey = "zd-sidebar-scope";
|
|
@@ -30,47 +32,59 @@ function useSidebarScope({ nodes, navigation, activeSlug, query, locale }) {
|
|
|
30
32
|
const groupKeys = new Set(trayGroups.map((group) => group.key));
|
|
31
33
|
const defaultOpen = (node) => sidebarBaselineOpen(node, navigation ? sidebarInitialExpansion(navigation, activeSlug.value) : void 0, activeSlug.value);
|
|
32
34
|
const defaultGroupOpen = (items) => items.some((item) => item.slug === activeSlug.value) || !!navigation && sidebarInitialExpansion(navigation, activeSlug.value) === "expanded";
|
|
35
|
+
const baselineOf = (key) => {
|
|
36
|
+
const node = occurrenceIndex.get(key);
|
|
37
|
+
if (node) return node.children.length ? defaultOpen(node) : void 0;
|
|
38
|
+
const group = trayGroups.find((candidate) => candidate.key === key);
|
|
39
|
+
return group ? defaultGroupOpen(group.items) : void 0;
|
|
40
|
+
};
|
|
41
|
+
const scopeRoot = computed(() => selectedNodes.value.length === 1 ? selectedNodes.value[0].occurrenceId : void 0);
|
|
42
|
+
const automaticOpen = (key, baseline) => revealed.value.has(key) || key === scopeRoot.value || baseline;
|
|
33
43
|
const canRestore = computed(() => {
|
|
34
44
|
if (JSON.stringify(selectedNodes.value) !== JSON.stringify(nodes)) return true;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
const differs = Object.entries(expansion.value).some(([key, open]) => {
|
|
46
|
+
const baseline = baselineOf(key);
|
|
47
|
+
return baseline !== void 0 && open !== automaticOpen(key, baseline);
|
|
48
|
+
});
|
|
49
|
+
return differs || [...revealed.value].some((key) => expansion.value[key] === void 0 && baselineOf(key) === false);
|
|
39
50
|
});
|
|
40
|
-
const
|
|
41
|
-
|
|
51
|
+
const canonicalScope = (id) => id !== null && navigation && (nodes.length === 1 && nodes[0].occurrenceId === id || JSON.stringify(sidebarScopeNodes(navigation, nodes, id)) === JSON.stringify(nodes)) ? null : id;
|
|
52
|
+
const withAncestors = (id) => {
|
|
53
|
+
const chain = [];
|
|
54
|
+
for (let ancestor = id; ancestor && ancestor !== SIDEBAR_FOREST_SCOPE; ancestor = navigation?.parents[ancestor] ?? null) chain.push(ancestor);
|
|
55
|
+
return chain;
|
|
56
|
+
};
|
|
57
|
+
const withoutCollapses = (keys) => {
|
|
42
58
|
const next = { ...expansion.value };
|
|
43
|
-
for (const [key
|
|
44
|
-
if (node.slug !== activeSlug.value && key !== id) continue;
|
|
45
|
-
let ancestor = key;
|
|
46
|
-
while (ancestor) {
|
|
47
|
-
next[ancestor] = true;
|
|
48
|
-
ancestor = navigation.parents[ancestor];
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
for (const group of trayGroups) {
|
|
52
|
-
if (group.items.some((item) => item.slug === activeSlug.value)) next[group.key] = true;
|
|
53
|
-
}
|
|
59
|
+
for (const key of keys) if (next[key] === false) delete next[key];
|
|
54
60
|
expansion.value = next;
|
|
55
|
-
reveal.value++;
|
|
56
61
|
};
|
|
57
|
-
const
|
|
62
|
+
const arrive = () => {
|
|
63
|
+
revealed.value = /* @__PURE__ */ new Set();
|
|
64
|
+
withoutCollapses([
|
|
65
|
+
...[...occurrenceIndex].filter(([, node]) => node.slug === activeSlug.value).flatMap(([key]) => withAncestors(key)),
|
|
66
|
+
...trayGroups.filter((group) => group.items.some((item) => item.slug === activeSlug.value)).map((group) => group.key),
|
|
67
|
+
...scopeRoot.value ? [scopeRoot.value] : []
|
|
68
|
+
]);
|
|
69
|
+
};
|
|
70
|
+
const save = (broadcast = true) => {
|
|
58
71
|
if (!navigation) return;
|
|
72
|
+
const state = { version: STATE_VERSION, context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value };
|
|
59
73
|
try {
|
|
60
|
-
sessionStorage.setItem(stateKey, JSON.stringify(
|
|
74
|
+
sessionStorage.setItem(stateKey, JSON.stringify(state));
|
|
61
75
|
} catch {
|
|
62
76
|
}
|
|
63
|
-
document.dispatchEvent(new CustomEvent(syncEvent, { detail: {
|
|
77
|
+
if (broadcast) document.dispatchEvent(new CustomEvent(syncEvent, { detail: { ...state, revealed: [...revealed.value] } }));
|
|
64
78
|
};
|
|
65
79
|
const changeScope = (id) => {
|
|
66
80
|
const previous = selected.value;
|
|
67
|
-
selected.value =
|
|
81
|
+
selected.value = canonicalScope(id);
|
|
68
82
|
if (id === null) {
|
|
69
83
|
expansion.value = {};
|
|
70
|
-
|
|
84
|
+
revealed.value = /* @__PURE__ */ new Set();
|
|
71
85
|
} else {
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
withoutCollapses(withAncestors(id));
|
|
87
|
+
revealed.value = /* @__PURE__ */ new Set([...revealed.value, ...withAncestors(previous), ...withAncestors(id)]);
|
|
74
88
|
}
|
|
75
89
|
save();
|
|
76
90
|
queueMicrotask(() => {
|
|
@@ -101,40 +115,47 @@ function useSidebarScope({ nodes, navigation, activeSlug, query, locale }) {
|
|
|
101
115
|
if (node.occurrenceId) changeScope(node.occurrenceId);
|
|
102
116
|
},
|
|
103
117
|
focusLabel: labels.focus,
|
|
104
|
-
expansion,
|
|
105
|
-
reveal,
|
|
106
118
|
defaultOpen,
|
|
107
119
|
defaultGroupOpen,
|
|
108
|
-
|
|
120
|
+
isOpen: (key, baseline) => expansion.value[key] ?? automaticOpen(key, baseline),
|
|
121
|
+
setOpen: (key, open) => {
|
|
122
|
+
expansion.value = { ...expansion.value, [key]: open };
|
|
123
|
+
save();
|
|
124
|
+
}
|
|
109
125
|
} : void 0;
|
|
110
126
|
getScope().onActivate(() => {
|
|
111
127
|
if (!navigation) return;
|
|
112
128
|
lastPage = activeSlug.value;
|
|
129
|
+
let samePage = false;
|
|
113
130
|
try {
|
|
114
131
|
const stored = JSON.parse(sessionStorage.getItem(stateKey) ?? "null");
|
|
115
|
-
if (stored
|
|
132
|
+
if (isRecord(stored) && stored.context === navigation.id) {
|
|
116
133
|
const candidate = typeof stored.selected === "string" ? stored.selected : null;
|
|
117
134
|
const valid = candidate === SIDEBAR_FOREST_SCOPE || candidate !== null && occurrenceIndex.has(candidate) ? candidate : null;
|
|
118
|
-
|
|
135
|
+
samePage = stored.page === activeSlug.value;
|
|
136
|
+
selected.value = canonicalScope(samePage ? valid : reconcileSidebarScope(navigation, nodes, valid, activeSlug.value));
|
|
119
137
|
query.value = typeof stored.query === "string" ? stored.query : "";
|
|
120
|
-
if (stored.
|
|
121
|
-
|
|
138
|
+
if (stored.version === STATE_VERSION && isRecord(stored.expansion)) {
|
|
139
|
+
expansion.value = Object.fromEntries(Object.entries(stored.expansion).filter(([id, value]) => (occurrenceIndex.has(id) || groupKeys.has(id)) && typeof value === "boolean"));
|
|
140
|
+
}
|
|
122
141
|
}
|
|
123
142
|
} catch {
|
|
124
143
|
}
|
|
125
|
-
|
|
144
|
+
if (!samePage) arrive();
|
|
145
|
+
save(false);
|
|
126
146
|
const sync = (event) => {
|
|
127
147
|
const state = event.detail;
|
|
128
148
|
if (state?.context !== navigation.id || state.page !== activeSlug.value) return;
|
|
129
149
|
selected.value = state.selected;
|
|
130
150
|
query.value = state.query;
|
|
131
151
|
expansion.value = state.expansion;
|
|
152
|
+
revealed.value = new Set(state.revealed);
|
|
132
153
|
};
|
|
133
154
|
const navigate = () => {
|
|
134
155
|
if (lastPage === activeSlug.value) return;
|
|
135
156
|
lastPage = activeSlug.value;
|
|
136
|
-
selected.value = reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value);
|
|
137
|
-
|
|
157
|
+
selected.value = canonicalScope(reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value));
|
|
158
|
+
arrive();
|
|
138
159
|
save();
|
|
139
160
|
};
|
|
140
161
|
document.addEventListener(syncEvent, sync);
|
|
@@ -30,6 +30,12 @@ export interface SidebarDefinition {
|
|
|
30
30
|
items: SidebarItem[];
|
|
31
31
|
/** Resolved occurrence indexes from the forest to a category (not URL segments). */
|
|
32
32
|
initialPath?: number[];
|
|
33
|
+
/**
|
|
34
|
+
* What `initialPath` selects as the fresh/Restore baseline: the target's
|
|
35
|
+
* `"children"`, or the target `"node"` itself (its row, link and children).
|
|
36
|
+
* @default "children"
|
|
37
|
+
*/
|
|
38
|
+
initialPathMode?: "children" | "node";
|
|
33
39
|
/** Share across sections using the same editorial tree. Locale/version are added by chrome. */
|
|
34
40
|
contextId?: string;
|
|
35
41
|
}
|
|
@@ -82,12 +82,19 @@ function buildSidebarForSection(docs, lang, categoryMatch, categoryMeta, sidebar
|
|
|
82
82
|
return filterByCategory(tree, categoryMatch, explicitCategoryPrefixes);
|
|
83
83
|
}
|
|
84
84
|
if (Array.isArray(config)) return resolveItems(config, tree, lang);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
return followInitialPath(resolveItems(config.items, tree, lang), config, sectionKey).nodes;
|
|
86
|
+
}
|
|
87
|
+
function followInitialPath(forest, definition, sectionKey) {
|
|
88
|
+
const mode = definition?.initialPathMode;
|
|
89
|
+
if (mode !== void 0 && mode !== "children" && mode !== "node") throw new Error(`Invalid sidebar initialPathMode for ${sectionKey}`);
|
|
90
|
+
let nodes = forest;
|
|
91
|
+
let target;
|
|
92
|
+
for (const index of definition?.initialPath ?? []) {
|
|
93
|
+
target = nodes[index];
|
|
94
|
+
if (!Number.isInteger(index) || index < 0 || !target) throw new Error(`Invalid sidebar initialPath for ${sectionKey}`);
|
|
95
|
+
nodes = target.children;
|
|
89
96
|
}
|
|
90
|
-
return nodes;
|
|
97
|
+
return { nodes: target && definition?.initialPathMode === "node" ? [target] : nodes, target };
|
|
91
98
|
}
|
|
92
99
|
function buildSidebarNavigation(...args) {
|
|
93
100
|
const [docs, lang, categoryMatch, meta, sidebars, buildTree, prefixes] = args;
|
|
@@ -158,13 +165,9 @@ function buildSidebarNavigation(...args) {
|
|
|
158
165
|
roots = result.canonical;
|
|
159
166
|
nodes = result.visible;
|
|
160
167
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (!Number.isInteger(index) || index < 0 || !node) throw new Error(`Invalid sidebar initialPath for ${categoryMatch ?? "default"}`);
|
|
165
|
-
localParentId = node.occurrenceId;
|
|
166
|
-
nodes = node.children;
|
|
167
|
-
}
|
|
168
|
+
const { nodes: local, target } = followInitialPath(nodes, definition, categoryMatch ?? "default");
|
|
169
|
+
nodes = local;
|
|
170
|
+
const localParentId = definition?.initialPathMode === "node" && target?.occurrenceId ? parents[target.occurrenceId] ?? void 0 : target?.occurrenceId;
|
|
168
171
|
let hash = 2166136261;
|
|
169
172
|
const serialized = JSON.stringify(Object.keys(initialExpansion).length ? [roots, initialExpansion] : roots);
|
|
170
173
|
for (let i = 0; i < serialized.length; i++) hash = Math.imul(hash ^ serialized.charCodeAt(i), 16777619);
|
|
@@ -659,7 +659,7 @@ function TrayGroupNode({
|
|
|
659
659
|
group.value.items.some((item) => item.slug === currentSlug.value),
|
|
660
660
|
);
|
|
661
661
|
const occurrenceKey = computed(() => `${trayOccurrence.value}#${group.value.key}`);
|
|
662
|
-
const open = signal(scopeControls
|
|
662
|
+
const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items)) : containsCurrent.value);
|
|
663
663
|
const storageKey = computed(() =>
|
|
664
664
|
noteTrayGroupStorageKey(traySlug.value, group.value.key),
|
|
665
665
|
);
|
|
@@ -672,7 +672,7 @@ function TrayGroupNode({
|
|
|
672
672
|
if (!scopeControls && getOpenSet().has(storageKey.value)) open.value = true;
|
|
673
673
|
});
|
|
674
674
|
scope.effect(() => {
|
|
675
|
-
if (containsCurrent.value) open.value = true;
|
|
675
|
+
if (!scopeControls && containsCurrent.value) open.value = true;
|
|
676
676
|
});
|
|
677
677
|
scope.effect(() => {
|
|
678
678
|
if (!scopeControls && open.value) {
|
|
@@ -684,20 +684,18 @@ function TrayGroupNode({
|
|
|
684
684
|
}
|
|
685
685
|
});
|
|
686
686
|
const toggle = () => {
|
|
687
|
-
open.value = !open.value;
|
|
688
687
|
if (scopeControls) {
|
|
689
|
-
scopeControls.
|
|
690
|
-
scopeControls.save();
|
|
688
|
+
scopeControls.setOpen(occurrenceKey.value, !open.value);
|
|
691
689
|
return;
|
|
692
690
|
}
|
|
691
|
+
open.value = !open.value;
|
|
693
692
|
const stored = getOpenSet();
|
|
694
693
|
if (open.value) stored.add(storageKey.value);
|
|
695
694
|
else stored.delete(storageKey.value);
|
|
696
695
|
saveOpenSet(stored);
|
|
697
696
|
};
|
|
698
697
|
scope.effect(() => {
|
|
699
|
-
|
|
700
|
-
if (scopeControls) open.value = stored ?? scopeControls.defaultGroupOpen(group.value.items);
|
|
698
|
+
if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey.value, scopeControls.defaultGroupOpen(group.value.items));
|
|
701
699
|
});
|
|
702
700
|
const expanded = computed(() => forceOpen.value || open.value);
|
|
703
701
|
const items = computed(() => group.value.items);
|
|
@@ -827,15 +825,13 @@ function CategoryNode({
|
|
|
827
825
|
);
|
|
828
826
|
const active = computed(() => node.value.slug === currentSlug.value);
|
|
829
827
|
const occurrenceKey = () => node.value.occurrenceId ?? node.value.slug;
|
|
830
|
-
const open = signal(scopeControls
|
|
828
|
+
const open = signal(scopeControls ? scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value)) : containsCurrent.value || !node.value.collapsed);
|
|
831
829
|
scope.onActivate(() => {
|
|
832
830
|
if (!scopeControls && getOpenSet().has(node.value.slug)) open.value = true;
|
|
833
831
|
});
|
|
834
832
|
scope.effect(() => {
|
|
835
833
|
const slug = currentSlug.value;
|
|
836
|
-
|
|
837
|
-
if (slug !== undefined && subtreeContainsSlug(node.value, slug)) open.value = true;
|
|
838
|
-
void revision;
|
|
834
|
+
if (!scopeControls && slug !== undefined && subtreeContainsSlug(node.value, slug)) open.value = true;
|
|
839
835
|
});
|
|
840
836
|
scope.effect(() => {
|
|
841
837
|
if (!scopeControls && open.value) {
|
|
@@ -847,20 +843,18 @@ function CategoryNode({
|
|
|
847
843
|
}
|
|
848
844
|
});
|
|
849
845
|
const toggle = () => {
|
|
850
|
-
open.value = !open.value;
|
|
851
846
|
if (scopeControls) {
|
|
852
|
-
scopeControls.
|
|
853
|
-
scopeControls.save();
|
|
847
|
+
scopeControls.setOpen(occurrenceKey(), !open.value);
|
|
854
848
|
return;
|
|
855
849
|
}
|
|
850
|
+
open.value = !open.value;
|
|
856
851
|
const stored = getOpenSet();
|
|
857
852
|
if (open.value) stored.add(node.value.slug);
|
|
858
853
|
else stored.delete(node.value.slug);
|
|
859
854
|
saveOpenSet(stored);
|
|
860
855
|
};
|
|
861
856
|
scope.effect(() => {
|
|
862
|
-
|
|
863
|
-
if (scopeControls) open.value = stored ?? scopeControls.defaultOpen(node.value);
|
|
857
|
+
if (scopeControls) open.value = scopeControls.isOpen(occurrenceKey(), scopeControls.defaultOpen(node.value));
|
|
864
858
|
});
|
|
865
859
|
const expanded = computed(() => forceOpen.value || open.value);
|
|
866
860
|
const focusButton = scopeControls ? (
|
|
@@ -7,15 +7,27 @@ import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
|
7
7
|
export interface ScopeControls {
|
|
8
8
|
focus: (node: SidebarNavNode) => void;
|
|
9
9
|
focusLabel: string;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/** Effective disclosure: the user's explicit choice, else revealed/scope root, else `baseline`. */
|
|
11
|
+
isOpen: (key: string, baseline: boolean) => boolean;
|
|
12
|
+
/** Records an explicit user disclosure choice. */
|
|
13
|
+
setOpen: (key: string, open: boolean) => void;
|
|
13
14
|
defaultOpen: (node: SidebarNavNode) => boolean;
|
|
14
15
|
defaultGroupOpen: (items: SidebarNavNode[]) => boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// Version 1 records (no `version`) mixed automatic reveal into `expansion`
|
|
19
|
+
// without provenance, so their expansion is discarded once on read.
|
|
20
|
+
const STATE_VERSION = 2;
|
|
21
|
+
|
|
22
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
23
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
|
|
17
25
|
/** One controller shared by the real desktop tree and mobile drawer. SSR always
|
|
18
|
-
* starts with configured roots; session state is applied only on activation.
|
|
26
|
+
* starts with configured roots; session state is applied only on activation.
|
|
27
|
+
* `expansion` holds explicit user choices only. The active path and the
|
|
28
|
+
* visible scope's sole root are derived, other scope-change reveal stays in
|
|
29
|
+
* memory, and a reveal removes conflicting explicit collapses instead of
|
|
30
|
+
* writing automatic entries. */
|
|
19
31
|
export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }: {
|
|
20
32
|
nodes: SidebarNavNode[];
|
|
21
33
|
navigation?: SidebarNavigationContext;
|
|
@@ -25,7 +37,7 @@ export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }
|
|
|
25
37
|
}) {
|
|
26
38
|
const selected = signal<string | null>(null);
|
|
27
39
|
const expansion = signal<Record<string, boolean>>({});
|
|
28
|
-
const
|
|
40
|
+
const revealed = signal<ReadonlySet<string>>(new Set());
|
|
29
41
|
let lastPage = activeSlug.value;
|
|
30
42
|
const navRef: Ref<HTMLElement> = { current: null };
|
|
31
43
|
const stateKey = "zd-sidebar-scope";
|
|
@@ -47,42 +59,63 @@ export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }
|
|
|
47
59
|
const groupKeys = new Set(trayGroups.map((group) => group.key));
|
|
48
60
|
const defaultOpen = (node: SidebarNavNode) => sidebarBaselineOpen(node, navigation ? sidebarInitialExpansion(navigation, activeSlug.value) : undefined, activeSlug.value);
|
|
49
61
|
const defaultGroupOpen = (items: SidebarNavNode[]) => items.some((item) => item.slug === activeSlug.value) || (!!navigation && sidebarInitialExpansion(navigation, activeSlug.value) === "expanded");
|
|
62
|
+
const baselineOf = (key: string) => {
|
|
63
|
+
const node = occurrenceIndex.get(key);
|
|
64
|
+
if (node) return node.children.length ? defaultOpen(node) : undefined;
|
|
65
|
+
const group = trayGroups.find((candidate) => candidate.key === key);
|
|
66
|
+
return group ? defaultGroupOpen(group.items) : undefined;
|
|
67
|
+
};
|
|
68
|
+
const scopeRoot = computed(() => selectedNodes.value.length === 1 ? selectedNodes.value[0]!.occurrenceId : undefined);
|
|
69
|
+
const automaticOpen = (key: string, baseline: boolean) => revealed.value.has(key) || key === scopeRoot.value || baseline;
|
|
50
70
|
const canRestore = computed(() => {
|
|
51
71
|
if (JSON.stringify(selectedNodes.value) !== JSON.stringify(nodes)) return true;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
const differs = Object.entries(expansion.value).some(([key, open]) => {
|
|
73
|
+
const baseline = baselineOf(key);
|
|
74
|
+
return baseline !== undefined && open !== automaticOpen(key, baseline);
|
|
75
|
+
});
|
|
76
|
+
return differs || [...revealed.value].some((key) => expansion.value[key] === undefined && baselineOf(key) === false);
|
|
56
77
|
});
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
// A stored or chosen scope whose view equals the destination baseline is the baseline.
|
|
79
|
+
const canonicalScope = (id: string | null) =>
|
|
80
|
+
id !== null && navigation && ((nodes.length === 1 && nodes[0]!.occurrenceId === id)
|
|
81
|
+
|| JSON.stringify(sidebarScopeNodes(navigation, nodes, id)) === JSON.stringify(nodes)) ? null : id;
|
|
82
|
+
const withAncestors = (id: string | null) => {
|
|
83
|
+
const chain: string[] = [];
|
|
84
|
+
for (let ancestor = id; ancestor && ancestor !== SIDEBAR_FOREST_SCOPE; ancestor = navigation?.parents[ancestor] ?? null) chain.push(ancestor);
|
|
85
|
+
return chain;
|
|
86
|
+
};
|
|
87
|
+
const withoutCollapses = (keys: Iterable<string>) => {
|
|
59
88
|
const next = { ...expansion.value };
|
|
60
|
-
for (const [key
|
|
61
|
-
if (node.slug !== activeSlug.value && key !== id) continue;
|
|
62
|
-
let ancestor: string | null | undefined = key;
|
|
63
|
-
while (ancestor) { next[ancestor] = true; ancestor = navigation.parents[ancestor]; }
|
|
64
|
-
}
|
|
65
|
-
for (const group of trayGroups) {
|
|
66
|
-
if (group.items.some((item) => item.slug === activeSlug.value)) next[group.key] = true;
|
|
67
|
-
}
|
|
89
|
+
for (const key of keys) if (next[key] === false) delete next[key];
|
|
68
90
|
expansion.value = next;
|
|
69
|
-
reveal.value++;
|
|
70
91
|
};
|
|
71
|
-
|
|
92
|
+
// Destination arrival reveals the active path and scope root by dropping
|
|
93
|
+
// explicit collapses there. Deterministic, so every controller converges.
|
|
94
|
+
const arrive = () => {
|
|
95
|
+
revealed.value = new Set();
|
|
96
|
+
withoutCollapses([
|
|
97
|
+
...[...occurrenceIndex].filter(([, node]) => node.slug === activeSlug.value).flatMap(([key]) => withAncestors(key)),
|
|
98
|
+
...trayGroups.filter((group) => group.items.some((item) => item.slug === activeSlug.value)).map((group) => group.key),
|
|
99
|
+
...(scopeRoot.value ? [scopeRoot.value] : []),
|
|
100
|
+
]);
|
|
101
|
+
};
|
|
102
|
+
const save = (broadcast = true) => {
|
|
72
103
|
if (!navigation) return;
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
const state = { version: STATE_VERSION, context: navigation.id, page: activeSlug.value, selected: selected.value, query: query.value, expansion: expansion.value };
|
|
105
|
+
try { sessionStorage.setItem(stateKey, JSON.stringify(state)); } catch { /* Optional persistence. */ }
|
|
106
|
+
if (broadcast) document.dispatchEvent(new CustomEvent(syncEvent, { detail: { ...state, revealed: [...revealed.value] } }));
|
|
75
107
|
};
|
|
76
108
|
const changeScope = (id: string | null) => {
|
|
77
109
|
const previous = selected.value;
|
|
78
|
-
selected.value =
|
|
110
|
+
selected.value = canonicalScope(id);
|
|
79
111
|
if (id === null) {
|
|
80
|
-
// Restore the
|
|
112
|
+
// Restore the configured baseline; the active path is open by default.
|
|
81
113
|
expansion.value = {};
|
|
82
|
-
|
|
114
|
+
revealed.value = new Set();
|
|
83
115
|
} else {
|
|
84
|
-
|
|
85
|
-
|
|
116
|
+
// Focus opens its target; the scope being left keeps explicit choices.
|
|
117
|
+
withoutCollapses(withAncestors(id));
|
|
118
|
+
revealed.value = new Set([...revealed.value, ...withAncestors(previous), ...withAncestors(id)]);
|
|
86
119
|
}
|
|
87
120
|
save();
|
|
88
121
|
// Scroll only the sidebar's own viewport; never scrollIntoView, which also
|
|
@@ -112,38 +145,48 @@ export function useSidebarScope({ nodes, navigation, activeSlug, query, locale }
|
|
|
112
145
|
};
|
|
113
146
|
const scopeControls: ScopeControls | undefined = navigation ? {
|
|
114
147
|
focus: (node) => { if (node.occurrenceId) changeScope(node.occurrenceId); },
|
|
115
|
-
focusLabel: labels.focus,
|
|
116
|
-
|
|
148
|
+
focusLabel: labels.focus, defaultOpen, defaultGroupOpen,
|
|
149
|
+
isOpen: (key, baseline) => expansion.value[key] ?? automaticOpen(key, baseline),
|
|
150
|
+
setOpen: (key, open) => {
|
|
151
|
+
expansion.value = { ...expansion.value, [key]: open };
|
|
152
|
+
save();
|
|
153
|
+
},
|
|
117
154
|
} : undefined;
|
|
118
155
|
getScope().onActivate(() => {
|
|
119
156
|
if (!navigation) return;
|
|
120
157
|
lastPage = activeSlug.value;
|
|
158
|
+
let samePage = false;
|
|
121
159
|
try {
|
|
122
|
-
const stored = JSON.parse(sessionStorage.getItem(stateKey) ?? "null");
|
|
123
|
-
if (stored
|
|
160
|
+
const stored: unknown = JSON.parse(sessionStorage.getItem(stateKey) ?? "null");
|
|
161
|
+
if (isRecord(stored) && stored.context === navigation.id) {
|
|
124
162
|
const candidate = typeof stored.selected === "string" ? stored.selected : null;
|
|
125
163
|
const valid = candidate === SIDEBAR_FOREST_SCOPE || (candidate !== null && occurrenceIndex.has(candidate)) ? candidate : null;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
: reconcileSidebarScope(navigation, nodes, valid, activeSlug.value);
|
|
164
|
+
samePage = stored.page === activeSlug.value;
|
|
165
|
+
selected.value = canonicalScope(samePage ? valid : reconcileSidebarScope(navigation, nodes, valid, activeSlug.value));
|
|
129
166
|
query.value = typeof stored.query === "string" ? stored.query : "";
|
|
130
|
-
if (stored.
|
|
131
|
-
|
|
167
|
+
if (stored.version === STATE_VERSION && isRecord(stored.expansion)) {
|
|
168
|
+
expansion.value = Object.fromEntries(Object.entries(stored.expansion).filter(([id, value]) => (occurrenceIndex.has(id) || groupKeys.has(id)) && typeof value === "boolean")) as Record<string, boolean>;
|
|
169
|
+
}
|
|
132
170
|
}
|
|
133
171
|
} catch { /* Stale or unavailable storage uses the configured baseline. */ }
|
|
134
|
-
|
|
172
|
+
// Same-page remount/reload keeps explicit choices, including an active-path collapse.
|
|
173
|
+
if (!samePage) arrive();
|
|
174
|
+
// Persist without broadcasting: a late-activating controller must not reset
|
|
175
|
+
// a live peer's in-memory reveal; peers handle arrival themselves.
|
|
176
|
+
save(false);
|
|
135
177
|
const sync = (event: Event) => {
|
|
136
178
|
const state = (event as CustomEvent).detail;
|
|
137
179
|
if (state?.context !== navigation.id || state.page !== activeSlug.value) return;
|
|
138
180
|
selected.value = state.selected;
|
|
139
181
|
query.value = state.query;
|
|
140
182
|
expansion.value = state.expansion;
|
|
183
|
+
revealed.value = new Set(state.revealed);
|
|
141
184
|
};
|
|
142
185
|
const navigate = () => {
|
|
143
186
|
if (lastPage === activeSlug.value) return;
|
|
144
187
|
lastPage = activeSlug.value;
|
|
145
|
-
selected.value = reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value);
|
|
146
|
-
|
|
188
|
+
selected.value = canonicalScope(reconcileSidebarScope(navigation, nodes, selected.value, activeSlug.value));
|
|
189
|
+
arrive();
|
|
147
190
|
save();
|
|
148
191
|
};
|
|
149
192
|
document.addEventListener(syncEvent, sync);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takazudo/zudo-doc",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "zudo-doc framework primitives layer that sits on top of zfb's engine — sidebar, theme, TOC, breadcrumb, layouts, head injection, View Transitions, SSR-skip wrappers (per ADR-003).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -788,7 +788,7 @@
|
|
|
788
788
|
"typescript": "^5.0.0",
|
|
789
789
|
"vitest": "^4.1.0",
|
|
790
790
|
"zod": "^4.3.6",
|
|
791
|
-
"@takazudo/zudo-doc-history-server": "6.0
|
|
791
|
+
"@takazudo/zudo-doc-history-server": "6.1.0"
|
|
792
792
|
},
|
|
793
793
|
"scripts": {
|
|
794
794
|
"gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
|