estreui 1.4.0 → 1.5.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/README.md +8 -8
- package/index.html +9 -0
- package/package.json +2 -2
- package/scripts/estreUi-core.js +2 -0
- package/scripts/estreUi-main.js +1036 -76
- package/scripts/estreUi-pageModel.js +90 -0
- package/serviceWorker.js +6 -3
- package/styles/estreUiCore.css +355 -4
- package/vectors/cover-icon-default-instant.svg +3 -0
- package/vectors/cover-icon-default-overlay.svg +3 -0
- package/vectors/cover-icon-default-static.svg +5 -0
package/scripts/estreUi-main.js
CHANGED
|
@@ -49,6 +49,12 @@ const estreUi = {
|
|
|
49
49
|
panelCurrentOnTop: null,
|
|
50
50
|
headerCurrentOnTop: null,
|
|
51
51
|
|
|
52
|
+
// External back handler stack — host-mounted external embeds register navigation
|
|
53
|
+
// steps here so native back input flows through them before EstreUI's section
|
|
54
|
+
// stack. Roadmap #011 / push-pop API.
|
|
55
|
+
externalBackStack: [],
|
|
56
|
+
nextBackHandlerToken: 1,
|
|
57
|
+
|
|
52
58
|
//static getter
|
|
53
59
|
get currentTopComponent() {
|
|
54
60
|
return this.blindedCurrentOnTop ?? (this.isOpenMainMenu ? this.menuCurrentOnTop : null) ?? this.mainCurrentOnTop;
|
|
@@ -109,6 +115,7 @@ const estreUi = {
|
|
|
109
115
|
$grabArea: null,
|
|
110
116
|
|
|
111
117
|
$overwatchPanel: null,
|
|
118
|
+
$topLayer: null,
|
|
112
119
|
get $panelSections() { return this.$panelBlock?.find(c.c + se + uis.blockItem) ?? $(); },
|
|
113
120
|
$panelHeader: null,
|
|
114
121
|
$panelHost: null,
|
|
@@ -197,6 +204,43 @@ const estreUi = {
|
|
|
197
204
|
},
|
|
198
205
|
get isDarkMode() { return document.body.dataset.darkMode == t1; },
|
|
199
206
|
|
|
207
|
+
// Cover bar — desktop-class viewport with a pointing device that can hover.
|
|
208
|
+
// Static environment check; matches the media query that gates the cover
|
|
209
|
+
// bar UI surface in CSS. See roadmap entry (forthcoming) for the wider
|
|
210
|
+
// host-integration design.
|
|
211
|
+
get isWideHoverViewport() {
|
|
212
|
+
return matchMedia("(min-width: 1025px) and (min-height: 769px) and (hover: hover) and (pointer: fine)").matches;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
// Cover bar — whether the rootbar is currently in its extended layout, i.e.,
|
|
216
|
+
// the non-rootbar nav siblings under fixedBottom (`customFixedSections`,
|
|
217
|
+
// `instantSections`) take flex-grow:1 per estreUiCore.css's `@media all and
|
|
218
|
+
// (min-height: 700px) and (min-width: 740px)` block, AND the cover-bar
|
|
219
|
+
// handle is initialized. Combines static media-query state with dynamic
|
|
220
|
+
// bootstrap state.
|
|
221
|
+
get rootBarExtended() {
|
|
222
|
+
if (this.coverBarHandle == null) return false;
|
|
223
|
+
const fb = this.$fixedBottom?.[0] ?? this.$fixedBottom;
|
|
224
|
+
const nav = fb?.querySelector?.("nav:not(#rootbar)");
|
|
225
|
+
if (nav == null) return false;
|
|
226
|
+
return getComputedStyle(nav).flexGrow === "1";
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
// Cover bar — composite readiness: the environment supports the bar (wide
|
|
230
|
+
// viewport with hover) AND the rootbar is in its extended layout with a
|
|
231
|
+
// handle already initialized. Cover entries should only be pushed when
|
|
232
|
+
// this returns true; the handle itself is idempotent against pushes that
|
|
233
|
+
// arrive while still false.
|
|
234
|
+
get isInstantBarReady() {
|
|
235
|
+
return this.isWideHoverViewport && this.rootBarExtended;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
// Placeholder for the cover-bar handle introduced in Phase 1C. Held at null
|
|
239
|
+
// so rootBarExtended can short-circuit before initialization. Naming follows
|
|
240
|
+
// EstreUI's stock handle convention (EstreHandle, EstreSwipeHandler, etc.)
|
|
241
|
+
// even though the cover bar isn't DOM-attached the same way.
|
|
242
|
+
coverBarHandle: null,
|
|
243
|
+
|
|
200
244
|
|
|
201
245
|
|
|
202
246
|
//links (object redirection)
|
|
@@ -225,6 +269,8 @@ const estreUi = {
|
|
|
225
269
|
|
|
226
270
|
this.$fixedBottom = $("#fixedBottom");
|
|
227
271
|
|
|
272
|
+
this.$topLayer = $("#topLayer");
|
|
273
|
+
|
|
228
274
|
this.$handlePrototypes = $("section#handlePrototypes");
|
|
229
275
|
|
|
230
276
|
|
|
@@ -239,6 +285,7 @@ const estreUi = {
|
|
|
239
285
|
this.$tabsbar = this.$fixedBottom.find(".tabsbar");
|
|
240
286
|
this.$rootbar = this.$fixedBottom.find("nav#rootbar");
|
|
241
287
|
this.initRootbar();
|
|
288
|
+
this.initCoverBar();
|
|
242
289
|
}
|
|
243
290
|
|
|
244
291
|
const onLoadedFixedTop = subTerm => {
|
|
@@ -891,83 +938,17 @@ const estreUi = {
|
|
|
891
938
|
}
|
|
892
939
|
|
|
893
940
|
this.$rootTabs.filter(ax(eds.tabId)).click(this.rootTabOnClick);
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
// * Currently not using
|
|
897
|
-
// fetch("./structure/rootmenu" + estreStruct.structureSuffix)
|
|
898
|
-
// .then((response) => {
|
|
899
|
-
// if (response.ok) return response.json();
|
|
900
|
-
// throw Error("[" + response.status + "]" + response.url);
|
|
901
|
-
// })
|
|
902
|
-
// .then((data) => estreUi.renderRootBar(data))
|
|
903
|
-
// .catch((error) => console.log("fetch error: " + error));
|
|
904
941
|
},
|
|
905
942
|
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
this.$mainArea.append(this.buildMainSection(item));
|
|
914
|
-
if (item.type == "static" && item.home) topId = item.id;
|
|
915
|
-
}
|
|
916
|
-
this.$rootTabs = this.$rootbar.find(c.c + btn);
|
|
917
|
-
|
|
918
|
-
if (topId != null) {
|
|
919
|
-
this.$rootTabs.filter(aiv(eds.tabId, topId)).attr(eds.active, t1);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
this.$rootTabs.filter(ax(eds.tabId)).click(this.rootTabOnClick);
|
|
943
|
+
// Cover bar — instantiates the handle bound to the loaded fixedBottom
|
|
944
|
+
// markup. Idempotent: subsequent calls noop, so reload paths are safe.
|
|
945
|
+
// Phase 1C-2/1C-3 add lifecycle hooks and entry rendering on top of this.
|
|
946
|
+
initCoverBar() {
|
|
947
|
+
if (this.coverBarHandle != null) return;
|
|
948
|
+
if (this.$fixedBottom == null || this.$fixedBottom.length === 0) return;
|
|
949
|
+
this.coverBarHandle = new EstreCoverBarHandle(this.$fixedBottom, this.$topLayer);
|
|
923
950
|
},
|
|
924
951
|
|
|
925
|
-
buildRootTabItem(esm) {
|
|
926
|
-
const element = doc.ce(btn);
|
|
927
|
-
element.setAttribute(m.cls, "tp_tiled_btn");
|
|
928
|
-
element.setAttribute("title", esm.desc);
|
|
929
|
-
element.setAttribute(eds.tabId, esm.id);
|
|
930
|
-
element.innerHTML = esm.title;
|
|
931
|
-
return element;
|
|
932
|
-
},
|
|
933
|
-
|
|
934
|
-
buildMainSection(esm) {
|
|
935
|
-
const element = doc.ce(se);
|
|
936
|
-
element.setAttribute(m.cls, "vfv_scroll");
|
|
937
|
-
element.setAttribute("id", esm.id);
|
|
938
|
-
this.fetchContent(esm, element);
|
|
939
|
-
return element;
|
|
940
|
-
},
|
|
941
|
-
|
|
942
|
-
fetchContent(esm, target) {
|
|
943
|
-
return fetch("." + esm.direct + estreStruct.structureSuffix)
|
|
944
|
-
.then((response) => {
|
|
945
|
-
if (response.ok) return response.json();
|
|
946
|
-
throw Error("[" + response.status + "]" + response.url);
|
|
947
|
-
})
|
|
948
|
-
.then((data) => {
|
|
949
|
-
const parts = this.renderContentArea(data);
|
|
950
|
-
for (var part of parts) target.append(part);
|
|
951
|
-
})
|
|
952
|
-
.catch((error) => {
|
|
953
|
-
if (window.isLogging) console.error("fetch error: " + error);
|
|
954
|
-
});
|
|
955
|
-
},
|
|
956
|
-
|
|
957
|
-
renderContentArea(ecm) {
|
|
958
|
-
const set = [];
|
|
959
|
-
const article = doc.ce(ar);
|
|
960
|
-
if (ecm.content.display == "constraint") article.setAttribute(m.cls, "constraint");
|
|
961
|
-
set.push(article);
|
|
962
|
-
for (var handle of handles) {
|
|
963
|
-
const handler = doc.ce(div);
|
|
964
|
-
handler.setAttribute(m.cls, "handle_set " + handle.attach);
|
|
965
|
-
set.push(handler);
|
|
966
|
-
}
|
|
967
|
-
return set;
|
|
968
|
-
},
|
|
969
|
-
// ===========================
|
|
970
|
-
|
|
971
952
|
showExactAppbar(component, container, article) {
|
|
972
953
|
const appbar = this.appbar;
|
|
973
954
|
if (appbar == null) return;
|
|
@@ -1845,9 +1826,168 @@ const estreUi = {
|
|
|
1845
1826
|
},
|
|
1846
1827
|
|
|
1847
1828
|
async onBack() {
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1829
|
+
// External handler stack first (LIFO). Each entry can absorb the back
|
|
1830
|
+
// input by returning truthy; falsy lets the next entry (and finally the
|
|
1831
|
+
// EstreUI section stack below) try. Errors are isolated and logged.
|
|
1832
|
+
for (let i = this.externalBackStack.length - 1; i >= 0; i--) {
|
|
1833
|
+
try {
|
|
1834
|
+
if (await this.externalBackStack[i].handler()) return true;
|
|
1835
|
+
} catch (e) {
|
|
1836
|
+
if (window.isLogging) console.warn("[estreUi] external back handler error", e);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
if (await this.onBackOverlay()) return true;
|
|
1840
|
+
if (onBackWhile()) return true;
|
|
1841
|
+
if (this.isOpenMainMenu) {
|
|
1842
|
+
return await this.onBackMenu() || await this.closeMainMenu();
|
|
1843
|
+
}
|
|
1844
|
+
return await this.onBackBlinded() || await this.onBackMain();
|
|
1845
|
+
},
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* Register a back handler from a host-mounted external embed.
|
|
1849
|
+
*
|
|
1850
|
+
* Handlers are consumed LIFO — the most recent push runs first on the next
|
|
1851
|
+
* `back()` / popstate. Returning `true` (or a Promise resolving truthy)
|
|
1852
|
+
* stops the chain and absorbs the back input; returning a falsy value lets
|
|
1853
|
+
* the previous entry (or the EstreUI section stack) try.
|
|
1854
|
+
*
|
|
1855
|
+
* Re-entry is fine: the same `handler` may be pushed multiple times; each
|
|
1856
|
+
* push gets a separate token and counts as a separate stack entry.
|
|
1857
|
+
*
|
|
1858
|
+
* @param {() => boolean | Promise<boolean>} handler
|
|
1859
|
+
* @returns {number | null} Token for `popBackHandler`, or `null` if `handler` is invalid.
|
|
1860
|
+
*/
|
|
1861
|
+
pushBackHandler(handler) {
|
|
1862
|
+
if (typeof handler !== "function") {
|
|
1863
|
+
if (window.isLogging) console.warn("[estreUi] pushBackHandler — handler must be a function");
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
const token = this.nextBackHandlerToken++;
|
|
1867
|
+
this.externalBackStack.push({ token, handler });
|
|
1868
|
+
return token;
|
|
1869
|
+
},
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Remove a previously pushed handler. Out-of-order pop is allowed —
|
|
1873
|
+
* tokens identify entries independently of their stack position.
|
|
1874
|
+
*
|
|
1875
|
+
* @param {number} token
|
|
1876
|
+
* @returns {boolean} `true` if an entry was removed, `false` if the token was unknown.
|
|
1877
|
+
*/
|
|
1878
|
+
popBackHandler(token) {
|
|
1879
|
+
const idx = this.externalBackStack.findIndex(it => it.token === token);
|
|
1880
|
+
if (idx < 0) {
|
|
1881
|
+
if (window.isLogging) console.warn("[estreUi] popBackHandler — token not found:", token);
|
|
1882
|
+
return false;
|
|
1883
|
+
}
|
|
1884
|
+
this.externalBackStack.splice(idx, 1);
|
|
1885
|
+
return true;
|
|
1886
|
+
},
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Drop every external back handler — fallback cleanup for embed teardown
|
|
1890
|
+
* paths that cannot match tokens individually. Embeds should prefer paired
|
|
1891
|
+
* push/pop and reach for this only as a safety net.
|
|
1892
|
+
*/
|
|
1893
|
+
clearAllExternalBackHandlers() {
|
|
1894
|
+
this.externalBackStack.length = 0;
|
|
1895
|
+
},
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
// ─── instantSections — external embed hook (cover bar, Phase 3) ───
|
|
1899
|
+
//
|
|
1900
|
+
// Light-DOM-mounted embeds (mango-class talk, future docked tools…) can
|
|
1901
|
+
// surface their own windows in the right-side cover bar (instantSections)
|
|
1902
|
+
// using these four wrappers. Internally they delegate to the singleton
|
|
1903
|
+
// EstreCoverBarHandle managed by initCoverBar(); each push returns a
|
|
1904
|
+
// monotone positive integer token. The user's intent is reported back
|
|
1905
|
+
// through the per-entry `onAction(action)` callback, where `action` is
|
|
1906
|
+
// one of "focus" / "minimize" / "restore" / "close". The embed owns the
|
|
1907
|
+
// actual window transition — the bar only routes intent.
|
|
1908
|
+
|
|
1909
|
+
/**
|
|
1910
|
+
* Register an external cover-bar entry. Returns a token; pass it back to
|
|
1911
|
+
* `updateInstantSectionEntry` / `removeInstantSectionEntry` /
|
|
1912
|
+
* `setInstantSectionActiveByToken`.
|
|
1913
|
+
*
|
|
1914
|
+
* With `closable: true` an ✕ button is rendered on the entry; clicking
|
|
1915
|
+
* it fires `onAction("close")`. The embed is responsible for the actual
|
|
1916
|
+
* close (so async confirm / server roundtrip can run first) and must
|
|
1917
|
+
* call `removeInstantSectionEntry` once the close completes.
|
|
1918
|
+
*
|
|
1919
|
+
* @param {object} data
|
|
1920
|
+
* @param {string} [data.title]
|
|
1921
|
+
* @param {string|null|undefined} [data.icon] — empty / undefined → sectionBound default, "none" / null → text-only, "<url>" → that URL
|
|
1922
|
+
* @param {string} [data.sectionBound] — "main" | "blind" | "overlay", drives the default icon
|
|
1923
|
+
* @param {(action: "focus"|"minimize"|"restore"|"close") => void} [data.onAction]
|
|
1924
|
+
* @param {boolean} [data.closable] — render an ✕ that fires onAction("close")
|
|
1925
|
+
* @param {number} [data.badge] — unread / notification count (0 or null → no badge, 1 → dot, 2-99 → numeric, >99 → "99+")
|
|
1926
|
+
* @returns {number|null} token, or `null` if the cover bar isn't initialised
|
|
1927
|
+
*/
|
|
1928
|
+
pushInstantSectionEntry(data) {
|
|
1929
|
+
if (this.coverBarHandle == null) {
|
|
1930
|
+
if (window.isLogging) console.warn("[estreUi] pushInstantSectionEntry — coverBarHandle not initialised");
|
|
1931
|
+
return null;
|
|
1932
|
+
}
|
|
1933
|
+
return this.coverBarHandle.pushEntry({
|
|
1934
|
+
title: data?.title,
|
|
1935
|
+
icon: data?.icon,
|
|
1936
|
+
sectionBound: data?.sectionBound,
|
|
1937
|
+
onAction: data?.onAction,
|
|
1938
|
+
closable: data?.closable,
|
|
1939
|
+
badge: data?.badge,
|
|
1940
|
+
});
|
|
1941
|
+
},
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Patch an existing external entry. `partial` may carry any of `title`,
|
|
1945
|
+
* `icon`, `onAction`, `closable`. Returns false if the token is unknown
|
|
1946
|
+
* or the cover bar isn't initialised.
|
|
1947
|
+
*
|
|
1948
|
+
* @param {number} token
|
|
1949
|
+
* @param {object} partial
|
|
1950
|
+
* @returns {boolean}
|
|
1951
|
+
*/
|
|
1952
|
+
updateInstantSectionEntry(token, partial) {
|
|
1953
|
+
return this.coverBarHandle?.updateEntry(token, partial) ?? false;
|
|
1954
|
+
},
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* Remove an external entry. The bar detaches the DOM and forgets the
|
|
1958
|
+
* state; `activeToken` is cleared if the removed entry was active.
|
|
1959
|
+
*
|
|
1960
|
+
* @param {number} token
|
|
1961
|
+
* @returns {boolean}
|
|
1962
|
+
*/
|
|
1963
|
+
removeInstantSectionEntry(token) {
|
|
1964
|
+
return this.coverBarHandle?.removeEntry(token) ?? false;
|
|
1965
|
+
},
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Mark an external entry active (visually highlighted on the bar).
|
|
1969
|
+
* Call this from the embed when its window gains focus through some
|
|
1970
|
+
* other path than a bar click — e.g., the user clicked inside the
|
|
1971
|
+
* embed itself.
|
|
1972
|
+
*
|
|
1973
|
+
* @param {number} token
|
|
1974
|
+
* @returns {boolean}
|
|
1975
|
+
*/
|
|
1976
|
+
setInstantSectionActiveByToken(token) {
|
|
1977
|
+
return this.coverBarHandle?.setActiveByToken(token) ?? false;
|
|
1978
|
+
},
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Mark an external entry minimized / restored. Call this from the embed
|
|
1982
|
+
* when its window's visibility changes through a path other than a bar
|
|
1983
|
+
* click — e.g., the embed exposed its own minimize control.
|
|
1984
|
+
*
|
|
1985
|
+
* @param {number} token
|
|
1986
|
+
* @param {boolean} minimized
|
|
1987
|
+
* @returns {boolean}
|
|
1988
|
+
*/
|
|
1989
|
+
setInstantSectionMinimizedByToken(token, minimized) {
|
|
1990
|
+
return this.coverBarHandle?.setMinimizedByToken(token, minimized) ?? false;
|
|
1851
1991
|
},
|
|
1852
1992
|
|
|
1853
1993
|
async onCloseContainer() {
|
|
@@ -2015,3 +2155,823 @@ const estreUi = {
|
|
|
2015
2155
|
eoo: eoo
|
|
2016
2156
|
}
|
|
2017
2157
|
|
|
2158
|
+
/**
|
|
2159
|
+
* Cover bar handle — owns the bottom-bar entry list that mirrors opt-in pages
|
|
2160
|
+
* (instantDoc / managedOverlay sections marked with `data-cover-mount`) and,
|
|
2161
|
+
* in Phase 3, external-embed windows registered via the public push API.
|
|
2162
|
+
*
|
|
2163
|
+
* Named with the `Handle` suffix to align with EstreUI's stock handle
|
|
2164
|
+
* convention (EstreHandle, EstreSwipeHandler, etc.) even though this one
|
|
2165
|
+
* isn't DOM-element-attached — it manages the bar surface as a single owner.
|
|
2166
|
+
*
|
|
2167
|
+
* Phase 1C-1 scope: scaffolding only — area refs cached, entry CRUD comes in
|
|
2168
|
+
* 1C-2 and entry rendering in 1C-3. The handle is instantiated by
|
|
2169
|
+
* estreUi.initCoverBar() during fixedBottom load (see onLoadedFixedBottom).
|
|
2170
|
+
*/
|
|
2171
|
+
class EstreCoverBarHandle {
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* Reusable 14x14 SVG glyphs for the context menu. Same stroke weight and
|
|
2175
|
+
* currentColor convention as the hide-all toggle / overflow sentinel, so
|
|
2176
|
+
* host code can grab the same icons when opening its own menus via
|
|
2177
|
+
* openContextMenu(). New icons should follow the same 14x14 viewBox +
|
|
2178
|
+
* stroke-only / currentColor pattern.
|
|
2179
|
+
*
|
|
2180
|
+
* center → 4-directional inward arrows pointing at the middle (cross
|
|
2181
|
+
* shape so users don't confuse it with the diagonal-X close)
|
|
2182
|
+
* minimize → single low bar (Windows-style _)
|
|
2183
|
+
* restore → outlined rect (window frame)
|
|
2184
|
+
* close → diagonal-X
|
|
2185
|
+
*/
|
|
2186
|
+
static menuIcons = {
|
|
2187
|
+
center:
|
|
2188
|
+
'<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
|
2189
|
+
+ '<path d="M7 1.5v3.5M5.5 3.5L7 5l1.5-1.5"/>'
|
|
2190
|
+
+ '<path d="M7 12.5V9M5.5 10.5L7 9l1.5 1.5"/>'
|
|
2191
|
+
+ '<path d="M1.5 7h3.5M3.5 5.5L5 7l-1.5 1.5"/>'
|
|
2192
|
+
+ '<path d="M12.5 7H9M10.5 5.5L9 7l1.5 1.5"/>'
|
|
2193
|
+
+ '</svg>',
|
|
2194
|
+
minimize:
|
|
2195
|
+
'<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
|
|
2196
|
+
+ '<path d="M3 10h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>'
|
|
2197
|
+
+ '</svg>',
|
|
2198
|
+
restore:
|
|
2199
|
+
'<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
|
|
2200
|
+
+ '<rect x="3" y="3" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5"/>'
|
|
2201
|
+
+ '</svg>',
|
|
2202
|
+
close:
|
|
2203
|
+
'<svg viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false">'
|
|
2204
|
+
+ '<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>'
|
|
2205
|
+
+ '</svg>',
|
|
2206
|
+
};
|
|
2207
|
+
|
|
2208
|
+
#instantSections = null;
|
|
2209
|
+
#customFixedSections = null;
|
|
2210
|
+
#topLayer = null;
|
|
2211
|
+
#instantSentinel = null;
|
|
2212
|
+
#customFixedSentinel = null;
|
|
2213
|
+
#resizeObserver = null;
|
|
2214
|
+
#entries = [];
|
|
2215
|
+
#nextToken = 1;
|
|
2216
|
+
#activeToken = null;
|
|
2217
|
+
#openDropdown = null;
|
|
2218
|
+
#openContextMenu = null;
|
|
2219
|
+
#onDocumentPointerDown = null;
|
|
2220
|
+
#onDocumentKeydown = null;
|
|
2221
|
+
|
|
2222
|
+
constructor($fixedBottom, $topLayer) {
|
|
2223
|
+
// Accept either a jQuery wrapper or a native element; the cover bar is
|
|
2224
|
+
// intentionally jQuery-agnostic internally so it stays usable under the
|
|
2225
|
+
// estreU0EEOZ jQuery fallback (jsdom test environment) as well as the
|
|
2226
|
+
// real jQuery in browsers.
|
|
2227
|
+
const fb = $fixedBottom?.[0] ?? $fixedBottom;
|
|
2228
|
+
this.#instantSections = fb?.querySelector?.("nav#instantSections") ?? null;
|
|
2229
|
+
this.#customFixedSections = fb?.querySelector?.("nav#customFixedSections") ?? null;
|
|
2230
|
+
// Top-layer host for overflow dropdowns (Phase 2C). Optional — falls
|
|
2231
|
+
// back to null when the host hasn't mounted the slot. The bar still
|
|
2232
|
+
// renders entries; overflow dropdowns simply do not appear.
|
|
2233
|
+
const tl = $topLayer?.[0] ?? $topLayer;
|
|
2234
|
+
this.#topLayer = tl ?? null;
|
|
2235
|
+
|
|
2236
|
+
// Overflow sentinels — a ⌃-caret button at the leading edge of each
|
|
2237
|
+
// area. Always appended once; visibility flips via the `hidden` attr
|
|
2238
|
+
// as #recomputeOverflow measures area space against entry width.
|
|
2239
|
+
// instantSections is right-aligned (justify-content: flex-end) so
|
|
2240
|
+
// the sentinel ends up at the left edge of the visible cluster —
|
|
2241
|
+
// i.e., it points at the overflowed (clipped) side. The sentinel
|
|
2242
|
+
// is prepended so newly pushed entries (appended) always render to
|
|
2243
|
+
// its trailing side.
|
|
2244
|
+
if (this.#instantSections != null) {
|
|
2245
|
+
this.#instantSentinel = this.#createSentinel("instant");
|
|
2246
|
+
this.#instantSections.appendChild(this.#instantSentinel);
|
|
2247
|
+
}
|
|
2248
|
+
if (this.#customFixedSections != null) {
|
|
2249
|
+
this.#customFixedSentinel = this.#createSentinel("custom-fixed");
|
|
2250
|
+
this.#customFixedSections.appendChild(this.#customFixedSentinel);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// ResizeObserver watches both areas so overflow recomputes when the
|
|
2254
|
+
// host viewport changes or the bar's siblings adjust. Falls back to a
|
|
2255
|
+
// no-op if the API isn't available (rare; jsdom recent versions ship
|
|
2256
|
+
// ResizeObserver). Push/remove/update paths call #recomputeOverflow
|
|
2257
|
+
// directly so the bar stays in sync even without observer events.
|
|
2258
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
2259
|
+
this.#resizeObserver = new ResizeObserver(() => this.#recomputeOverflow());
|
|
2260
|
+
if (this.#instantSections != null) this.#resizeObserver.observe(this.#instantSections);
|
|
2261
|
+
if (this.#customFixedSections != null) this.#resizeObserver.observe(this.#customFixedSections);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Global listeners — close the overflow dropdown on outside click or
|
|
2265
|
+
// Escape. Capture phase so an embed cannot swallow the pointerdown
|
|
2266
|
+
// before we see it (the dropdown lives in #topLayer and the user's
|
|
2267
|
+
// intent in clicking anywhere else is unambiguously "dismiss").
|
|
2268
|
+
const self = this;
|
|
2269
|
+
this.#onDocumentPointerDown = (event) => {
|
|
2270
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
2271
|
+
// Dropdown close exception list: its own element, its sentinel, and
|
|
2272
|
+
// any currently-open context menu (a context menu opened from a
|
|
2273
|
+
// dropdown row mounts as a sibling in #topLayer, so the dropdown
|
|
2274
|
+
// shouldn't treat clicks on the menu as "outside").
|
|
2275
|
+
if (self.#openDropdown != null
|
|
2276
|
+
&& !path.includes(self.#openDropdown.element)
|
|
2277
|
+
&& !path.includes(self.#openDropdown.sentinel)
|
|
2278
|
+
&& (self.#openContextMenu == null || !path.includes(self.#openContextMenu.element))) {
|
|
2279
|
+
self.#closeDropdown();
|
|
2280
|
+
}
|
|
2281
|
+
if (self.#openContextMenu != null
|
|
2282
|
+
&& !path.includes(self.#openContextMenu.element)) {
|
|
2283
|
+
self.#closeContextMenu();
|
|
2284
|
+
}
|
|
2285
|
+
};
|
|
2286
|
+
this.#onDocumentKeydown = (event) => {
|
|
2287
|
+
if (event.key !== "Escape") return;
|
|
2288
|
+
if (self.#openContextMenu != null) self.#closeContextMenu();
|
|
2289
|
+
else if (self.#openDropdown != null) self.#closeDropdown();
|
|
2290
|
+
};
|
|
2291
|
+
document.addEventListener("pointerdown", this.#onDocumentPointerDown, true);
|
|
2292
|
+
document.addEventListener("keydown", this.#onDocumentKeydown);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
get instantSections() { return this.#instantSections; }
|
|
2296
|
+
get customFixedSections() { return this.#customFixedSections; }
|
|
2297
|
+
get topLayer() { return this.#topLayer; }
|
|
2298
|
+
/** @deprecated jQuery-flavoured getter kept for legacy callers; prefer `instantSections`. */
|
|
2299
|
+
get $instantSections() { return this.#instantSections == null ? null : $(this.#instantSections); }
|
|
2300
|
+
/** @deprecated jQuery-flavoured getter kept for legacy callers; prefer `customFixedSections`. */
|
|
2301
|
+
get $customFixedSections() { return this.#customFixedSections == null ? null : $(this.#customFixedSections); }
|
|
2302
|
+
get entries() { return this.#entries; }
|
|
2303
|
+
get activeToken() { return this.#activeToken; }
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* Register a new cover-bar entry and render its DOM into `instantSections`.
|
|
2307
|
+
* @param {object} data
|
|
2308
|
+
* @param {EstrePageHandle} [data.pageHandle] — page bound to this entry (null for external embeds in Phase 3)
|
|
2309
|
+
* @param {string} [data.sectionBound] — main / blind / overlay etc., drives the default icon
|
|
2310
|
+
* @param {string} [data.title]
|
|
2311
|
+
* @param {string|null|undefined} [data.icon]
|
|
2312
|
+
* @returns {number} token
|
|
2313
|
+
*/
|
|
2314
|
+
pushEntry(data) {
|
|
2315
|
+
const token = this.#nextToken++;
|
|
2316
|
+
const entry = {
|
|
2317
|
+
token,
|
|
2318
|
+
pageHandle: data.pageHandle ?? null,
|
|
2319
|
+
sectionBound: data.sectionBound ?? null,
|
|
2320
|
+
title: data.title ?? null,
|
|
2321
|
+
icon: data.icon,
|
|
2322
|
+
// External-embed wiring (Phase 3). pageHandle and onAction are
|
|
2323
|
+
// mutually exclusive at the click-routing level: when pageHandle
|
|
2324
|
+
// is set, clicks call show/hide on it; otherwise onAction fires
|
|
2325
|
+
// with one of "focus" / "minimize" / "restore" / "close".
|
|
2326
|
+
onAction: typeof data.onAction === "function" ? data.onAction : null,
|
|
2327
|
+
closable: data.closable === true,
|
|
2328
|
+
badge: typeof data.badge === "number" ? data.badge : null,
|
|
2329
|
+
minimized: false,
|
|
2330
|
+
element: null,
|
|
2331
|
+
};
|
|
2332
|
+
this.#entries.push(entry);
|
|
2333
|
+
this.#renderEntry(entry);
|
|
2334
|
+
this.#refreshEntryBadge(entry);
|
|
2335
|
+
this.#recomputeOverflow();
|
|
2336
|
+
return token;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
removeEntry(token) {
|
|
2340
|
+
const idx = this.#entries.findIndex(e => e.token === token);
|
|
2341
|
+
if (idx < 0) return false;
|
|
2342
|
+
const entry = this.#entries[idx];
|
|
2343
|
+
entry.element?.remove();
|
|
2344
|
+
this.#entries.splice(idx, 1);
|
|
2345
|
+
if (this.#activeToken === token) this.#activeToken = null;
|
|
2346
|
+
this.#recomputeOverflow();
|
|
2347
|
+
return true;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
setActiveByToken(token) {
|
|
2351
|
+
// Passing null / undefined clears the active state. Useful for
|
|
2352
|
+
// cumulative-state reconcile flows where the payload may legitimately
|
|
2353
|
+
// have no active entry; without this the previously-active entry
|
|
2354
|
+
// would stay highlighted indefinitely.
|
|
2355
|
+
if (token == null) {
|
|
2356
|
+
if (this.#activeToken == null) return true;
|
|
2357
|
+
const prev = this.#entries.find(e => e.token === this.#activeToken);
|
|
2358
|
+
prev?.element?.setAttribute("data-active", "");
|
|
2359
|
+
this.#activeToken = null;
|
|
2360
|
+
return true;
|
|
2361
|
+
}
|
|
2362
|
+
if (this.#entries.findIndex(e => e.token === token) < 0) return false;
|
|
2363
|
+
if (this.#activeToken != null && this.#activeToken !== token) {
|
|
2364
|
+
const prev = this.#entries.find(e => e.token === this.#activeToken);
|
|
2365
|
+
prev?.element?.setAttribute("data-active", "");
|
|
2366
|
+
}
|
|
2367
|
+
this.#activeToken = token;
|
|
2368
|
+
const entry = this.#entries.find(e => e.token === token);
|
|
2369
|
+
entry?.element?.setAttribute("data-active", "1");
|
|
2370
|
+
return true;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
setMinimizedByToken(token, minimized) {
|
|
2374
|
+
const entry = this.#entries.find(e => e.token === token);
|
|
2375
|
+
if (entry == null) return false;
|
|
2376
|
+
entry.minimized = !!minimized;
|
|
2377
|
+
entry.element?.setAttribute("data-minimized", entry.minimized ? "1" : "");
|
|
2378
|
+
return true;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
updateEntry(token, partial) {
|
|
2382
|
+
const entry = this.#entries.find(e => e.token === token);
|
|
2383
|
+
if (entry == null) return false;
|
|
2384
|
+
if ("title" in partial) {
|
|
2385
|
+
entry.title = partial.title;
|
|
2386
|
+
const label = entry.element?.querySelector(":scope > label");
|
|
2387
|
+
if (label != null) label.textContent = entry.title ?? "";
|
|
2388
|
+
// Mirror the title attr (native tooltip) so hover stays in sync
|
|
2389
|
+
// with the visible label.
|
|
2390
|
+
if (entry.element != null) {
|
|
2391
|
+
if (entry.title != null) entry.element.setAttribute("title", entry.title);
|
|
2392
|
+
else entry.element.removeAttribute("title");
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
if ("icon" in partial) {
|
|
2396
|
+
entry.icon = partial.icon;
|
|
2397
|
+
this.#refreshEntryIcon(entry);
|
|
2398
|
+
}
|
|
2399
|
+
if ("onAction" in partial) {
|
|
2400
|
+
entry.onAction = typeof partial.onAction === "function" ? partial.onAction : null;
|
|
2401
|
+
}
|
|
2402
|
+
if ("closable" in partial) {
|
|
2403
|
+
entry.closable = partial.closable === true;
|
|
2404
|
+
this.#refreshEntryClose(entry);
|
|
2405
|
+
}
|
|
2406
|
+
if ("badge" in partial) {
|
|
2407
|
+
entry.badge = typeof partial.badge === "number" ? partial.badge : null;
|
|
2408
|
+
this.#refreshEntryBadge(entry);
|
|
2409
|
+
}
|
|
2410
|
+
this.#recomputeOverflow();
|
|
2411
|
+
return true;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/** Lookup entry by token. Returns the live object — callers should treat it as read-only. */
|
|
2415
|
+
findEntry(token) {
|
|
2416
|
+
return this.#entries.find(e => e.token === token) ?? null;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
/**
|
|
2420
|
+
* Resolve the icon URL for an entry per the cover-icon fallback rules:
|
|
2421
|
+
* - icon === undefined → unset → sectionBound default
|
|
2422
|
+
* - icon === "" → explicit blank → sectionBound default
|
|
2423
|
+
* - icon === "none" → text-only (returns null)
|
|
2424
|
+
* - icon === null → text-only (returns null)
|
|
2425
|
+
* - icon === "<url>" → that URL
|
|
2426
|
+
*/
|
|
2427
|
+
#resolveIconUrl(entry) {
|
|
2428
|
+
const icon = entry.icon;
|
|
2429
|
+
if (icon === "none" || icon === null) return null;
|
|
2430
|
+
if (icon === "" || icon === undefined) return this.#defaultIconForSection(entry.sectionBound);
|
|
2431
|
+
return icon;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
#defaultIconForSection(sectionBound) {
|
|
2435
|
+
if (sectionBound === "main") return "./vectors/cover-icon-default-static.svg";
|
|
2436
|
+
if (sectionBound === "blind") return "./vectors/cover-icon-default-instant.svg";
|
|
2437
|
+
if (sectionBound === "overlay") return "./vectors/cover-icon-default-overlay.svg";
|
|
2438
|
+
return null;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
#renderEntry(entry) {
|
|
2442
|
+
if (this.#instantSections == null) return;
|
|
2443
|
+
const btn = document.createElement("button");
|
|
2444
|
+
btn.type = "button";
|
|
2445
|
+
btn.className = "clean cover_entry";
|
|
2446
|
+
btn.setAttribute("data-cover-token", entry.token);
|
|
2447
|
+
if (entry.sectionBound != null) btn.setAttribute("data-section-bound", entry.sectionBound);
|
|
2448
|
+
// Full title surfaces as the native tooltip — the visible label is
|
|
2449
|
+
// ellipsis-clipped on narrow tiles, but hovering reveals the rest.
|
|
2450
|
+
if (entry.title != null) btn.setAttribute("title", entry.title);
|
|
2451
|
+
|
|
2452
|
+
const iconUrl = this.#resolveIconUrl(entry);
|
|
2453
|
+
if (iconUrl != null) {
|
|
2454
|
+
const span = document.createElement("span");
|
|
2455
|
+
span.className = "cover_icon";
|
|
2456
|
+
const img = document.createElement("img");
|
|
2457
|
+
img.alt = "";
|
|
2458
|
+
img.src = iconUrl;
|
|
2459
|
+
span.appendChild(img);
|
|
2460
|
+
btn.appendChild(span);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
const label = document.createElement("label");
|
|
2464
|
+
label.textContent = entry.title ?? "";
|
|
2465
|
+
btn.appendChild(label);
|
|
2466
|
+
|
|
2467
|
+
const self = this;
|
|
2468
|
+
btn.addEventListener("click", () => self.#onEntryClicked(entry.token));
|
|
2469
|
+
btn.addEventListener("contextmenu", (event) => self.#onEntryContextMenu(entry.token, event));
|
|
2470
|
+
|
|
2471
|
+
entry.element = btn;
|
|
2472
|
+
this.#instantSections.appendChild(btn);
|
|
2473
|
+
if (entry.closable) this.#refreshEntryClose(entry);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
/**
|
|
2477
|
+
* Add / remove the close ✕ on an entry to match `entry.closable`. Idempotent
|
|
2478
|
+
* so it can be called from #renderEntry (initial paint) and updateEntry
|
|
2479
|
+
* (state change). The ✕ click fires onAction("close") only — the embed is
|
|
2480
|
+
* responsible for the actual close (so an async confirm / server roundtrip
|
|
2481
|
+
* can run first) and must call removeInstantSectionEntry afterwards.
|
|
2482
|
+
*/
|
|
2483
|
+
#refreshEntryClose(entry) {
|
|
2484
|
+
if (entry.element == null) return;
|
|
2485
|
+
const existing = entry.element.querySelector(":scope > .cover_entry_close");
|
|
2486
|
+
if (!entry.closable) {
|
|
2487
|
+
existing?.remove();
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
if (existing != null) return;
|
|
2491
|
+
const close = document.createElement("span");
|
|
2492
|
+
close.className = "cover_entry_close";
|
|
2493
|
+
close.setAttribute("role", "button");
|
|
2494
|
+
close.setAttribute("aria-label", "Close");
|
|
2495
|
+
close.textContent = "✕";
|
|
2496
|
+
close.addEventListener("click", (event) => {
|
|
2497
|
+
event.stopPropagation();
|
|
2498
|
+
if (typeof entry.onAction === "function") entry.onAction("close");
|
|
2499
|
+
});
|
|
2500
|
+
entry.element.appendChild(close);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/**
|
|
2504
|
+
* Cover-bar entry click handler. Routes to one of two surfaces based on
|
|
2505
|
+
* whether the entry has a page handle or an external onAction callback.
|
|
2506
|
+
* The state-to-intent mapping is the same in both branches — it mirrors
|
|
2507
|
+
* how task-switcher-style docks behave:
|
|
2508
|
+
*
|
|
2509
|
+
* - active & visible → hide / "minimize"
|
|
2510
|
+
* - inactive → show + focus / "focus"
|
|
2511
|
+
* - active & minimized → show + focus / "restore"
|
|
2512
|
+
*
|
|
2513
|
+
* For internal page entries the handle's own show/hide is invoked. For
|
|
2514
|
+
* external embed entries (Phase 3) the registered onAction callback is
|
|
2515
|
+
* fired with the corresponding action token; the embed owns the actual
|
|
2516
|
+
* focus/minimize/restore transition. Entries with neither route configured
|
|
2517
|
+
* noop on click.
|
|
2518
|
+
*/
|
|
2519
|
+
#onEntryClicked(token) {
|
|
2520
|
+
const entry = this.#entries.find(e => e.token === token);
|
|
2521
|
+
if (entry == null) return;
|
|
2522
|
+
if (entry.pageHandle != null) {
|
|
2523
|
+
if (this.#activeToken === token && !entry.minimized) {
|
|
2524
|
+
entry.pageHandle.hide();
|
|
2525
|
+
} else {
|
|
2526
|
+
entry.pageHandle.show(true, true);
|
|
2527
|
+
}
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if (typeof entry.onAction === "function") {
|
|
2531
|
+
let action;
|
|
2532
|
+
if (this.#activeToken === token && !entry.minimized) action = "minimize";
|
|
2533
|
+
else if (this.#activeToken === token && entry.minimized) action = "restore";
|
|
2534
|
+
else action = "focus";
|
|
2535
|
+
entry.onAction(action);
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
#refreshEntryIcon(entry) {
|
|
2540
|
+
if (entry.element == null) return;
|
|
2541
|
+
entry.element.querySelector(":scope > .cover_icon")?.remove();
|
|
2542
|
+
const iconUrl = this.#resolveIconUrl(entry);
|
|
2543
|
+
if (iconUrl == null) return;
|
|
2544
|
+
const span = document.createElement("span");
|
|
2545
|
+
span.className = "cover_icon";
|
|
2546
|
+
const img = document.createElement("img");
|
|
2547
|
+
img.alt = "";
|
|
2548
|
+
img.src = iconUrl;
|
|
2549
|
+
span.appendChild(img);
|
|
2550
|
+
entry.element.insertBefore(span, entry.element.firstChild);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* Apply the project-wide [data-badge] attribute convention (see
|
|
2555
|
+
* estreUi.css `article [data-badge]::after`) to an element. Display
|
|
2556
|
+
* rules — kept consistent with `setMangoTalkBadge` / `setPushNotificationBadge`
|
|
2557
|
+
* elsewhere in the host so themes can override `--badge-color` once:
|
|
2558
|
+
* - count null / 0 / negative → attribute removed
|
|
2559
|
+
* - count === 1 → data-badge="" → dot (CSS :empty variant)
|
|
2560
|
+
* - 2 ≤ count ≤ 99 → data-badge="<count>" → numeric pill
|
|
2561
|
+
* - count > 99 → data-badge="99+"
|
|
2562
|
+
*/
|
|
2563
|
+
#setBadgeAttr(target, count) {
|
|
2564
|
+
if (count == null || count <= 0) {
|
|
2565
|
+
target.removeAttribute("data-badge");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
target.setAttribute("data-badge",
|
|
2569
|
+
count === 1 ? "" :
|
|
2570
|
+
count > 99 ? "99+" :
|
|
2571
|
+
String(count));
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* Sync the per-entry unread badge with `entry.badge`. The badge is
|
|
2576
|
+
* surfaced as a [data-badge] attribute on the entry's .cover_icon —
|
|
2577
|
+
* styled via the cover-bar-scoped ::after rule that mirrors the
|
|
2578
|
+
* project-wide article [data-badge] convention. Text-only entries
|
|
2579
|
+
* (icon: "none" / no .cover_icon host) silently no-op.
|
|
2580
|
+
*/
|
|
2581
|
+
#refreshEntryBadge(entry) {
|
|
2582
|
+
if (entry.element == null) return;
|
|
2583
|
+
const iconHost = entry.element.querySelector(":scope > .cover_icon");
|
|
2584
|
+
if (iconHost == null) return; // text-only entries can't host the badge
|
|
2585
|
+
this.#setBadgeAttr(iconHost, entry.badge);
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
/**
|
|
2589
|
+
* Create the per-area overflow sentinel button. Hidden by default; the
|
|
2590
|
+
* recompute pass flips `hidden` based on whether entries fit. Clicking
|
|
2591
|
+
* toggles the area's overflow dropdown (Phase 2C).
|
|
2592
|
+
*
|
|
2593
|
+
* The caret is rendered as an inline SVG so it can be precisely sized and
|
|
2594
|
+
* positioned (top-aligned on a narrow button) regardless of the platform
|
|
2595
|
+
* font. The shape mirrors the rootbar's chevron affordances — a thin
|
|
2596
|
+
* upward chevron stroked in currentColor so hover / data-opened states
|
|
2597
|
+
* pick up the surrounding color transitions for free.
|
|
2598
|
+
*/
|
|
2599
|
+
#createSentinel(areaKey) {
|
|
2600
|
+
const btn = document.createElement("button");
|
|
2601
|
+
btn.type = "button";
|
|
2602
|
+
btn.className = "clean cover_overflow_sentinel";
|
|
2603
|
+
btn.setAttribute("data-area", areaKey);
|
|
2604
|
+
btn.setAttribute("aria-label", "Show overflowed entries");
|
|
2605
|
+
btn.innerHTML =
|
|
2606
|
+
'<svg viewBox="0 0 12 8" aria-hidden="true" focusable="false">' +
|
|
2607
|
+
'<polyline points="2,6 6,2 10,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
2608
|
+
'</svg>';
|
|
2609
|
+
btn.hidden = true;
|
|
2610
|
+
const self = this;
|
|
2611
|
+
btn.addEventListener("click", (event) => {
|
|
2612
|
+
event.stopPropagation();
|
|
2613
|
+
self.#toggleDropdown(areaKey);
|
|
2614
|
+
});
|
|
2615
|
+
return btn;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
/**
|
|
2619
|
+
* Re-evaluate overflow for both areas. Cheap: short loop guided by
|
|
2620
|
+
* `area.scrollWidth > area.clientWidth`. Called from push / remove /
|
|
2621
|
+
* update / ResizeObserver. Hidden entries (data-overflowed="1") drop
|
|
2622
|
+
* out of layout via CSS, so subsequent measurements reflect the new
|
|
2623
|
+
* width budget.
|
|
2624
|
+
*/
|
|
2625
|
+
#recomputeOverflow() {
|
|
2626
|
+
// Both areas hide from the trailing (DOM-end / most-recently-pushed)
|
|
2627
|
+
// side. The sentinel sits at the trailing edge of the visible cluster
|
|
2628
|
+
// (`order: 1`), so the entries that disappear are the ones closest to
|
|
2629
|
+
// it — most-recently-pushed first. Users opening a fresh window see it
|
|
2630
|
+
// get pushed into the dropdown as the bar fills up; older windows
|
|
2631
|
+
// stay visible on the leading side where they were first placed.
|
|
2632
|
+
this.#recomputeAreaOverflow(this.#instantSections, this.#instantSentinel, "trailing");
|
|
2633
|
+
this.#recomputeAreaOverflow(this.#customFixedSections, this.#customFixedSentinel, "trailing");
|
|
2634
|
+
if (this.#openDropdown != null) this.#refreshOpenDropdown();
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
/**
|
|
2638
|
+
* Hide entries one at a time from the chosen end until the cluster fits.
|
|
2639
|
+
* - leading → hide from index 0 forward (oldest first, suited to
|
|
2640
|
+
* flex-end areas where the leading edge is clipped)
|
|
2641
|
+
* - trailing → hide from index N-1 backward (newest first, suited to
|
|
2642
|
+
* flex-start areas where the trailing edge is clipped)
|
|
2643
|
+
*/
|
|
2644
|
+
#recomputeAreaOverflow(area, sentinel, hideFrom) {
|
|
2645
|
+
if (area == null || sentinel == null) return;
|
|
2646
|
+
const entries = this.#entriesForArea(area);
|
|
2647
|
+
for (const e of entries) e.element?.removeAttribute("data-overflowed");
|
|
2648
|
+
sentinel.hidden = true;
|
|
2649
|
+
if (entries.length === 0) return;
|
|
2650
|
+
if (area.scrollWidth <= area.clientWidth) return;
|
|
2651
|
+
|
|
2652
|
+
// Reveal the sentinel so its width counts toward the budget; then
|
|
2653
|
+
// hide entries until the remaining cluster fits alongside it.
|
|
2654
|
+
sentinel.hidden = false;
|
|
2655
|
+
const indices = hideFrom === "leading"
|
|
2656
|
+
? entries.map((_, i) => i)
|
|
2657
|
+
: entries.map((_, i) => entries.length - 1 - i);
|
|
2658
|
+
for (const i of indices) {
|
|
2659
|
+
if (area.scrollWidth <= area.clientWidth) return;
|
|
2660
|
+
entries[i].element?.setAttribute("data-overflowed", "1");
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
/** Currently only `instantSections` hosts cover-bar entries. customFixedSections
|
|
2665
|
+
* is reserved for future user-pinned items (see PM/002 task ledger §Phase 4+). */
|
|
2666
|
+
#entriesForArea(area) {
|
|
2667
|
+
if (area === this.#instantSections) return this.#entries;
|
|
2668
|
+
return [];
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* Open / close / toggle the overflow dropdown for an area. Single open
|
|
2673
|
+
* dropdown at a time; opening one while another is open swaps them.
|
|
2674
|
+
* No-op when the top-layer host slot is missing.
|
|
2675
|
+
*/
|
|
2676
|
+
#toggleDropdown(areaKey) {
|
|
2677
|
+
if (this.#openDropdown != null && this.#openDropdown.areaKey === areaKey) {
|
|
2678
|
+
this.#closeDropdown();
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
this.#openDropdown != null && this.#closeDropdown();
|
|
2682
|
+
this.#openDropdown = this.#openDropdownFor(areaKey);
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
#openDropdownFor(areaKey) {
|
|
2686
|
+
if (this.#topLayer == null) return null;
|
|
2687
|
+
const sentinel = areaKey === "instant" ? this.#instantSentinel : this.#customFixedSentinel;
|
|
2688
|
+
const area = areaKey === "instant" ? this.#instantSections : this.#customFixedSections;
|
|
2689
|
+
if (sentinel == null || area == null) return null;
|
|
2690
|
+
|
|
2691
|
+
const dropdown = document.createElement("div");
|
|
2692
|
+
dropdown.className = "cover_overflow_dropdown";
|
|
2693
|
+
dropdown.setAttribute("data-area", areaKey);
|
|
2694
|
+
this.#topLayer.appendChild(dropdown);
|
|
2695
|
+
sentinel.setAttribute("data-opened", "1");
|
|
2696
|
+
|
|
2697
|
+
const state = { areaKey, sentinel, area, element: dropdown };
|
|
2698
|
+
// Render and position after attaching so size can be measured.
|
|
2699
|
+
this.#renderDropdownRows(state);
|
|
2700
|
+
this.#positionDropdown(state);
|
|
2701
|
+
return state;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
#closeDropdown() {
|
|
2705
|
+
if (this.#openDropdown == null) return;
|
|
2706
|
+
this.#openDropdown.element.remove();
|
|
2707
|
+
this.#openDropdown.sentinel?.removeAttribute("data-opened");
|
|
2708
|
+
this.#openDropdown = null;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
/** Re-render rows + re-position the open dropdown. Called when the set
|
|
2712
|
+
* of overflowed entries changes (e.g. resize) while it's open. */
|
|
2713
|
+
#refreshOpenDropdown() {
|
|
2714
|
+
if (this.#openDropdown == null) return;
|
|
2715
|
+
// If the sentinel went away (everything fits again), drop the dropdown.
|
|
2716
|
+
if (this.#openDropdown.sentinel?.hidden) {
|
|
2717
|
+
this.#closeDropdown();
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
this.#renderDropdownRows(this.#openDropdown);
|
|
2721
|
+
this.#positionDropdown(this.#openDropdown);
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
#renderDropdownRows(state) {
|
|
2725
|
+
const { element, area } = state;
|
|
2726
|
+
element.replaceChildren();
|
|
2727
|
+
// Newest hidden entries surface first — visually they sat closest to
|
|
2728
|
+
// the sentinel before being pushed off, so the user reads them at
|
|
2729
|
+
// the top of the dropdown. We hide from the trailing edge (most-
|
|
2730
|
+
// recently-pushed first), so reversing the natural DOM order lines
|
|
2731
|
+
// the dropdown rows up "newest → older" top to bottom.
|
|
2732
|
+
const entries = this.#entriesForArea(area)
|
|
2733
|
+
.filter(e => e.element?.getAttribute("data-overflowed") === "1")
|
|
2734
|
+
.reverse();
|
|
2735
|
+
for (const entry of entries) {
|
|
2736
|
+
const row = document.createElement("button");
|
|
2737
|
+
row.type = "button";
|
|
2738
|
+
row.className = "clean cover_entry";
|
|
2739
|
+
row.setAttribute("data-cover-token", entry.token);
|
|
2740
|
+
if (entry.sectionBound != null) row.setAttribute("data-section-bound", entry.sectionBound);
|
|
2741
|
+
if (this.#activeToken === entry.token) row.setAttribute("data-active", "1");
|
|
2742
|
+
if (entry.minimized) row.setAttribute("data-minimized", "1");
|
|
2743
|
+
if (entry.title != null) row.setAttribute("title", entry.title);
|
|
2744
|
+
|
|
2745
|
+
const iconUrl = this.#resolveIconUrl(entry);
|
|
2746
|
+
if (iconUrl != null) {
|
|
2747
|
+
const span = document.createElement("span");
|
|
2748
|
+
span.className = "cover_icon";
|
|
2749
|
+
const img = document.createElement("img");
|
|
2750
|
+
img.alt = "";
|
|
2751
|
+
img.src = iconUrl;
|
|
2752
|
+
span.appendChild(img);
|
|
2753
|
+
this.#setBadgeAttr(span, entry.badge);
|
|
2754
|
+
row.appendChild(span);
|
|
2755
|
+
}
|
|
2756
|
+
const label = document.createElement("label");
|
|
2757
|
+
label.textContent = entry.title ?? "";
|
|
2758
|
+
row.appendChild(label);
|
|
2759
|
+
|
|
2760
|
+
const self = this;
|
|
2761
|
+
row.addEventListener("click", (event) => {
|
|
2762
|
+
event.stopPropagation();
|
|
2763
|
+
self.#closeDropdown();
|
|
2764
|
+
self.#onEntryClicked(entry.token);
|
|
2765
|
+
});
|
|
2766
|
+
// Right-click inside the overflow dropdown opens the entry's
|
|
2767
|
+
// context menu on top of it — the dropdown stays open so the user
|
|
2768
|
+
// can pick another row after dismissing the menu. The document-
|
|
2769
|
+
// level pointerdown handler also exempts the open context menu
|
|
2770
|
+
// from closing the dropdown (see constructor).
|
|
2771
|
+
row.addEventListener("contextmenu", (event) => {
|
|
2772
|
+
self.#onEntryContextMenu(entry.token, event);
|
|
2773
|
+
});
|
|
2774
|
+
if (entry.closable) {
|
|
2775
|
+
const close = document.createElement("span");
|
|
2776
|
+
close.className = "cover_entry_close";
|
|
2777
|
+
close.setAttribute("role", "button");
|
|
2778
|
+
close.setAttribute("aria-label", "Close");
|
|
2779
|
+
close.textContent = "✕";
|
|
2780
|
+
close.addEventListener("click", (event) => {
|
|
2781
|
+
event.stopPropagation();
|
|
2782
|
+
if (typeof entry.onAction === "function") entry.onAction("close");
|
|
2783
|
+
});
|
|
2784
|
+
row.appendChild(close);
|
|
2785
|
+
}
|
|
2786
|
+
element.appendChild(row);
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
#positionDropdown(state) {
|
|
2791
|
+
const { element, sentinel, areaKey } = state;
|
|
2792
|
+
const rect = sentinel.getBoundingClientRect();
|
|
2793
|
+
// Anchor above the sentinel — fixedBottom sits at the screen base, so
|
|
2794
|
+
// the dropdown opens upward. Use `bottom` rather than `top` so the
|
|
2795
|
+
// dropdown grows up from the anchor as more rows are added.
|
|
2796
|
+
const gap = 6;
|
|
2797
|
+
element.style.bottom = `${Math.max(0, window.innerHeight - rect.top + gap)}px`;
|
|
2798
|
+
element.style.top = "auto";
|
|
2799
|
+
if (areaKey === "instant") {
|
|
2800
|
+
// Right-align to the area's right edge so the dropdown stays
|
|
2801
|
+
// within the host margin even when the sentinel itself sits
|
|
2802
|
+
// anywhere along the bar's leading edge.
|
|
2803
|
+
const areaRect = state.area.getBoundingClientRect();
|
|
2804
|
+
element.style.right = `${Math.max(0, window.innerWidth - areaRect.right)}px`;
|
|
2805
|
+
element.style.left = "auto";
|
|
2806
|
+
} else {
|
|
2807
|
+
const areaRect = state.area.getBoundingClientRect();
|
|
2808
|
+
element.style.left = `${Math.max(0, areaRect.left)}px`;
|
|
2809
|
+
element.style.right = "auto";
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/**
|
|
2814
|
+
* Right-click on a cover-bar entry opens a small context menu in #topLayer:
|
|
2815
|
+
*
|
|
2816
|
+
* ── title ──────────────────
|
|
2817
|
+
* 화면 가운데로 이동 (placeholder until the embed exposes the API)
|
|
2818
|
+
* 최소화 / 복원 (label flips on entry.minimized)
|
|
2819
|
+
* 닫기 (fires onAction("close"))
|
|
2820
|
+
*
|
|
2821
|
+
* Internal page-handle entries don't get the menu — they have their own
|
|
2822
|
+
* navigation surface already. External-embed entries (onAction !== null)
|
|
2823
|
+
* are the target audience. The menu is single-instance: opening one closes
|
|
2824
|
+
* any prior open instance, and outside pointerdown / Escape close it (see
|
|
2825
|
+
* the document-level listeners in the constructor).
|
|
2826
|
+
*/
|
|
2827
|
+
#onEntryContextMenu(token, event) {
|
|
2828
|
+
const entry = this.#entries.find(e => e.token === token);
|
|
2829
|
+
if (entry == null || typeof entry.onAction !== "function") return;
|
|
2830
|
+
if (this.#topLayer == null) return;
|
|
2831
|
+
event.preventDefault();
|
|
2832
|
+
if (this.#openContextMenu != null) this.#closeContextMenu();
|
|
2833
|
+
this.#openContextMenu = this.#openContextMenuFor(entry, event.clientX, event.clientY);
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
#openContextMenuFor(entry, x, y) {
|
|
2837
|
+
// SVG glyphs picked for visual parity with the hide-all toggle and the
|
|
2838
|
+
// overflow sentinel — same stroke weight (1.6 ~ 1.8) and 14x14 viewBox,
|
|
2839
|
+
// all in currentColor so hover / disabled inherit the menu's text tone.
|
|
2840
|
+
const SVG = EstreCoverBarHandle.menuIcons;
|
|
2841
|
+
const items = [
|
|
2842
|
+
{ label: "화면 가운데로 이동", action: "center", svg: SVG.center },
|
|
2843
|
+
entry.minimized
|
|
2844
|
+
? { label: "복원", action: "restore", svg: SVG.restore }
|
|
2845
|
+
: { label: "최소화", action: "minimize", svg: SVG.minimize },
|
|
2846
|
+
{ label: "닫기", action: "close", svg: SVG.close },
|
|
2847
|
+
];
|
|
2848
|
+
return this.openContextMenu({
|
|
2849
|
+
x, y,
|
|
2850
|
+
title: entry.title,
|
|
2851
|
+
items,
|
|
2852
|
+
onAction: (action) => this.#onContextMenuAction(entry.token, action),
|
|
2853
|
+
anchorData: { "data-cover-token": entry.token },
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
/**
|
|
2858
|
+
* Public context-menu surface — opens a menu in #topLayer at the cursor,
|
|
2859
|
+
* matching the chrome the cover-bar uses internally for entry right-click.
|
|
2860
|
+
* Host code (e.g. for a rootbar tab) calls this directly with its own
|
|
2861
|
+
* items / onAction, instead of routing through a cover_entry.
|
|
2862
|
+
*
|
|
2863
|
+
* Items are `{ label, action, svg?, disabled? }`. The menu is single-
|
|
2864
|
+
* instance — opening one closes any prior open menu. Outside pointerdown
|
|
2865
|
+
* and Escape close it; both branches are wired in the constructor.
|
|
2866
|
+
*
|
|
2867
|
+
* Returns the menu state object (or null if #topLayer is missing).
|
|
2868
|
+
*/
|
|
2869
|
+
openContextMenu({ x, y, title, items, onAction, anchorData }) {
|
|
2870
|
+
if (this.#topLayer == null) return null;
|
|
2871
|
+
if (this.#openContextMenu != null) this.#closeContextMenu();
|
|
2872
|
+
|
|
2873
|
+
const menu = document.createElement("div");
|
|
2874
|
+
menu.className = "cover_entry_menu";
|
|
2875
|
+
if (anchorData != null) {
|
|
2876
|
+
for (const [k, v] of Object.entries(anchorData)) menu.setAttribute(k, v);
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
if (title != null) {
|
|
2880
|
+
const titleEl = document.createElement("header");
|
|
2881
|
+
titleEl.className = "cover_menu_title";
|
|
2882
|
+
titleEl.textContent = title;
|
|
2883
|
+
menu.appendChild(titleEl);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
const self = this;
|
|
2887
|
+
for (const it of items ?? []) {
|
|
2888
|
+
const item = document.createElement("button");
|
|
2889
|
+
item.type = "button";
|
|
2890
|
+
item.className = "clean cover_menu_item";
|
|
2891
|
+
item.setAttribute("data-action", it.action);
|
|
2892
|
+
const iconSpan = document.createElement("span");
|
|
2893
|
+
iconSpan.className = "cover_menu_item_icon";
|
|
2894
|
+
if (it.svg != null) iconSpan.innerHTML = it.svg;
|
|
2895
|
+
item.appendChild(iconSpan);
|
|
2896
|
+
const labelSpan = document.createElement("span");
|
|
2897
|
+
labelSpan.className = "cover_menu_item_label";
|
|
2898
|
+
labelSpan.textContent = it.label;
|
|
2899
|
+
item.appendChild(labelSpan);
|
|
2900
|
+
if (it.disabled) item.disabled = true;
|
|
2901
|
+
item.addEventListener("click", (ev) => {
|
|
2902
|
+
ev.stopPropagation();
|
|
2903
|
+
self.#closeContextMenu();
|
|
2904
|
+
onAction?.(it.action);
|
|
2905
|
+
});
|
|
2906
|
+
menu.appendChild(item);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
const state = { element: menu };
|
|
2910
|
+
this.#topLayer.appendChild(menu);
|
|
2911
|
+
this.#positionContextMenu(state, x, y);
|
|
2912
|
+
this.#openContextMenu = state;
|
|
2913
|
+
return state;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/**
|
|
2917
|
+
* Anchor the context menu at the cursor and pick a quadrant so it always
|
|
2918
|
+
* opens *toward* the viewport center — bottom-right click → menu pinned to
|
|
2919
|
+
* its bottom-right (= grows up-and-left), top-left click → menu pinned
|
|
2920
|
+
* top-left (= grows down-and-right), etc. The transform-origin custom
|
|
2921
|
+
* property is set inline so the scale-grow open animation pivots at the
|
|
2922
|
+
* click point.
|
|
2923
|
+
*/
|
|
2924
|
+
#positionContextMenu(state, x, y) {
|
|
2925
|
+
const { element } = state;
|
|
2926
|
+
const w = window.innerWidth;
|
|
2927
|
+
const h = window.innerHeight;
|
|
2928
|
+
const isRight = x > w / 2;
|
|
2929
|
+
const isBottom = y > h / 2;
|
|
2930
|
+
element.style.left = isRight ? "auto" : `${x}px`;
|
|
2931
|
+
element.style.right = isRight ? `${Math.max(0, w - x)}px` : "auto";
|
|
2932
|
+
element.style.top = isBottom ? "auto" : `${y}px`;
|
|
2933
|
+
element.style.bottom = isBottom ? `${Math.max(0, h - y)}px` : "auto";
|
|
2934
|
+
// Origin corner = the cursor-anchored corner; CSS uses keywords (`top`
|
|
2935
|
+
// / `bottom` / `left` / `right`) for transform-origin so it stays
|
|
2936
|
+
// pixel-exact regardless of layout shifts during animation.
|
|
2937
|
+
const vert = isBottom ? "bottom" : "top";
|
|
2938
|
+
const horiz = isRight ? "right" : "left";
|
|
2939
|
+
element.style.setProperty("--menu-origin", `${vert} ${horiz}`);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
/**
|
|
2943
|
+
* Fade the menu out (0.2s ease) before detaching, so the close looks like
|
|
2944
|
+
* a deliberate dismiss rather than a pop. The element is removed on the
|
|
2945
|
+
* transitionend; null-ing the state first prevents a re-entrant close from
|
|
2946
|
+
* doubling up the fade.
|
|
2947
|
+
*/
|
|
2948
|
+
#closeContextMenu() {
|
|
2949
|
+
if (this.#openContextMenu == null) return;
|
|
2950
|
+
const el = this.#openContextMenu.element;
|
|
2951
|
+
this.#openContextMenu = null;
|
|
2952
|
+
el.setAttribute("data-closing", "1");
|
|
2953
|
+
const remove = () => el.remove();
|
|
2954
|
+
el.addEventListener("transitionend", remove, { once: true });
|
|
2955
|
+
// Safety fallback — if the animation is interrupted (e.g. element no
|
|
2956
|
+
// longer in DOM), still detach after the expected duration.
|
|
2957
|
+
setTimeout(remove, 260);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/** Route a context-menu click to the right action. The first three actions
|
|
2961
|
+
* delegate to the same onAction surface as bar-click intent (so embed
|
|
2962
|
+
* wirings only need one handler); "center" is a placeholder action that
|
|
2963
|
+
* the embed may wire later as a window-center transition. */
|
|
2964
|
+
#onContextMenuAction(token, action) {
|
|
2965
|
+
const entry = this.#entries.find(e => e.token === token);
|
|
2966
|
+
if (entry == null || typeof entry.onAction !== "function") return;
|
|
2967
|
+
entry.onAction(action);
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
// Expose estreUi on window so ES-module-realm host integrations (external embeds
|
|
2973
|
+
// loaded as <script type="module">) can reach the public API. Same-realm classic
|
|
2974
|
+
// scripts already see the lexical const; this line only adds the cross-realm
|
|
2975
|
+
// surface. See review #011.
|
|
2976
|
+
window.estreUi = estreUi;
|
|
2977
|
+
|