@kerfjs/ui 5.0.0-beta.50 → 5.0.0-beta.52
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/README.md +29 -5
- package/ai/compile-time-contracts-v1.json +5 -2
- package/ai/component-catalog-v2.d.ts +19 -1
- package/ai/component-catalog-v2.json +842 -127
- package/ai/component-catalog-v2.schema.json +33 -1
- package/ai/component-catalog.json +603 -126
- package/ai/component-catalog.schema.json +21 -1
- package/ai/public-api-signatures-v1.md +218 -31
- package/ai/skill.md +27 -24
- package/ai/webawesome-jsx-signatures-v1.md +1 -1
- package/analyzer/index.mjs +105 -35
- package/dist/app-tab.d.ts +10 -1
- package/dist/app-tab.js +1 -1
- package/dist/catalog-resources.d.ts +1 -1
- package/dist/catalog.d.ts +28 -5
- package/dist/catalog.js +66 -25
- package/dist/catalog.js.map +1 -1
- package/dist/{chunk-OL4TC3U2.js → chunk-2CE22Q2K.js} +9 -2
- package/dist/chunk-2CE22Q2K.js.map +1 -0
- package/dist/{chunk-SIU2AC5W.js → chunk-2S7OWAEV.js} +3 -3
- package/dist/{chunk-SIU2AC5W.js.map → chunk-2S7OWAEV.js.map} +1 -1
- package/dist/{chunk-2YOUUEWR.js → chunk-3S7OUHUN.js} +4 -4
- package/dist/{chunk-2YOUUEWR.js.map → chunk-3S7OUHUN.js.map} +1 -1
- package/dist/{chunk-2WMSE4MK.js → chunk-4XE2CDYB.js} +2 -2
- package/dist/{chunk-2WMSE4MK.js.map → chunk-4XE2CDYB.js.map} +1 -1
- package/dist/{chunk-V6RZWDII.js → chunk-7GUQYHKA.js} +19 -2
- package/dist/chunk-7GUQYHKA.js.map +1 -0
- package/dist/{chunk-ZNZ2JDBW.js → chunk-7IUSV4XG.js} +1 -1
- package/dist/chunk-7IUSV4XG.js.map +1 -0
- package/dist/{chunk-UMGY5JSP.js → chunk-7XJO4D6A.js} +22 -2
- package/dist/chunk-7XJO4D6A.js.map +1 -0
- package/dist/{chunk-KKNK3JUT.js → chunk-ABAXNUXU.js} +5 -5
- package/dist/chunk-ABAXNUXU.js.map +1 -0
- package/dist/{chunk-AQSKJ6U6.js → chunk-DNCOFEUL.js} +21 -2
- package/dist/chunk-DNCOFEUL.js.map +1 -0
- package/dist/chunk-EKYZCRCX.js +256 -0
- package/dist/chunk-EKYZCRCX.js.map +1 -0
- package/dist/{chunk-COUPLRZG.js → chunk-IR53BQUJ.js} +16 -12
- package/dist/chunk-IR53BQUJ.js.map +1 -0
- package/dist/{chunk-3V75EQIP.js → chunk-JGFZ42ZF.js} +3 -3
- package/dist/{chunk-3V75EQIP.js.map → chunk-JGFZ42ZF.js.map} +1 -1
- package/dist/{chunk-54HM36IQ.js → chunk-KAQUUTZR.js} +4 -2
- package/dist/chunk-KAQUUTZR.js.map +1 -0
- package/dist/{chunk-ZSYLBVWG.js → chunk-LIOXPQ7N.js} +4 -4
- package/dist/chunk-LIOXPQ7N.js.map +1 -0
- package/dist/{chunk-S6ZTTTV7.js → chunk-M6GLI6A6.js} +3 -3
- package/dist/{chunk-S6ZTTTV7.js.map → chunk-M6GLI6A6.js.map} +1 -1
- package/dist/{chunk-OQI7RCRP.js → chunk-MTM43SJO.js} +7 -1
- package/dist/chunk-MTM43SJO.js.map +1 -0
- package/dist/{chunk-5CE64WGC.js → chunk-OSCL5TKR.js} +3 -3
- package/dist/{chunk-5CE64WGC.js.map → chunk-OSCL5TKR.js.map} +1 -1
- package/dist/{chunk-DARTMI5Z.js → chunk-SV22UIW4.js} +2 -2
- package/dist/{chunk-DARTMI5Z.js.map → chunk-SV22UIW4.js.map} +1 -1
- package/dist/{chunk-ELRMBQEC.js → chunk-T7C2VZUB.js} +17 -2
- package/dist/chunk-T7C2VZUB.js.map +1 -0
- package/dist/{chunk-DJKWFYAA.js → chunk-YZV2PTY4.js} +3 -3
- package/dist/{chunk-DJKWFYAA.js.map → chunk-YZV2PTY4.js.map} +1 -1
- package/dist/collapsible-panel.d.ts +1 -1
- package/dist/collapsible-panel.js.map +1 -1
- package/dist/css-values.d.ts +41 -3
- package/dist/css-values.js +1 -1
- package/dist/grid.d.ts +15 -1
- package/dist/grid.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +19 -19
- package/dist/list-action-row.js +1 -1
- package/dist/list-header.js +1 -1
- package/dist/list-item.js +1 -1
- package/dist/list.d.ts +21 -2
- package/dist/list.js +2 -1
- package/dist/nav-stack.js +1 -1
- package/dist/pane.d.ts +17 -2
- package/dist/pane.js +1 -1
- package/dist/row.d.ts +19 -1
- package/dist/row.js +2 -1
- package/dist/segmented-control.js +1 -1
- package/dist/select.d.ts +13 -3
- package/dist/select.js +1 -1
- package/dist/skeleton.d.ts +5 -1
- package/dist/skeleton.js +1 -1
- package/dist/spacer.js +1 -1
- package/dist/split-view.js +1 -1
- package/dist/state-banner.js +1 -1
- package/dist/styles/app-tab.css +9 -0
- package/dist/styles/catalog/components/catalog-example-stack.css +4 -1
- package/dist/styles/catalog/components/catalog-example.css +145 -0
- package/dist/styles/collapsible-panel.css +132 -4
- package/dist/styles/document.css +23 -0
- package/dist/styles/foundation.css +21 -7
- package/dist/styles/grid.css +10 -0
- package/dist/styles/list-header.css +45 -0
- package/dist/styles/list-inset-control.css +8 -1
- package/dist/styles/list-item.css +6 -0
- package/dist/styles/list.css +11 -0
- package/dist/styles/nav-stack.css +89 -2
- package/dist/styles/pane.css +86 -1
- package/dist/styles/resizable-region.css +93 -5
- package/dist/styles/row.css +10 -0
- package/dist/styles/select.css +39 -4
- package/dist/styles/skeleton.css +4 -1
- package/dist/styles/split-view.css +107 -1
- package/dist/styles/tab-scaffold.css +64 -1
- package/dist/styles/text.css +6 -0
- package/dist/styles/toolbar-control-group.css +90 -11
- package/dist/styles/toolbar-text.css +4 -1
- package/dist/styles/toolbar.css +130 -38
- package/dist/styles/webawesome.css +48 -2
- package/dist/styles/workbench.css +136 -4
- package/dist/text.d.ts +4 -3
- package/dist/text.js +1 -1
- package/dist/toolbar-text.js +1 -1
- package/dist/toolbar.d.ts +24 -3
- package/dist/toolbar.js +1 -1
- package/dist/{types-CzgSrFXP.d.ts → types-CEsieBZi.d.ts} +6 -1
- package/dist/value-table.js +1 -1
- package/dist/wire-resizable-regions.js +1 -1
- package/dist/wire-sidebar.d.ts +18 -3
- package/dist/wire-sidebar.js +96 -15
- package/dist/wire-sidebar.js.map +1 -1
- package/docs/accessibility.md +49 -9
- package/docs/app-layouts.md +73 -0
- package/docs/catalog.md +23 -2
- package/docs/collapsible-panel.md +83 -9
- package/docs/component-contract.md +36 -12
- package/docs/component-integration.md +9 -4
- package/docs/component-selection.md +25 -6
- package/docs/design/templates/list/stack-dark.svg +1 -1
- package/docs/design/templates/list/stack.svg +1 -1
- package/docs/design/templates/list-dark.svg +1 -1
- package/docs/design/templates/list-header/action-dark.svg +1 -1
- package/docs/design/templates/list-header/action.svg +1 -1
- package/docs/design/templates/list-header/count-dark.svg +1 -1
- package/docs/design/templates/list-header/count.svg +1 -1
- package/docs/design/templates/list-header/inline-action-dark.svg +1 -1
- package/docs/design/templates/list-header/inline-action.svg +1 -1
- package/docs/design/templates/list-header/toggle-expanded-dark.svg +1 -1
- package/docs/design/templates/list-header/toggle-expanded.svg +1 -1
- package/docs/design/templates/list-header-dark.svg +1 -1
- package/docs/design/templates/list-header.svg +1 -1
- package/docs/design/templates/list.svg +1 -1
- package/docs/design/templates/state-banner/info-badge-dark.svg +1 -1
- package/docs/design/templates/state-banner/info-badge.svg +1 -1
- package/docs/design/templates/state-banner-dark.svg +1 -1
- package/docs/design/templates/state-banner.svg +1 -1
- package/docs/design/templates/toolbar/title-and-actions-dark.svg +1 -1
- package/docs/design/templates/toolbar/title-and-actions.svg +1 -1
- package/docs/design/templates/toolbar-control-group/with-text-dark.svg +1 -1
- package/docs/design/templates/toolbar-control-group/with-text.svg +1 -1
- package/docs/design/templates/toolbar-control-group-dark.svg +1 -1
- package/docs/design/templates/toolbar-control-group.svg +1 -1
- package/docs/design/templates/toolbar-dark.svg +1 -1
- package/docs/design/templates/toolbar-text/default-dark.svg +1 -1
- package/docs/design/templates/toolbar-text/default.svg +1 -1
- package/docs/design/templates/toolbar-text/large-dark.svg +1 -1
- package/docs/design/templates/toolbar-text/large.svg +1 -1
- package/docs/design/templates/toolbar-text/small-dark.svg +1 -1
- package/docs/design/templates/toolbar-text/small.svg +1 -1
- package/docs/design/templates/toolbar-text-dark.svg +1 -1
- package/docs/design/templates/toolbar-text.svg +1 -1
- package/docs/design/templates/toolbar.svg +1 -1
- package/docs/document-baseline.md +31 -0
- package/docs/layout.md +86 -21
- package/docs/nav-stack.md +8 -0
- package/docs/recipes.md +81 -44
- package/docs/split-view.md +8 -0
- package/docs/tab-scaffold.md +4 -2
- package/docs/type-contracts.md +21 -17
- package/docs/ui-analyzer.md +19 -0
- package/docs/ui-evaluator.md +17 -0
- package/docs/ux-demo.md +5 -5
- package/docs/webawesome-theme.md +20 -2
- package/docs/workbench.md +20 -1
- package/evaluator/index.mjs +73 -8
- package/llms.txt +32 -14
- package/package.json +4 -2
- package/ux-demo/recipes/app-shell.tsx +209 -178
- package/ux-demo/recipes/collapsible-sidebar.tsx +189 -159
- package/ux-demo/recipes/compact-toolbar.tsx +48 -35
- package/ux-demo/recipes/composer-form.tsx +87 -79
- package/ux-demo/recipes/list-detail-dialog.tsx +124 -142
- package/ux-demo/recipes/list-workspace-states.tsx +83 -83
- package/ux-demo/recipes/loaders.ts +5 -2
- package/ux-demo/recipes/loading-inspector.tsx +157 -160
- package/ux-demo/recipes/navigation-sidebar.tsx +111 -109
- package/ux-demo/recipes/navigation-stack.tsx +50 -49
- package/ux-demo/recipes/types.ts +14 -0
- package/ux-demo/recipes/workspace-header.tsx +73 -56
- package/dist/chunk-54HM36IQ.js.map +0 -1
- package/dist/chunk-AQSKJ6U6.js.map +0 -1
- package/dist/chunk-COUPLRZG.js.map +0 -1
- package/dist/chunk-ELRMBQEC.js.map +0 -1
- package/dist/chunk-J3EE6XWD.js +0 -115
- package/dist/chunk-J3EE6XWD.js.map +0 -1
- package/dist/chunk-KKNK3JUT.js.map +0 -1
- package/dist/chunk-OL4TC3U2.js.map +0 -1
- package/dist/chunk-OQI7RCRP.js.map +0 -1
- package/dist/chunk-UMGY5JSP.js.map +0 -1
- package/dist/chunk-V6RZWDII.js.map +0 -1
- package/dist/chunk-ZNZ2JDBW.js.map +0 -1
- package/dist/chunk-ZSYLBVWG.js.map +0 -1
- package/ux-demo/recipes/app-shell.css +0 -87
- package/ux-demo/recipes/collapsible-sidebar.css +0 -83
- package/ux-demo/recipes/compact-toolbar.css +0 -35
- package/ux-demo/recipes/composer-form.css +0 -19
- package/ux-demo/recipes/list-detail-dialog.css +0 -42
- package/ux-demo/recipes/list-workspace-states.css +0 -30
- package/ux-demo/recipes/loading-inspector.css +0 -7
- package/ux-demo/recipes/navigation-sidebar.css +0 -12
- package/ux-demo/recipes/navigation-stack.css +0 -12
- package/ux-demo/recipes/recipe-root.css +0 -67
- package/ux-demo/recipes/recipe-root.tsx +0 -87
- package/ux-demo/recipes/workspace-header.css +0 -7
package/dist/wire-sidebar.js
CHANGED
|
@@ -23,14 +23,41 @@ function wireSidebar(root, {
|
|
|
23
23
|
const byAction = new Map(panels.map((panel) => [panel.toggleAction, panel]));
|
|
24
24
|
const returnFocus = /* @__PURE__ */ new Map();
|
|
25
25
|
const disposers = [];
|
|
26
|
+
const adapting = /* @__PURE__ */ new Set();
|
|
27
|
+
const overlayActive = () => Boolean(deviceClass?.value.compact) && compactPresentation === "overlay";
|
|
28
|
+
let inlineState;
|
|
29
|
+
const setAll = (value) => {
|
|
30
|
+
for (const panel of panels) {
|
|
31
|
+
const next = value(panel);
|
|
32
|
+
if (panel.collapsed.peek() === next) continue;
|
|
33
|
+
adapting.add(panel);
|
|
34
|
+
panel.collapsed.value = next;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const restoreInline = () => {
|
|
38
|
+
const remembered = inlineState;
|
|
39
|
+
if (!remembered) return;
|
|
40
|
+
inlineState = void 0;
|
|
41
|
+
setAll((panel) => remembered.get(panel));
|
|
42
|
+
};
|
|
43
|
+
if (overlayActive()) inlineState = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const panel of panels) {
|
|
45
|
+
const stored = panel.storageKey && storage ? storage.getItem(panel.storageKey) : null;
|
|
46
|
+
const seed = stored === "true" || stored === "false" ? stored === "true" : panel.inlineCollapsed;
|
|
47
|
+
if (inlineState) {
|
|
48
|
+
inlineState.set(panel, seed ?? panel.collapsed.peek());
|
|
49
|
+
panel.collapsed.value = true;
|
|
50
|
+
} else if (seed !== void 0) {
|
|
51
|
+
panel.collapsed.value = seed;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
26
54
|
for (const panel of panels) {
|
|
27
55
|
if (!panel.storageKey || !storage) continue;
|
|
28
|
-
const stored = storage.getItem(panel.storageKey);
|
|
29
|
-
if (stored === "true" || stored === "false")
|
|
30
|
-
panel.collapsed.value = stored === "true";
|
|
31
56
|
disposers.push(
|
|
32
57
|
effect(() => {
|
|
33
|
-
|
|
58
|
+
const collapsed = panel.collapsed.value;
|
|
59
|
+
if (overlayActive()) return;
|
|
60
|
+
storage.setItem(panel.storageKey, String(collapsed));
|
|
34
61
|
})
|
|
35
62
|
);
|
|
36
63
|
}
|
|
@@ -47,12 +74,21 @@ function wireSidebar(root, {
|
|
|
47
74
|
returnFocus.set(panel.id, trigger);
|
|
48
75
|
const next = !panel.collapsed.value;
|
|
49
76
|
panel.collapsed.value = next;
|
|
50
|
-
if (!next && exclusiveCompact &&
|
|
77
|
+
if (!next && exclusiveCompact && overlayActive()) {
|
|
51
78
|
for (const other of panels)
|
|
52
79
|
if (other !== panel) other.collapsed.value = true;
|
|
53
80
|
}
|
|
54
81
|
})
|
|
55
82
|
);
|
|
83
|
+
const restoreFocus = (panel) => {
|
|
84
|
+
const element = panelElement(panel.id);
|
|
85
|
+
const trigger = returnFocus.get(panel.id);
|
|
86
|
+
const target = trigger?.isConnected && !element?.contains(trigger) ? trigger : [...root.querySelectorAll("[data-action]")].find(
|
|
87
|
+
(candidate) => candidate.dataset.action === panel.toggleAction && !element?.contains(candidate)
|
|
88
|
+
);
|
|
89
|
+
target?.focus();
|
|
90
|
+
return target !== void 0;
|
|
91
|
+
};
|
|
56
92
|
for (const panel of panels) {
|
|
57
93
|
let previous = panel.collapsed.value;
|
|
58
94
|
disposers.push(
|
|
@@ -61,48 +97,92 @@ function wireSidebar(root, {
|
|
|
61
97
|
if (collapsed === previous) return;
|
|
62
98
|
previous = collapsed;
|
|
63
99
|
const element = panelElement(panel.id);
|
|
100
|
+
if (adapting.delete(panel)) {
|
|
101
|
+
if (collapsed && element?.contains(ownerDocument.activeElement))
|
|
102
|
+
restoreFocus(panel);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
64
105
|
const replaced = deviceClass?.value.compact && compactPresentation === "hidden";
|
|
65
106
|
if (!collapsed && element && !replaced) {
|
|
66
|
-
(focusables(element)[0] ?? element).focus();
|
|
67
|
-
} else if (collapsed) {
|
|
68
|
-
|
|
107
|
+
(focusables(element)[0] ?? element).focus({ preventScroll: true });
|
|
108
|
+
} else if (collapsed && !restoreFocus(panel)) {
|
|
109
|
+
globalThis.queueMicrotask(() => {
|
|
110
|
+
if (panel.collapsed.peek()) restoreFocus(panel);
|
|
111
|
+
});
|
|
69
112
|
}
|
|
70
113
|
})
|
|
71
114
|
);
|
|
72
115
|
}
|
|
73
116
|
if (deviceClass) {
|
|
74
117
|
let backdrop;
|
|
118
|
+
let hostResponsive;
|
|
119
|
+
let hostOverlay;
|
|
120
|
+
let backdropFor;
|
|
75
121
|
const openPanel = () => panels.find((panel) => !panel.collapsed.value);
|
|
76
122
|
const removeBackdrop = () => {
|
|
77
123
|
backdrop?.remove();
|
|
78
124
|
backdrop = void 0;
|
|
125
|
+
backdropFor = void 0;
|
|
126
|
+
};
|
|
127
|
+
const setHostData = (name, value) => {
|
|
128
|
+
if (root.dataset[name] === value) return;
|
|
129
|
+
if (value === void 0) delete root.dataset[name];
|
|
130
|
+
else root.dataset[name] = value;
|
|
131
|
+
};
|
|
132
|
+
const syncHost = () => {
|
|
133
|
+
setHostData("collapsibleResponsive", hostResponsive);
|
|
134
|
+
setHostData("collapsibleOverlay", hostOverlay);
|
|
135
|
+
if (!backdrop || backdropFor === void 0) return;
|
|
136
|
+
const element = panelElement(backdropFor);
|
|
137
|
+
if (element && backdrop.nextElementSibling !== element)
|
|
138
|
+
element.parentElement?.insertBefore(backdrop, element);
|
|
79
139
|
};
|
|
80
140
|
disposers.push(
|
|
81
141
|
effect(() => {
|
|
82
142
|
const compact = deviceClass.value.compact;
|
|
83
143
|
const overlay = compact && compactPresentation === "overlay";
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
144
|
+
for (const panel of panels) void panel.collapsed.value;
|
|
145
|
+
if (overlay && !inlineState) {
|
|
146
|
+
inlineState = new Map(
|
|
147
|
+
panels.map((panel) => [panel, panel.collapsed.peek()])
|
|
148
|
+
);
|
|
149
|
+
setAll(() => true);
|
|
150
|
+
} else if (!overlay) {
|
|
151
|
+
restoreInline();
|
|
152
|
+
}
|
|
153
|
+
const open = overlay ? panels.find((panel) => !panel.collapsed.peek()) : void 0;
|
|
154
|
+
hostResponsive = compact ? compactPresentation : "inline";
|
|
155
|
+
hostOverlay = compact ? String(overlay) : void 0;
|
|
156
|
+
if (open) {
|
|
88
157
|
if (!backdrop) {
|
|
89
158
|
backdrop = ownerDocument.createElement("button");
|
|
90
159
|
backdrop.type = "button";
|
|
91
160
|
backdrop.className = "kui-collapsible-panel__backdrop";
|
|
92
161
|
backdrop.setAttribute("aria-label", "Close");
|
|
162
|
+
backdrop.dataset.morphPreserve = "";
|
|
93
163
|
backdrop.addEventListener("click", () => {
|
|
94
164
|
const current = openPanel();
|
|
95
165
|
if (current) collapse(current);
|
|
96
166
|
});
|
|
97
|
-
const element = panelElement(open.id);
|
|
98
|
-
element?.parentElement?.insertBefore(backdrop, element);
|
|
99
167
|
}
|
|
168
|
+
backdropFor = open.id;
|
|
100
169
|
} else {
|
|
101
170
|
removeBackdrop();
|
|
102
|
-
if (!compact) delete root.dataset.collapsibleOverlay;
|
|
103
171
|
}
|
|
172
|
+
syncHost();
|
|
104
173
|
})
|
|
105
174
|
);
|
|
175
|
+
const observer = new MutationObserver(syncHost);
|
|
176
|
+
observer.observe(root, {
|
|
177
|
+
attributes: true,
|
|
178
|
+
attributeFilter: [
|
|
179
|
+
"data-collapsible-responsive",
|
|
180
|
+
"data-collapsible-overlay"
|
|
181
|
+
],
|
|
182
|
+
childList: true,
|
|
183
|
+
subtree: true
|
|
184
|
+
});
|
|
185
|
+
disposers.push(() => observer.disconnect());
|
|
106
186
|
const onKeydown = (event) => {
|
|
107
187
|
if (!deviceClass.value.compact || compactPresentation !== "overlay")
|
|
108
188
|
return;
|
|
@@ -137,6 +217,7 @@ function wireSidebar(root, {
|
|
|
137
217
|
}
|
|
138
218
|
return () => {
|
|
139
219
|
for (const dispose of disposers.splice(0)) dispose();
|
|
220
|
+
restoreInline();
|
|
140
221
|
delete root.dataset.collapsibleOverlay;
|
|
141
222
|
delete root.dataset.collapsibleResponsive;
|
|
142
223
|
};
|
package/dist/wire-sidebar.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/wire-sidebar.ts"],"names":[],"mappings":"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","file":"wire-sidebar.js","sourcesContent":["import { delegate, effect, type ReadonlySignal, type Signal } from 'kerfjs';\n\nimport type { DeviceClass } from './device-class.js';\n\n/** Minimal `localStorage`-shaped store, so the persistence hook is testable. */\nexport interface SidebarStorage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n}\n\nexport interface WireSidebarPanel {\n /** The panel id — matches `CollapsiblePanel`'s `id` and a toggle's `panelId`. */\n id: string;\n /** The app-owned collapsed signal. `wireSidebar` reads it (focus, overlay) and\n * writes it (toggle, Escape, backdrop, persistence). */\n collapsed: Signal<boolean>;\n /** `data-action` value the panel's toggle button(s) carry. */\n toggleAction: string;\n /** When set, the collapsed state is loaded from and saved to `storage` under\n * this key (a persistence hook), so the panel remembers its state. */\n storageKey?: string;\n}\n\nexport interface WireSidebarOptions {\n panels: readonly WireSidebarPanel[];\n /**\n * When provided, the sidebar adopts a compact **overlay** presentation while\n * `deviceClass.compact` is true: an open panel floats over the content with a\n * dismissable backdrop, Escape and backdrop-click collapse it, and focus is\n * trapped within the open panel (the ARIA dialog pattern). Without it the panel\n * is always inline.\n */\n deviceClass?: ReadonlySignal<DeviceClass>;\n /** Compact devices either overlay the panels (default) or hide them in favor\n * of an application-owned responsive replacement. */\n compactPresentation?: 'overlay' | 'hidden';\n /** Collapse the other panels when one opens in compact overlay mode. */\n exclusiveCompact?: boolean;\n /** Persistence store (default `globalThis.localStorage`, if present). */\n storage?: SidebarStorage;\n}\n\nconst FOCUSABLE =\n 'a[href],area[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nfunction focusables(panel: HTMLElement): HTMLElement[] {\n // Focus only ever targets an OPEN panel, so the selector (which already drops\n // disabled controls and tabindex=\"-1\") is sufficient without a layout-based\n // visibility check that a headless DOM can't answer.\n return [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];\n}\n\nfunction defaultStorage(): SidebarStorage | undefined {\n try {\n return globalThis.localStorage ?? undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * The reusable sidebar-semantics layer for {@link CollapsiblePanel}s: toggle\n * delegation with focus restore, focus-into on open, an optional compact overlay\n * (backdrop + Escape + focus trap) driven by {@link deviceClass}, and an optional\n * persistence hook. The app owns each `collapsed` signal and the layout; this wire\n * owns the interaction. Returns a disposer. See `docs/24-collapsible-panel.md`.\n */\nexport function wireSidebar(\n root: HTMLElement,\n {\n panels,\n deviceClass,\n compactPresentation = 'overlay',\n exclusiveCompact = true,\n storage = defaultStorage(),\n }: WireSidebarOptions,\n): () => void {\n const ownerDocument = root.ownerDocument;\n const byAction = new Map(panels.map((panel) => [panel.toggleAction, panel]));\n const returnFocus = new Map<string, HTMLElement>();\n const disposers: Array<() => void> = [];\n\n // Persistence: seed from storage, then mirror on change.\n for (const panel of panels) {\n if (!panel.storageKey || !storage) continue;\n const stored = storage.getItem(panel.storageKey);\n if (stored === 'true' || stored === 'false')\n panel.collapsed.value = stored === 'true';\n disposers.push(\n effect(() => {\n storage.setItem(panel.storageKey!, String(panel.collapsed.value));\n }),\n );\n }\n\n const panelElement = (id: string): HTMLElement | null =>\n root.querySelector<HTMLElement>(`[data-collapsible-panel=\"${id}\"]`);\n\n const collapse = (panel: WireSidebarPanel): void => {\n if (panel.collapsed.value) return;\n panel.collapsed.value = true;\n };\n\n // Toggle delegation. The trigger is remembered so focus restores to it on close.\n disposers.push(\n delegate(root, 'click', '[data-action]', (_event, element) => {\n const trigger = element as HTMLElement;\n const panel = byAction.get(trigger.dataset.action ?? '');\n if (!panel) return;\n returnFocus.set(panel.id, trigger);\n const next = !panel.collapsed.value;\n panel.collapsed.value = next;\n if (\n !next &&\n exclusiveCompact &&\n deviceClass?.value.compact &&\n compactPresentation === 'overlay'\n ) {\n for (const other of panels)\n if (other !== panel) other.collapsed.value = true;\n }\n }),\n );\n\n // Focus: move into the panel when it opens; restore to the trigger when it closes.\n for (const panel of panels) {\n let previous = panel.collapsed.value;\n disposers.push(\n effect(() => {\n const collapsed = panel.collapsed.value;\n if (collapsed === previous) return;\n previous = collapsed;\n const element = panelElement(panel.id);\n const replaced =\n deviceClass?.value.compact && compactPresentation === 'hidden';\n if (!collapsed && element && !replaced) {\n (focusables(element)[0] ?? element).focus();\n } else if (collapsed) {\n returnFocus.get(panel.id)?.focus();\n }\n }),\n );\n }\n\n // Compact overlay: a dismissable backdrop + Escape + focus trap while compact\n // and a panel is open.\n if (deviceClass) {\n let backdrop: HTMLButtonElement | undefined;\n const openPanel = (): WireSidebarPanel | undefined =>\n panels.find((panel) => !panel.collapsed.value);\n const removeBackdrop = (): void => {\n backdrop?.remove();\n backdrop = undefined;\n };\n disposers.push(\n effect(() => {\n const compact = deviceClass.value.compact;\n const overlay = compact && compactPresentation === 'overlay';\n const open = overlay ? openPanel() : undefined;\n root.dataset.collapsibleResponsive = compact\n ? compactPresentation\n : 'inline';\n root.dataset.collapsibleOverlay = String(overlay);\n if (overlay && open) {\n if (!backdrop) {\n backdrop = ownerDocument.createElement('button');\n backdrop.type = 'button';\n backdrop.className = 'kui-collapsible-panel__backdrop';\n backdrop.setAttribute('aria-label', 'Close');\n backdrop.addEventListener('click', () => {\n const current = openPanel();\n if (current) collapse(current);\n });\n const element = panelElement(open.id);\n element?.parentElement?.insertBefore(backdrop, element);\n }\n } else {\n removeBackdrop();\n if (!compact) delete root.dataset.collapsibleOverlay;\n }\n }),\n );\n\n const onKeydown = (event: KeyboardEvent): void => {\n if (!deviceClass.value.compact || compactPresentation !== 'overlay')\n return;\n const open = openPanel();\n if (!open) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n collapse(open);\n return;\n }\n if (event.key !== 'Tab') return;\n const element = panelElement(open.id);\n if (!element) return;\n const items = focusables(element);\n if (items.length === 0) return;\n const first = items[0]!;\n const last = items[items.length - 1]!;\n const active = ownerDocument.activeElement;\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first.focus();\n }\n };\n ownerDocument.addEventListener('keydown', onKeydown);\n disposers.push(() =>\n ownerDocument.removeEventListener('keydown', onKeydown),\n );\n disposers.push(removeBackdrop);\n }\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose();\n delete root.dataset.collapsibleOverlay;\n delete root.dataset.collapsibleResponsive;\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/wire-sidebar.ts"],"names":[],"mappings":"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","file":"wire-sidebar.js","sourcesContent":["import { delegate, effect, type ReadonlySignal, type Signal } from 'kerfjs';\n\nimport type { DeviceClass } from './device-class.js';\n\n/** Minimal `localStorage`-shaped store, so the persistence hook is testable. */\nexport interface SidebarStorage {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n}\n\nexport interface WireSidebarPanel {\n /** The panel id — matches `CollapsiblePanel`'s `id` and a toggle's `panelId`. */\n id: string;\n /** The app-owned collapsed signal. `wireSidebar` reads it (focus, overlay) and\n * writes it (toggle, Escape, backdrop, persistence). */\n collapsed: Signal<boolean>;\n /** `data-action` value the panel's toggle button(s) carry. */\n toggleAction: string;\n /** When set, the panel's inline collapsed state is loaded from and saved to\n * `storage` under this key (a persistence hook), so the panel remembers the\n * user's inline choice. A compact overlay's open/closed state is transient\n * and is never persisted. */\n storageKey?: string;\n /** The panel's inline (non-compact) collapsed state when `storage` holds no\n * choice for it; wire-up seeds the signal with it. Defaults to the signal's\n * own value. Set it when the app seeds the signal from the device class\n * (`signal(device.value.compact)`) so a compact first render already starts\n * collapsed while a later crossing to a wide class still restores this\n * inline default. */\n inlineCollapsed?: boolean;\n}\n\nexport interface WireSidebarOptions {\n panels: readonly WireSidebarPanel[];\n /**\n * When provided, the sidebar adopts a compact **overlay** presentation while\n * `deviceClass.compact` is true: an open panel floats over the content with a\n * dismissable backdrop, Escape and backdrop-click collapse it, and focus is\n * trapped within the open panel (the ARIA dialog pattern). Without it the panel\n * is always inline.\n *\n * An overlay is transient and user-initiated: whenever the compact overlay\n * presentation begins (wire-up on a compact device, or a crossing from a wide\n * class), every panel starts collapsed so nothing blocks the page until the\n * user opens it. The inline state is remembered and restored when the device\n * crosses back to a wide class (and on disposal).\n */\n deviceClass?: ReadonlySignal<DeviceClass>;\n /** Compact devices either overlay the panels (default) or hide them in favor\n * of an application-owned responsive replacement. */\n compactPresentation?: 'overlay' | 'hidden';\n /** Collapse the other panels when one opens in compact overlay mode. */\n exclusiveCompact?: boolean;\n /** Persistence store (default `globalThis.localStorage`, if present). */\n storage?: SidebarStorage;\n}\n\nconst FOCUSABLE =\n 'a[href],area[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nfunction focusables(panel: HTMLElement): HTMLElement[] {\n // Focus only ever targets an OPEN panel, so the selector (which already drops\n // disabled controls and tabindex=\"-1\") is sufficient without a layout-based\n // visibility check that a headless DOM can't answer.\n return [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];\n}\n\nfunction defaultStorage(): SidebarStorage | undefined {\n try {\n return globalThis.localStorage ?? undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * The reusable sidebar-semantics layer for {@link CollapsiblePanel}s: toggle\n * delegation with focus restore, focus-into on open, an optional compact overlay\n * (backdrop + Escape + focus trap) driven by {@link deviceClass}, and an optional\n * persistence hook. The app owns each `collapsed` signal and the layout; this wire\n * owns the interaction. Returns a disposer. See `ui/docs/collapsible-panel.md` and `docs/23-app-layouts.md`.\n */\nexport function wireSidebar(\n root: HTMLElement,\n {\n panels,\n deviceClass,\n compactPresentation = 'overlay',\n exclusiveCompact = true,\n storage = defaultStorage(),\n }: WireSidebarOptions,\n): () => void {\n const ownerDocument = root.ownerDocument;\n const byAction = new Map(panels.map((panel) => [panel.toggleAction, panel]));\n const returnFocus = new Map<string, HTMLElement>();\n const disposers: Array<() => void> = [];\n // Panels whose next collapsed change is presentation-driven, not a user\n // action, so it must not move focus the way a toggle does. A set rather than\n // a flag: writes made inside an effect are batched, so the focus effects run\n // after the writer returns.\n const adapting = new Set<WireSidebarPanel>();\n const overlayActive = (): boolean =>\n Boolean(deviceClass?.value.compact) && compactPresentation === 'overlay';\n\n // Initial-state contract: an overlay is transient and only ever opens on a\n // user action. Entering the overlay presentation (at wire-up or on a wide →\n // compact crossing) remembers each panel's inline state and collapses it;\n // leaving it (compact → wide, or disposal) restores the remembered state.\n let inlineState: Map<WireSidebarPanel, boolean> | undefined;\n const setAll = (value: (panel: WireSidebarPanel) => boolean): void => {\n for (const panel of panels) {\n const next = value(panel);\n if (panel.collapsed.peek() === next) continue;\n adapting.add(panel);\n panel.collapsed.value = next;\n }\n };\n const restoreInline = (): void => {\n const remembered = inlineState;\n if (!remembered) return;\n inlineState = undefined;\n setAll((panel) => remembered.get(panel)!);\n };\n\n // Seed each panel's inline state from storage or `inlineCollapsed`. On a\n // compact overlay device the seed is only remembered: the panel starts\n // collapsed, synchronously and before any effect exists, so the first render\n // after wire-up already has no overlay open.\n if (overlayActive()) inlineState = new Map();\n for (const panel of panels) {\n const stored =\n panel.storageKey && storage ? storage.getItem(panel.storageKey) : null;\n const seed =\n stored === 'true' || stored === 'false'\n ? stored === 'true'\n : panel.inlineCollapsed;\n if (inlineState) {\n inlineState.set(panel, seed ?? panel.collapsed.peek());\n panel.collapsed.value = true;\n } else if (seed !== undefined) {\n panel.collapsed.value = seed;\n }\n }\n\n // Persistence mirrors inline changes only. An overlay's open/closed state is\n // transient, so it is never written.\n for (const panel of panels) {\n if (!panel.storageKey || !storage) continue;\n disposers.push(\n effect(() => {\n const collapsed = panel.collapsed.value;\n if (overlayActive()) return;\n storage.setItem(panel.storageKey!, String(collapsed));\n }),\n );\n }\n\n const panelElement = (id: string): HTMLElement | null =>\n root.querySelector<HTMLElement>(`[data-collapsible-panel=\"${id}\"]`);\n\n const collapse = (panel: WireSidebarPanel): void => {\n if (panel.collapsed.value) return;\n panel.collapsed.value = true;\n };\n\n // Toggle delegation. The trigger is remembered so focus restores to it on close.\n disposers.push(\n delegate(root, 'click', '[data-action]', (_event, element) => {\n const trigger = element as HTMLElement;\n const panel = byAction.get(trigger.dataset.action ?? '');\n if (!panel) return;\n returnFocus.set(panel.id, trigger);\n const next = !panel.collapsed.value;\n panel.collapsed.value = next;\n if (!next && exclusiveCompact && overlayActive()) {\n for (const other of panels)\n if (other !== panel) other.collapsed.value = true;\n }\n }),\n );\n\n // Where focus goes when a panel closes: the trigger that toggled it, unless\n // that trigger sits inside the now-hidden panel (the documented\n // collapse-inside placement) or was re-rendered away. Then it goes to the\n // panel's toggle outside it — the always-visible expand toggle, which an app\n // may render only while the panel is collapsed. Returns whether it moved.\n const restoreFocus = (panel: WireSidebarPanel): boolean => {\n const element = panelElement(panel.id);\n const trigger = returnFocus.get(panel.id);\n const target =\n trigger?.isConnected && !element?.contains(trigger)\n ? trigger\n : [...root.querySelectorAll<HTMLElement>('[data-action]')].find(\n (candidate) =>\n candidate.dataset.action === panel.toggleAction &&\n !element?.contains(candidate),\n );\n target?.focus();\n return target !== undefined;\n };\n\n // Focus: move into the panel when it opens; restore it when it closes.\n for (const panel of panels) {\n let previous = panel.collapsed.value;\n disposers.push(\n effect(() => {\n const collapsed = panel.collapsed.value;\n if (collapsed === previous) return;\n previous = collapsed;\n const element = panelElement(panel.id);\n if (adapting.delete(panel)) {\n // A presentation change, not a user action: only rescue focus that\n // the collapse would strand inside the now-hidden panel.\n if (collapsed && element?.contains(ownerDocument.activeElement))\n restoreFocus(panel);\n return;\n }\n const replaced =\n deviceClass?.value.compact && compactPresentation === 'hidden';\n if (!collapsed && element && !replaced) {\n // Without preventScroll the browser scrolls the panel's clipped,\n // still-sliding content to reveal the focus target, which then\n // unwinds as the slide finishes: the content jumps and drifts back.\n (focusables(element)[0] ?? element).focus({ preventScroll: true });\n } else if (collapsed && !restoreFocus(panel)) {\n // An app that renders after this effect has not produced its\n // expand toggle yet; look again once the current batch settles.\n globalThis.queueMicrotask(() => {\n if (panel.collapsed.peek()) restoreFocus(panel);\n });\n }\n }),\n );\n }\n\n // Compact overlay: a dismissable backdrop + Escape + focus trap while compact\n // and a panel is open.\n if (deviceClass) {\n let backdrop: HTMLButtonElement | undefined;\n // The host state this wire owns, recorded so it can be re-applied: the\n // app re-renders (and morphs) the root for reasons that never touch a\n // panel signal (a nav selection, say), and a morph strips attributes and\n // nodes the template doesn't emit.\n let hostResponsive: string | undefined;\n let hostOverlay: string | undefined;\n let backdropFor: string | undefined;\n const openPanel = (): WireSidebarPanel | undefined =>\n panels.find((panel) => !panel.collapsed.value);\n const removeBackdrop = (): void => {\n backdrop?.remove();\n backdrop = undefined;\n backdropFor = undefined;\n };\n const setHostData = (\n name: 'collapsibleResponsive' | 'collapsibleOverlay',\n value: string | undefined,\n ): void => {\n if (root.dataset[name] === value) return;\n if (value === undefined) delete root.dataset[name];\n else root.dataset[name] = value;\n };\n // Idempotent, so the observer below settles after one corrective write.\n const syncHost = (): void => {\n setHostData('collapsibleResponsive', hostResponsive);\n setHostData('collapsibleOverlay', hostOverlay);\n if (!backdrop || backdropFor === undefined) return;\n const element = panelElement(backdropFor);\n if (element && backdrop.nextElementSibling !== element)\n element.parentElement?.insertBefore(backdrop, element);\n };\n disposers.push(\n effect(() => {\n const compact = deviceClass.value.compact;\n const overlay = compact && compactPresentation === 'overlay';\n // A crossing adapts the panels here, in the same effect that owns the\n // host state. Every panel's state is read so an open or close moves\n // the backdrop; re-renders that touch no panel are covered by the\n // observer below.\n for (const panel of panels) void panel.collapsed.value;\n if (overlay && !inlineState) {\n inlineState = new Map(\n panels.map((panel) => [panel, panel.collapsed.peek()]),\n );\n setAll(() => true);\n } else if (!overlay) {\n restoreInline();\n }\n const open = overlay\n ? panels.find((panel) => !panel.collapsed.peek())\n : undefined;\n hostResponsive = compact ? compactPresentation : 'inline';\n hostOverlay = compact ? String(overlay) : undefined;\n if (open) {\n if (!backdrop) {\n backdrop = ownerDocument.createElement('button');\n backdrop.type = 'button';\n backdrop.className = 'kui-collapsible-panel__backdrop';\n backdrop.setAttribute('aria-label', 'Close');\n // Injected after render: the morph's removal pass must skip it.\n backdrop.dataset.morphPreserve = '';\n backdrop.addEventListener('click', () => {\n const current = openPanel();\n if (current) collapse(current);\n });\n }\n backdropFor = open.id;\n } else {\n removeBackdrop();\n }\n syncHost();\n }),\n );\n const observer = new MutationObserver(syncHost);\n observer.observe(root, {\n attributes: true,\n attributeFilter: [\n 'data-collapsible-responsive',\n 'data-collapsible-overlay',\n ],\n childList: true,\n subtree: true,\n });\n disposers.push(() => observer.disconnect());\n\n const onKeydown = (event: KeyboardEvent): void => {\n if (!deviceClass.value.compact || compactPresentation !== 'overlay')\n return;\n const open = openPanel();\n if (!open) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n collapse(open);\n return;\n }\n if (event.key !== 'Tab') return;\n const element = panelElement(open.id);\n if (!element) return;\n const items = focusables(element);\n if (items.length === 0) return;\n const first = items[0]!;\n const last = items[items.length - 1]!;\n const active = ownerDocument.activeElement;\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first.focus();\n }\n };\n ownerDocument.addEventListener('keydown', onKeydown);\n disposers.push(() =>\n ownerDocument.removeEventListener('keydown', onKeydown),\n );\n disposers.push(removeBackdrop);\n }\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose();\n // After every effect is gone, so handing the inline state back cannot\n // re-create overlay chrome or persist anything.\n restoreInline();\n delete root.dataset.collapsibleOverlay;\n delete root.dataset.collapsibleResponsive;\n };\n}\n"]}
|
package/docs/accessibility.md
CHANGED
|
@@ -10,7 +10,7 @@ These are normative contracts for package components and consuming applications.
|
|
|
10
10
|
- `ListHeader` owns action naming, disabled state, and disclosure `aria-expanded`. Toggle mode supplies one decorative 18px `DisclosureArrow` when `actionIcon` is omitted; it mirrors `expanded` while the native button's accessible name stays stable. The application must update that controlled state and reveal or hide real content. A valid `count` is visually rendered in an `aria-hidden` neutral pill while the required localized `countLabel` becomes part of the owning heading or disclosure button's accessible name; zero remains a real count. Its narrow `triggerAttributes` slot may describe a native popover or controlled-content relationship with `popoverTarget`, `popoverTargetAction`, `aria-controls`, and `aria-haspopup`; the application owns the target surface and its focus/dismissal behavior.
|
|
11
11
|
- Dispose `wireTabBars` and `wireTokenSearchFields` with their owning view. Disposal cancels queued controlled-render focus restoration, so a torn-down view cannot reclaim focus from its successor.
|
|
12
12
|
- `wireNavStack` moves focus into every newly active top view and remembers the last focused descendant of each mounted view for pop restoration. Mark a preferred initial heading or control with `data-nav-focus` (and `tabindex="-1"` when it is not normally tabbable); otherwise the helper uses the first enabled focusable descendant, then a programmatically focusable view-container fallback.
|
|
13
|
-
- In any `.kui-pane`, menu rows, `ListHeader` actions, and toolbar groups keep a minimum 44px target in both dimensions. A `ListHeader` fills the available inline width and keeps that action at the logical end, including in RTL. Its action visual defaults to 18px through `--kui-list-header-action-icon-size
|
|
13
|
+
- In any `.kui-pane`, menu rows, `ListHeader` actions, and toolbar groups keep a minimum 44px target in both dimensions. A `ListHeader` fills the available inline width and keeps that action at the logical end, including in RTL. Its action visual defaults to 18px through `--kui-list-header-action-icon-size` inside a fitted 36px hover square; a transparent hit layer extends the pointer target to at least 44 × 44 around that square without moving layout or enlarging the painted surface. Compact headers are 36px tall like the rest of the compact List family, so their action target is 44px wide and clamped to the 36px header height instead of reaching into a neighboring row. Do not reduce the target to the visible icon, and do not force the button itself to 44px.
|
|
14
14
|
- `LoadingSpinner` is either labeled (`role="img"`) or hidden. Its rotation stops for reduced motion.
|
|
15
15
|
- `StateBanner` defaults to polite `role="status"`; use `urgency="alert"` only for an attention-requiring failure. Its optional `badge` is persistent inline status/count content beside the title and is announced as part of the banner; keep it terse and do not rely on color alone for its meaning.
|
|
16
16
|
- `EmptyState` reports busy state through `aria-busy` and never relies on an illustration as its label.
|
|
@@ -29,10 +29,23 @@ adding height or spacing. This also applies to `renderSelected`: selected conten
|
|
|
29
29
|
stays separate from the control's stable accessible name. Applications do not
|
|
30
30
|
need shadow-DOM patches or extra label styling.
|
|
31
31
|
|
|
32
|
-
Use `presentation="toolbar-borderless"`
|
|
33
|
-
`
|
|
34
|
-
composed focus ring. `selectedPresentation="icon-only"` hides
|
|
35
|
-
selected label—the required `label` or `ariaLabel` still names
|
|
32
|
+
Use `presentation="toolbar-borderless"` inside a `ToolbarControlGroup`, with
|
|
33
|
+
`size="compact"` in a compact group; set `focusRingOwner="group"` when that
|
|
34
|
+
parent paints the composed focus ring. `selectedPresentation="icon-only"` hides
|
|
35
|
+
only the visible selected label—the required `label` or `ariaLabel` still names
|
|
36
|
+
the combobox. An icon-only trigger keeps its disclosure caret: the selected icon
|
|
37
|
+
and caret form one pill with the same geometry as a popup-menu `wa-dropdown`
|
|
38
|
+
trigger (8px inline padding on both sides, 12px from the icon to the caret, a
|
|
39
|
+
40px default or 32px compact control height), so the caret still signals a
|
|
40
|
+
choice list. Inside a group the trigger is inset evenly on every side (2px in a
|
|
41
|
+
default group, 1px in a compact one) with a concentric radius; the group grows
|
|
42
|
+
to the trigger's width, so a single group is wider than it is tall and its
|
|
43
|
+
focus ring follows the pill while focused and while its listbox is open.
|
|
44
|
+
Beside other controls in one group, the icon-only trigger is one more segment:
|
|
45
|
+
it keeps the group's gap and its siblings' block inset, height, item radius,
|
|
46
|
+
and hover pill. In a group with per-control rings (`focusRing="control"`, the
|
|
47
|
+
default) it paints the same ring as its sibling buttons, even when
|
|
48
|
+
`focusRingOwner="group"` was set, because no group ring would show focus.
|
|
36
49
|
Navigation selects can use intrinsic `presentation="navigation"` plus
|
|
37
50
|
`labelMaxWidth` for component-owned ellipsis. These props own the control's
|
|
38
51
|
appearance; its parent continues to own outer placement.
|
|
@@ -96,7 +109,27 @@ The handle exposes separator role, orientation, name, minimum, maximum, and curr
|
|
|
96
109
|
- Shift+Arrow in 64 px steps;
|
|
97
110
|
- Home and End for minimum and maximum;
|
|
98
111
|
- primary-pointer drag with clamping;
|
|
99
|
-
-
|
|
112
|
+
- a visible maximum: when a parent clamps the track below the requested size
|
|
113
|
+
(a `max-width` stage, a narrow container), pointer and keyboard resizing stop
|
|
114
|
+
at the size actually shown, and the handle announces that size in
|
|
115
|
+
`aria-valuenow` and that bound in `aria-valuemax`. Focusing the handle
|
|
116
|
+
reports a stale larger committed size as the shown size without committing
|
|
117
|
+
it, so the first key press moves the visible separator. The reported values
|
|
118
|
+
also stay current at rest: a `ResizeObserver` on each region and its parent
|
|
119
|
+
re-clamps them when the space changes, and a `MutationObserver` re-clamps
|
|
120
|
+
them after a re-render writes the rendered props back, so assistive
|
|
121
|
+
technology never reads an unclamped size between interactions (the
|
|
122
|
+
disposer disconnects both observers);
|
|
123
|
+
- a whole hit target and focus ring at a clamped edge: when the separator has
|
|
124
|
+
less room past it than the handle's 10px overhang (a parent clamps the track
|
|
125
|
+
to its own edge), the region carries `data-handle-inset` and the handle sits
|
|
126
|
+
wholly inside it, so a clipping ancestor cannot cut off half the handle or
|
|
127
|
+
its focus ring. The ring is drawn inside the handle's box, so the region's
|
|
128
|
+
ends meeting a clipping edge never shave it either;
|
|
129
|
+
- preview callbacks while dragging and one commit callback on release;
|
|
130
|
+
- a live size that also resizes slide-motion content, so the content tracks the
|
|
131
|
+
separator during a drag or key press instead of waiting for the app to
|
|
132
|
+
re-render the committed size.
|
|
100
133
|
|
|
101
134
|
The application owns persistence and collapsed/expanded policy. Keep the last expanded size outside the component and restore it when reopening. An optional `handleIcon` replaces only decorative dormant content; it must not contain controls or interactive roles because the separator remains the sole focus and interaction owner.
|
|
102
135
|
|
|
@@ -145,7 +178,10 @@ trailing, and end actions remain visible and do not shrink. The component owns
|
|
|
145
178
|
that geometry; applications must not override its anatomy to create the split.
|
|
146
179
|
`AppTab` provides compact 32px, segmented, truncating-label, and icon-only
|
|
147
180
|
presentations. Icon-only tabs keep the required `name` as the tab button's
|
|
148
|
-
accessible name while visually hiding the duplicate label.
|
|
181
|
+
accessible name while visually hiding the duplicate label. A `pending` tab (known,
|
|
182
|
+
still opening) keeps its visible name as the tab button's accessible name, so a
|
|
183
|
+
trailing spinner's own label is not folded into it; like `placeholder`, it is
|
|
184
|
+
`aria-busy`, disabled, out of the tab order, and not draggable. These props own
|
|
149
185
|
component appearance only; the application still owns the bar's outer placement.
|
|
150
186
|
`AppTab.rootAttributes` accepts application `data-*` metadata only; runtime
|
|
151
187
|
filtering rejects roles plus case variants of component- or wiring-owned
|
|
@@ -247,9 +283,13 @@ standard per-side collapse/expand glyph (`PanelLeft*` / `PanelRight*` /
|
|
|
247
283
|
`PanelBottom*`); place a collapse toggle inside the panel and an expand toggle in
|
|
248
284
|
an always-visible location so it is reachable while collapsed. `wireSidebar()`
|
|
249
285
|
moves focus into the panel when it opens and restores it to the trigger when it
|
|
250
|
-
closes
|
|
286
|
+
closes; a trigger inside the now-hidden panel hands focus to the panel's toggle
|
|
287
|
+
outside it instead, so an app may render the expand toggle only while the panel
|
|
288
|
+
is collapsed. When a `deviceClass()` reports `compact`, an open panel becomes an overlay
|
|
251
289
|
with a dismissable backdrop, Escape and backdrop-click collapse it, and Tab is
|
|
252
|
-
trapped within it (the ARIA dialog pattern).
|
|
290
|
+
trapped within it (the ARIA dialog pattern). That modal state only ever begins
|
|
291
|
+
from a user action: at wire-up on a compact device and on a wide → compact
|
|
292
|
+
crossing, every panel starts collapsed, so no page load or resize traps focus. The application owns each `collapsed`
|
|
253
293
|
signal, the panels, their sizes, and content; the wire may persist the collapsed
|
|
254
294
|
state per panel.
|
|
255
295
|
|
package/docs/app-layouts.md
CHANGED
|
@@ -19,6 +19,79 @@ Derive responsiveness from `deviceClass()`: `compact` (a handset or portrait
|
|
|
19
19
|
tablet) means "one pane at a time"; `atLeast('tablet')` / `atLeast('desktop')`
|
|
20
20
|
gate the roomier presentations.
|
|
21
21
|
|
|
22
|
+
Full-height shells retain `height: 100%` so they remain embeddable. Give a
|
|
23
|
+
top-level shell a definite height chain by importing `@kerfjs/ui/document.css`
|
|
24
|
+
and applying `.kui-app-root` to the direct mount container; see the
|
|
25
|
+
[document baseline](document-baseline.md). Do not replace an embeddable shell's
|
|
26
|
+
height with `100dvh`.
|
|
27
|
+
|
|
28
|
+
## Safe areas
|
|
29
|
+
|
|
30
|
+
On a device with unsafe areas (a notch, rounded corners, a home indicator) and a
|
|
31
|
+
page that opts into them with `<meta name="viewport" content="…, viewport-fit=cover">`,
|
|
32
|
+
the layouts handle the insets for you. There is nothing to configure:
|
|
33
|
+
|
|
34
|
+
- **Surfaces paint edge to edge.** Pane and panel backgrounds, separators, and
|
|
35
|
+
dividers run through the unsafe area. A collapsible rail or drawer grows by
|
|
36
|
+
the inset it reaches, so its content keeps its configured width or height.
|
|
37
|
+
- **Content is padded on the sides it touches.** Inline insets pad the sides a
|
|
38
|
+
region actually reaches. Block insets become padding _inside_ the scroll
|
|
39
|
+
container (with matching `scroll-padding`), so content scrolls under the
|
|
40
|
+
status bar or home indicator but its first and last items can always be
|
|
41
|
+
scrolled clear.
|
|
42
|
+
- **Interior edges get nothing.** A `Workbench` center next to an expanded rail,
|
|
43
|
+
a `SplitView` detail beside its list, or a `NavStack` view under its chrome is
|
|
44
|
+
not inset on that edge. Collapse the rail and the center picks up the edge.
|
|
45
|
+
- **No double inset.** Whichever region applies an inset clears it for its
|
|
46
|
+
descendants. A layout region whose only child is a `Pane` or another layout
|
|
47
|
+
lets that child own the insets. A `Pane` header or footer whose only child is
|
|
48
|
+
a `Toolbar` hands the inline insets to the toolbar, so the toolbar's dividers
|
|
49
|
+
still reach the edge.
|
|
50
|
+
|
|
51
|
+
A plain `Pane` follows the same rules. It assumes it may touch every screen edge
|
|
52
|
+
unless its layout says otherwise. When your own markup puts panes side by side,
|
|
53
|
+
say which sides each pane reaches with `safeAreaEdges`:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<div class="app-columns">
|
|
57
|
+
<Pane label="Library" safeAreaEdges={['block-start', 'block-end', 'inline-start']}>…</Pane>
|
|
58
|
+
<Pane label="Reader" safeAreaEdges={['block-start', 'block-end', 'inline-end']}>…</Pane>
|
|
59
|
+
</div>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
An app bar or bottom bar that sits directly at a screen edge, outside any
|
|
63
|
+
`Pane` header or footer, claims its edges the same way. A `Toolbar` only pads
|
|
64
|
+
the inline edges an owner hands it until you list the sides it reaches:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
<List fill>
|
|
68
|
+
<Toolbar
|
|
69
|
+
label="App bar"
|
|
70
|
+
safeAreaEdges={['block-start', 'inline-start', 'inline-end']}
|
|
71
|
+
leading={<ToolbarText text="Atlas" />}
|
|
72
|
+
/>
|
|
73
|
+
<Row gap="none" flex>…panes that list block-end and their outer side…</Row>
|
|
74
|
+
</List>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each claimed side pads by the edge context, or the full device inset when
|
|
78
|
+
nothing routes that edge, while the bar's surface and divider still reach the
|
|
79
|
+
screen edge. A claimed toolbar inside a `Pane` header or footer adds nothing
|
|
80
|
+
extra, because the pane already owns those edges.
|
|
81
|
+
|
|
82
|
+
Pass `safeAreaEdges={[]}` for a pane that never sits at a screen edge. For a
|
|
83
|
+
region you own, set `--kui-edge-inset-block-start`, `--kui-edge-inset-block-end`,
|
|
84
|
+
`--kui-edge-inset-inline-start`, or `--kui-edge-inset-inline-end` to `0px` on the
|
|
85
|
+
region for each edge it does not reach, and kerf descendants follow. A
|
|
86
|
+
`CollapsiblePanel` does this for its direct flex siblings automatically. A
|
|
87
|
+
panel wrapped in your own grid cell cannot see its siblings, so route those
|
|
88
|
+
edges yourself.
|
|
89
|
+
|
|
90
|
+
The device insets come from `--kui-safe-area-block-start`, `-block-end`,
|
|
91
|
+
`-inline-start`, and `-inline-end`, which default to `env(safe-area-inset-*)`.
|
|
92
|
+
Override them on `:root` to reserve room for app-owned chrome or to simulate a
|
|
93
|
+
device in tests.
|
|
94
|
+
|
|
22
95
|
## Decision matrix
|
|
23
96
|
|
|
24
97
|
| Situation | Layout | Device threshold |
|
package/docs/catalog.md
CHANGED
|
@@ -91,6 +91,18 @@ own overlay implementation.
|
|
|
91
91
|
generated note, then one specimen or one intentionally coupled specimen
|
|
92
92
|
cluster. Use one row per variant/state; do not hand-author the helper's private
|
|
93
93
|
classes.
|
|
94
|
+
- Focused demos do not import local stylesheets, use inline `style`, or invent
|
|
95
|
+
styling-only `demo-*` classes. Prefer a public Kerf component, then a Web
|
|
96
|
+
Awesome surface when Kerf has no equivalent, then ordinary semantic HTML.
|
|
97
|
+
Use `CatalogExample.viewport` for bounded specimen width, height, frame,
|
|
98
|
+
surface, overflow, responsive visibility, and public custom-property values;
|
|
99
|
+
keep those constraints catalog-owned instead of recreating a demo stylesheet.
|
|
100
|
+
Component heights run `short` (220px) through `tall` (360px); frame an app
|
|
101
|
+
shell, whole-screen layout, or other application-sized recipe with
|
|
102
|
+
`height: "app"` (592px), and give its root `fill` rather than a frame `Pane`.
|
|
103
|
+
An app frame stands in for the screen: it is the containing block for
|
|
104
|
+
screen-fixed chrome, so a compact `CollapsiblePanel` overlay, its backdrop,
|
|
105
|
+
and restore controls dock to the frame's edges rather than the page's.
|
|
94
106
|
- The specimen is an immediate child of `CatalogExample`. A focused component
|
|
95
107
|
row should place the component root there, without a decorative card or
|
|
96
108
|
spacing wrapper. A composition row may place the composition root there.
|
|
@@ -143,6 +155,9 @@ from structurally widened or JavaScript objects. Do not copy the helpers'
|
|
|
143
155
|
private `kui-catalog-*` classes into preview markup.
|
|
144
156
|
|
|
145
157
|
The app owns entry ids, `kind`, routing, sources, relationships, and test hooks.
|
|
158
|
+
`Catalog.stageRootAttributes` places app-owned `data-*` state on the
|
|
159
|
+
catalog-owned preview stage without an extra wrapper; `data-catalog-stage`
|
|
160
|
+
remains protected.
|
|
146
161
|
The helpers own their structural markers, label/note anatomy, alignment marker,
|
|
147
162
|
and private classes. Component metadata such as margin/border/padding ownership
|
|
148
163
|
lives in `component-catalog.json`; do not infer or overwrite it from overlay
|
|
@@ -204,7 +219,13 @@ facts that source can prove without guessing at rendered intent:
|
|
|
204
219
|
are not empty, and composition routes do not add redundant skip markers;
|
|
205
220
|
- every `@kerfjs/ui` import is a published package export and relative imports
|
|
206
221
|
do not reach into `ui/src`;
|
|
207
|
-
-
|
|
222
|
+
- focused demos have no relative stylesheet imports, inline style attributes,
|
|
223
|
+
or styling-only `demo-*`, `wa-demo-*`, and token-search class names;
|
|
224
|
+
- demo JSX does not copy private `kui-catalog-*` structural classes;
|
|
225
|
+
- recipe sources (checked by `npm run check:recipes` with the same shared
|
|
226
|
+
analyzer) import no stylesheet other than public `@kerfjs/ui` CSS, set no
|
|
227
|
+
inline style, and name only published layout and Web Awesome classes; each
|
|
228
|
+
recipe declares its catalog frame through an exported `presentation`; and
|
|
208
229
|
- the shell derives both geometry-overlay enablement and documented demo mode
|
|
209
230
|
from the active entry's source and kind.
|
|
210
231
|
|
|
@@ -217,7 +238,7 @@ stage geometry is itself the reviewed specimen. Add the narrow waiver to
|
|
|
217
238
|
[`catalog-conformance-exceptions.json`](../ux-demo/catalog-conformance-exceptions.json)
|
|
218
239
|
with the exact route, source file, stable diagnostic ids, a substantive reason,
|
|
219
240
|
and the reviewing `KF-*` ticket. Only helper/metadata rules are waivable;
|
|
220
|
-
private imports, private markup, empty examples, composition overlay drift, and
|
|
241
|
+
private imports, local styling, private markup, empty examples, composition overlay drift, and
|
|
221
242
|
shell-mode drift always fail. The gate rejects duplicate, malformed, unused,
|
|
222
243
|
and stale exceptions, so delete a waiver when its route adopts the standard
|
|
223
244
|
helpers.
|
|
@@ -37,25 +37,86 @@ Import the panel CSS (`@kerfjs/ui/collapsible-panel.css`) alongside `foundation.
|
|
|
37
37
|
is the app's: put a collapse toggle in the panel's own header and an expand toggle
|
|
38
38
|
somewhere always-visible (a toolbar) so it is reachable while collapsed.
|
|
39
39
|
- **`wireSidebar(root, { panels, deviceClass?, storage? })`** — the interaction
|
|
40
|
-
semantics. Each `panels` entry is
|
|
41
|
-
It:
|
|
40
|
+
semantics. Each `panels` entry is
|
|
41
|
+
`{ id, collapsed, toggleAction, storageKey?, inlineCollapsed? }`. It:
|
|
42
42
|
- **toggles** the panel's `collapsed` signal when any `[data-action=toggleAction]`
|
|
43
43
|
button is clicked, and remembers the trigger;
|
|
44
|
-
- **manages focus** — moves focus into the panel when it opens
|
|
45
|
-
|
|
44
|
+
- **manages focus** — moves focus into the panel when it opens (without
|
|
45
|
+
scrolling, so the sliding content doesn't jump), and restores it
|
|
46
|
+
to the trigger when it closes. When that trigger is the collapse toggle inside
|
|
47
|
+
the now-hidden panel, focus goes to the panel's toggle outside it instead, so
|
|
48
|
+
an app may render its expand toggle only while the panel is collapsed (one
|
|
49
|
+
control per action, as the recipe does);
|
|
46
50
|
- **presents a compact overlay** when `deviceClass.compact` is true (pass a
|
|
47
51
|
`deviceClass()` signal): the open panel floats over the content with a
|
|
48
52
|
dismissable backdrop, Escape and backdrop-click collapse it, and Tab is trapped
|
|
49
|
-
within the panel (the ARIA dialog pattern)
|
|
53
|
+
within the panel (the ARIA dialog pattern). The overlay and its backdrop are
|
|
54
|
+
fixed to the screen; an ancestor that establishes a containing block for
|
|
55
|
+
fixed descendants (such as the catalog's `height: "app"` frame) docks them to
|
|
56
|
+
its edges instead;
|
|
50
57
|
- accepts `compactPresentation: "hidden"` when a compact application replaces
|
|
51
58
|
the panel with different navigation instead of overlaying it;
|
|
59
|
+
- **starts every compact overlay closed** (see
|
|
60
|
+
[Compact initial state](#compact-initial-state));
|
|
52
61
|
- keeps compact overlays exclusive by default, collapsing another open panel
|
|
53
62
|
when a new one opens (`exclusiveCompact: false` opts out);
|
|
54
|
-
- **persists** the collapsed state to `storage` (default
|
|
55
|
-
`storageKey`, seeding the signal on wire-up.
|
|
63
|
+
- **persists** the inline collapsed state to `storage` (default
|
|
64
|
+
`localStorage`) under `storageKey`, seeding the signal on wire-up. An
|
|
65
|
+
overlay's open/closed state is never persisted;
|
|
66
|
+
- **owns the root's `data-collapsible-responsive` / `data-collapsible-overlay`
|
|
67
|
+
attributes and the injected backdrop.** Your app may re-render (and morph)
|
|
68
|
+
the wired root for reasons that never touch a panel signal — a nav
|
|
69
|
+
selection, say — and the morph drops attributes and nodes its template
|
|
70
|
+
doesn't emit. The wire observes the root and re-applies them before the
|
|
71
|
+
next paint, so don't render these attributes yourself.
|
|
56
72
|
|
|
57
73
|
Returns a disposer. Retain it and call it on teardown.
|
|
58
74
|
|
|
75
|
+
## Compact initial state
|
|
76
|
+
|
|
77
|
+
A compact overlay is transient chrome the user summons, like a slide-over
|
|
78
|
+
sidebar on a phone: it covers the content, traps focus, and must be dismissed.
|
|
79
|
+
It therefore opens **only on a user action** (a toggle, or the app setting the
|
|
80
|
+
signal in response to one), never by itself:
|
|
81
|
+
|
|
82
|
+
- **Wire-up on a compact device.** Every panel starts collapsed, whatever its
|
|
83
|
+
signal, `inlineCollapsed`, or stored choice says. No backdrop, focus trap, or
|
|
84
|
+
Escape handling is active until the user opens a panel.
|
|
85
|
+
- **Wide → compact crossing.** Open panels collapse; nothing covers the page.
|
|
86
|
+
- **Compact → wide crossing.** Each panel returns to its remembered inline state
|
|
87
|
+
(open or collapsed), whatever the user did with the overlay meanwhile.
|
|
88
|
+
- **Disposal** hands the inline state back to the signals.
|
|
89
|
+
- **Persistence** records only the inline choice. A stored "open" rail restores
|
|
90
|
+
inline on a wide screen and is remembered, not opened, on a compact one.
|
|
91
|
+
|
|
92
|
+
These presentation changes never move focus, except to rescue focus that a
|
|
93
|
+
collapse would strand inside the hidden panel (it returns to the panel's last
|
|
94
|
+
trigger). `compactPresentation: "hidden"` leaves the signals alone.
|
|
95
|
+
|
|
96
|
+
`wireSidebar` runs after the first render, so an app that seeds an open inline
|
|
97
|
+
default would render the rail open for that first frame on a compact device.
|
|
98
|
+
Seed the signal from the device class instead, and declare the inline default
|
|
99
|
+
with `inlineCollapsed` so a later wide crossing still opens it:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const device = deviceClass();
|
|
103
|
+
const navCollapsed = signal(device.value.compact);
|
|
104
|
+
// …
|
|
105
|
+
wireSidebar(app, {
|
|
106
|
+
panels: [
|
|
107
|
+
{
|
|
108
|
+
id: "nav",
|
|
109
|
+
collapsed: navCollapsed,
|
|
110
|
+
toggleAction: "toggle-nav",
|
|
111
|
+
inlineCollapsed: false,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
deviceClass: device,
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A stored inline choice takes precedence over `inlineCollapsed`.
|
|
119
|
+
|
|
59
120
|
## Example
|
|
60
121
|
|
|
61
122
|
```tsx
|
|
@@ -68,8 +129,10 @@ import {
|
|
|
68
129
|
import { wireSidebar } from "@kerfjs/ui/wire-sidebar";
|
|
69
130
|
import "@kerfjs/ui/collapsible-panel.css";
|
|
70
131
|
|
|
71
|
-
const navCollapsed = signal(false);
|
|
72
132
|
const device = deviceClass();
|
|
133
|
+
// Start collapsed on a compact device; `inlineCollapsed` keeps the open inline
|
|
134
|
+
// default for wide screens.
|
|
135
|
+
const navCollapsed = signal(device.value.compact);
|
|
73
136
|
|
|
74
137
|
const app = document.querySelector("#app")!;
|
|
75
138
|
mount(app, () => (
|
|
@@ -111,12 +174,22 @@ const stop = wireSidebar(app, {
|
|
|
111
174
|
collapsed: navCollapsed,
|
|
112
175
|
toggleAction: "toggle-nav",
|
|
113
176
|
storageKey: "app.nav-collapsed",
|
|
177
|
+
inlineCollapsed: false,
|
|
114
178
|
},
|
|
115
179
|
],
|
|
116
180
|
deviceClass: device,
|
|
117
181
|
});
|
|
118
182
|
```
|
|
119
183
|
|
|
184
|
+
## Safe areas
|
|
185
|
+
|
|
186
|
+
A panel grows by the unsafe inset of the edge it docks to and pads its content
|
|
187
|
+
for every edge it touches except its interior edge. While expanded inline, it
|
|
188
|
+
also clears that edge for its direct flex siblings, so a neighboring pane gains
|
|
189
|
+
the inset when the panel collapses. When you wrap the panel in your own grid
|
|
190
|
+
cell, set `--kui-edge-inset-*: 0px` on the sibling regions yourself (or pass
|
|
191
|
+
`safeAreaEdges` to a sibling `Pane`). See [Choosing an app layout › Safe areas](app-layouts.md#safe-areas).
|
|
192
|
+
|
|
120
193
|
## When to use which
|
|
121
194
|
|
|
122
195
|
- One or two independent rails / a drawer you place yourself → **`CollapsiblePanel` +
|
|
@@ -138,5 +211,6 @@ persistence: [open it](../ux-demo/?component=recipe-collapsible-sidebar) or read
|
|
|
138
211
|
[`recipes.md`](recipes.md#collapsible-sidebar) · [TSX source](../ux-demo/recipes/collapsible-sidebar.tsx).
|
|
139
212
|
It is covered end-to-end across Chromium, Firefox, and WebKit by
|
|
140
213
|
`tests/browser/collapsible-sidebar-recipe.spec.ts` (collapse/expand, focus
|
|
141
|
-
move/restore, the compact overlay + Escape/backdrop dismiss,
|
|
214
|
+
move/restore, the compact overlay + Escape/backdrop dismiss, the Tab trap, and
|
|
215
|
+
the compact initial state across first load and wide/compact crossings),
|
|
142
216
|
alongside the component/wire unit tests.
|