duet-ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/dist/chrome.d.ts +59 -0
- package/dist/chrome.js +494 -0
- package/dist/duet-panes.d.ts +45 -0
- package/dist/duet-panes.js +62 -0
- package/dist/duet.css +334 -0
- package/dist/geometry.d.ts +203 -0
- package/dist/geometry.js +215 -0
- package/dist/glassyui/glass-provider.d.ts +21 -0
- package/dist/glassyui/glass-provider.js +146 -0
- package/dist/glassyui/glass-surface.d.ts +40 -0
- package/dist/glassyui/glass-surface.js +56 -0
- package/dist/glassyui/lib/merge-refs.d.ts +2 -0
- package/dist/glassyui/lib/merge-refs.js +19 -0
- package/dist/glassyui/lib/preferences.d.ts +28 -0
- package/dist/glassyui/lib/preferences.js +118 -0
- package/dist/glassyui/lib/refraction-map.d.ts +140 -0
- package/dist/glassyui/lib/refraction-map.js +533 -0
- package/dist/glassyui/lib/scene-context.d.ts +14 -0
- package/dist/glassyui/lib/scene-context.js +41 -0
- package/dist/glassyui/lib/slab-optics.d.ts +4 -0
- package/dist/glassyui/lib/slab-optics.js +6 -0
- package/dist/glassyui/lib/use-refraction-filter.d.ts +79 -0
- package/dist/glassyui/lib/use-refraction-filter.js +620 -0
- package/dist/glassyui/styles/liquid.css +5456 -0
- package/dist/icons.d.ts +28 -0
- package/dist/icons.js +11 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/layout.d.ts +10 -0
- package/dist/layout.js +24 -0
- package/dist/panes.d.ts +14 -0
- package/dist/panes.js +17 -0
- package/package.json +34 -4
package/dist/chrome.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { ArrowLeftRight, ChevronLeft, Ellipsis, Search, X } from "./icons.js";
|
|
5
|
+
import { DUO, INLINE_WIDTH, ISLAND, MORPH, isSplit, magnet, plan, stable } from "./geometry.js";
|
|
6
|
+
import { GlassProvider } from "./glassyui/glass-provider.js";
|
|
7
|
+
import { GlassSurface } from "./glassyui/glass-surface.js";
|
|
8
|
+
import "./duet.css";
|
|
9
|
+
// --- context ------------------------------------------------------------------------------------
|
|
10
|
+
// A desktop page that draws a Duo around this page in an iframe (the site's web Duo) sends the signals the
|
|
11
|
+
// real device would give: `postMessage({ type: "duet:signals", signals }, origin)`. Same origin only. The page
|
|
12
|
+
// says `duet:ready` once it listens, so the host answers then instead of guessing when the page has loaded.
|
|
13
|
+
let emulated = null;
|
|
14
|
+
if (typeof window !== "undefined" && window.top !== window) {
|
|
15
|
+
addEventListener("message", (e) => {
|
|
16
|
+
if (e.source !== parent || e.origin !== location.origin || e.data?.type !== "duet:signals")
|
|
17
|
+
return;
|
|
18
|
+
emulated = e.data.signals;
|
|
19
|
+
listeners.forEach((f) => f());
|
|
20
|
+
});
|
|
21
|
+
parent.postMessage({ type: "duet:ready" }, location.origin);
|
|
22
|
+
}
|
|
23
|
+
/** The safe-area inset on one side as CSS: the real env() value, or the emulated one. */
|
|
24
|
+
const envInset = (side) => emulated ? `${side === "right" ? emulated.insetRight : emulated.insetLeft}px` : `env(safe-area-inset-${side})`;
|
|
25
|
+
let probe = null;
|
|
26
|
+
function signals() {
|
|
27
|
+
if (emulated)
|
|
28
|
+
return emulated;
|
|
29
|
+
if (!probe) {
|
|
30
|
+
probe = document.createElement("div");
|
|
31
|
+
probe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;"
|
|
32
|
+
+ "padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)";
|
|
33
|
+
document.body.appendChild(probe);
|
|
34
|
+
}
|
|
35
|
+
const pad = getComputedStyle(probe);
|
|
36
|
+
return {
|
|
37
|
+
innerWidth: innerWidth,
|
|
38
|
+
innerHeight: innerHeight,
|
|
39
|
+
outerWidth: outerWidth,
|
|
40
|
+
outerHeight: outerHeight,
|
|
41
|
+
visualHeight: visualViewport?.height ?? innerHeight,
|
|
42
|
+
insetTop: parseFloat(pad.paddingTop) || 0,
|
|
43
|
+
insetBottom: parseFloat(pad.paddingBottom) || 0,
|
|
44
|
+
screenWidth: innerWidth > innerHeight ? Math.max(screen.width, screen.height) : Math.min(screen.width, screen.height),
|
|
45
|
+
screenHeight: innerWidth > innerHeight ? Math.min(screen.width, screen.height) : Math.max(screen.width, screen.height),
|
|
46
|
+
insetRight: parseFloat(pad.paddingRight) || 0,
|
|
47
|
+
insetLeft: parseFloat(pad.paddingLeft) || 0,
|
|
48
|
+
framed: window.top !== window,
|
|
49
|
+
pixelRatio: devicePixelRatio,
|
|
50
|
+
standalone: navigator.standalone === true || matchMedia("(display-mode: standalone)").matches,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// Inline: the side is the user's, remembered per site, and apart for split view (Safari's column is on the pane's
|
|
54
|
+
// outer edge, which the page cannot see, so the controls follow it once moved there).
|
|
55
|
+
const sideKey = (split) => split ? "duet-side-split" : "duet-side";
|
|
56
|
+
const preferred = new Map();
|
|
57
|
+
const listeners = new Set();
|
|
58
|
+
/** undefined until the user moves the controls: then detect() picks the side for the context. */
|
|
59
|
+
function preferredSide(split) {
|
|
60
|
+
const key = sideKey(split);
|
|
61
|
+
if (!preferred.has(key)) {
|
|
62
|
+
try {
|
|
63
|
+
const v = localStorage.getItem(key);
|
|
64
|
+
preferred.set(key, v === "left" || v === "right" ? v : null);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
preferred.set(key, null);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return preferred.get(key) ?? undefined;
|
|
71
|
+
}
|
|
72
|
+
function setPreferredSide(side) {
|
|
73
|
+
const key = sideKey(!!snap?.split);
|
|
74
|
+
preferred.set(key, side);
|
|
75
|
+
try {
|
|
76
|
+
localStorage.setItem(key, side);
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
listeners.forEach((f) => f());
|
|
80
|
+
}
|
|
81
|
+
/** Moves the inline controls to `side` in one frame: data-drag="switch" holds every transition off until the
|
|
82
|
+
* new layout has painted (CSS), so no control glides across the page on its own. */
|
|
83
|
+
function switchSide(el, side) {
|
|
84
|
+
el.dataset.drag = "switch";
|
|
85
|
+
setPreferredSide(side);
|
|
86
|
+
requestAnimationFrame(() => requestAnimationFrame(() => { if (el.dataset.drag === "switch")
|
|
87
|
+
delete el.dataset.drag; }));
|
|
88
|
+
}
|
|
89
|
+
let snap = null, snapKey = "";
|
|
90
|
+
const damper = { flips: [] };
|
|
91
|
+
let pinTimer;
|
|
92
|
+
function read() {
|
|
93
|
+
const s = signals(), c = stable(damper, s, preferredSide(isSplit(s)), performance.now());
|
|
94
|
+
// A pin lifts on its own time, with no resize to re-read on.
|
|
95
|
+
if (damper.pinned && !pinTimer)
|
|
96
|
+
pinTimer = setTimeout(() => { pinTimer = undefined; listeners.forEach((f) => f()); }, damper.until - performance.now() + 20);
|
|
97
|
+
const k = JSON.stringify(c);
|
|
98
|
+
if (k !== snapKey)
|
|
99
|
+
[snapKey, snap] = [k, c];
|
|
100
|
+
return snap;
|
|
101
|
+
}
|
|
102
|
+
function subscribe(cb) {
|
|
103
|
+
// Safe-area insets and outerWidth settle up to ~400ms after resize and rotation.
|
|
104
|
+
const on = () => { cb(); setTimeout(cb, 300); setTimeout(cb, 600); };
|
|
105
|
+
addEventListener("resize", on);
|
|
106
|
+
addEventListener("orientationchange", on);
|
|
107
|
+
visualViewport?.addEventListener("resize", on);
|
|
108
|
+
// A fresh Safari tab can hydrate before its window geometry settles, with no resize afterwards.
|
|
109
|
+
const t = [0, 300, 1000].map((ms) => setTimeout(cb, ms));
|
|
110
|
+
listeners.add(cb);
|
|
111
|
+
return () => {
|
|
112
|
+
listeners.delete(cb);
|
|
113
|
+
t.forEach(clearTimeout);
|
|
114
|
+
removeEventListener("resize", on);
|
|
115
|
+
removeEventListener("orientationchange", on);
|
|
116
|
+
visualViewport?.removeEventListener("resize", on);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const ssr = { mode: "column", duo: true, layout: "narrow", side: "right", width: 382, height: 580, offsetTop: 0, screenWidth: 466, screenHeight: 678, insetTop: 0, insetBottom: 0 };
|
|
120
|
+
export function useDuet() {
|
|
121
|
+
return useSyncExternalStore(subscribe, read, () => ssr);
|
|
122
|
+
}
|
|
123
|
+
function useMinimized(on, scroller) {
|
|
124
|
+
const [min, setMin] = useState(false);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!on)
|
|
127
|
+
return;
|
|
128
|
+
// Captured, so a scroll inside the page (a pane of <DuetPanes>) counts as well as the scroller's own; each
|
|
129
|
+
// scrolling element keeps its own last position.
|
|
130
|
+
const target = scroller.current ?? window, last = new WeakMap();
|
|
131
|
+
const f = (e) => {
|
|
132
|
+
const el = e.target instanceof Element ? e.target : null, y = el ? el.scrollTop : scrollY, key = el ?? window;
|
|
133
|
+
const d = y - (last.get(key) ?? 0);
|
|
134
|
+
if (Math.abs(d) > 8) {
|
|
135
|
+
setMin(d > 0 && y > 40);
|
|
136
|
+
last.set(key, y);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
target.addEventListener("scroll", f, { passive: true, capture: true });
|
|
140
|
+
return () => target.removeEventListener("scroll", f, { capture: true });
|
|
141
|
+
}, [on, scroller]);
|
|
142
|
+
return on && min;
|
|
143
|
+
}
|
|
144
|
+
/** Safari tab: fixed content is clipped to the page viewport (so is sticky), but in-document content paints
|
|
145
|
+
* into Safari's column. So the chrome is an absolute layer at the document top, and the inner scroller keeps
|
|
146
|
+
* the document itself from scrolling, so the layer never moves. A scroll-driven transform lags momentum
|
|
147
|
+
* scrolling and drifts (issue #1); `follow` only covers pages without the scroller. Touches there reach the page
|
|
148
|
+
* but are not hit-tested to our elements, and Safari keeps the touch end (no pointerup, no click), so we route
|
|
149
|
+
* them by coordinates on pointerdown. Nothing in the column scrolls, so a touch there is always a tap.
|
|
150
|
+
* Split pane: the column is on the pane's outer edge, which the page cannot see, so the document spans a column
|
|
151
|
+
* on each side, scrolled 84 across, and the controls are drawn in both (refs[0] left, refs[1] right). Each pane
|
|
152
|
+
* shows only its own column, and a tap there names the side (`tapSide`). */
|
|
153
|
+
let tapSide = "right";
|
|
154
|
+
/** Without shrink-to-fit=no Safari zooms a split pane out to fit the layer's overflow, which reads as another
|
|
155
|
+
* window size and flips the mode. It applies even when set late, but must land before the first column paint. */
|
|
156
|
+
function noShrink() {
|
|
157
|
+
const meta = document.querySelector("meta[name=viewport]");
|
|
158
|
+
if (meta && !/shrink-to-fit/.test(meta.content))
|
|
159
|
+
meta.content += ",shrink-to-fit=no";
|
|
160
|
+
}
|
|
161
|
+
if (typeof document !== "undefined")
|
|
162
|
+
noShrink();
|
|
163
|
+
function useColumnLayer(on, split, column) {
|
|
164
|
+
const refs = [useRef(null), useRef(null)];
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const els = refs.map((r) => r.current).filter((el) => !!el);
|
|
167
|
+
if (!on || !els.length)
|
|
168
|
+
return;
|
|
169
|
+
const html = document.documentElement.style, prev = html.touchAction;
|
|
170
|
+
html.touchAction = "pan-y"; // the layer widens the document; keep the page from panning sideways
|
|
171
|
+
const follow = () => {
|
|
172
|
+
if (split && Math.round(scrollX) !== column)
|
|
173
|
+
scrollTo(column, scrollY);
|
|
174
|
+
for (const el of els)
|
|
175
|
+
el.style.transform = scrollY ? `translateY(${scrollY}px)` : "";
|
|
176
|
+
};
|
|
177
|
+
const tap = (e) => {
|
|
178
|
+
if (e.clientX >= 0 && e.clientX < innerWidth)
|
|
179
|
+
return; // inside the viewport, normal hit-testing applies
|
|
180
|
+
const el = split ? refs[e.clientX < 0 ? 0 : 1].current : refs[0].current;
|
|
181
|
+
if (split)
|
|
182
|
+
tapSide = e.clientX < 0 ? "left" : "right";
|
|
183
|
+
// Skip a button that became an open menu: the menu covers it, and its own slot sits on top.
|
|
184
|
+
for (const b of el?.querySelectorAll(":not([data-unrolled]) > button") ?? []) {
|
|
185
|
+
const r = b.getBoundingClientRect();
|
|
186
|
+
if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) {
|
|
187
|
+
b.click();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
follow();
|
|
193
|
+
// Safari restores or resets the sideways scroll on load and resize.
|
|
194
|
+
const t = [0, 300, 1000].map((ms) => setTimeout(follow, ms));
|
|
195
|
+
addEventListener("scroll", follow, { passive: true });
|
|
196
|
+
addEventListener("resize", follow);
|
|
197
|
+
addEventListener("pointerdown", tap);
|
|
198
|
+
return () => {
|
|
199
|
+
html.touchAction = prev;
|
|
200
|
+
for (const el of els)
|
|
201
|
+
el.style.transform = "";
|
|
202
|
+
t.forEach(clearTimeout);
|
|
203
|
+
removeEventListener("scroll", follow);
|
|
204
|
+
removeEventListener("resize", follow);
|
|
205
|
+
removeEventListener("pointerdown", tap);
|
|
206
|
+
};
|
|
207
|
+
}, [on, split, column]); // eslint-disable-line react-hooks/exhaustive-deps -- refs are stable
|
|
208
|
+
return refs;
|
|
209
|
+
}
|
|
210
|
+
/** Inline: drag any control across the middle of the page to move the strip to that side. The controls
|
|
211
|
+
* follow the finger (--duet-drag); a drag never becomes a tap. */
|
|
212
|
+
function useSideDrag(side, nav, onStart) {
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
const el = nav.current;
|
|
215
|
+
if (!side || !el)
|
|
216
|
+
return;
|
|
217
|
+
let x0 = null, y0 = 0, dragging = false, free = false;
|
|
218
|
+
const down = (e) => { if (e.target.closest(".duet-glass:not(.duet-menu)")) {
|
|
219
|
+
x0 = e.clientX;
|
|
220
|
+
y0 = e.clientY;
|
|
221
|
+
dragging = false;
|
|
222
|
+
} };
|
|
223
|
+
const move = (e) => {
|
|
224
|
+
if (x0 === null)
|
|
225
|
+
return;
|
|
226
|
+
// Only a deliberate sideways swipe picks the strip up: a scrub along the rail, or a wobbly tap, never does.
|
|
227
|
+
const dx = Math.abs(e.clientX - x0), dy = Math.abs(e.clientY - y0);
|
|
228
|
+
if (!dragging && dy > 12 && dy > dx) {
|
|
229
|
+
x0 = null;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!dragging && dx > 24 && dx > 2 * dy) {
|
|
233
|
+
dragging = true;
|
|
234
|
+
onStart();
|
|
235
|
+
}
|
|
236
|
+
if (!dragging)
|
|
237
|
+
return;
|
|
238
|
+
const m = magnet(side, e.clientX - x0);
|
|
239
|
+
if (m.free !== free) {
|
|
240
|
+
free = m.free;
|
|
241
|
+
el.dataset.drag = free ? "free" : "held";
|
|
242
|
+
}
|
|
243
|
+
el.style.setProperty("--duet-drag", `${m.offset}px`);
|
|
244
|
+
};
|
|
245
|
+
const up = (e) => {
|
|
246
|
+
if (x0 === null)
|
|
247
|
+
return;
|
|
248
|
+
x0 = null;
|
|
249
|
+
const to = e.clientX < innerWidth / 2 ? "left" : "right";
|
|
250
|
+
const flip = dragging && free && to !== side;
|
|
251
|
+
if (flip)
|
|
252
|
+
el.dataset.drag = "switch";
|
|
253
|
+
else
|
|
254
|
+
delete el.dataset.drag;
|
|
255
|
+
el.style.removeProperty("--duet-drag");
|
|
256
|
+
if (flip)
|
|
257
|
+
switchSide(el, to);
|
|
258
|
+
free = false;
|
|
259
|
+
};
|
|
260
|
+
const click = (e) => { if (dragging) {
|
|
261
|
+
e.stopPropagation();
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
dragging = false;
|
|
264
|
+
} };
|
|
265
|
+
el.addEventListener("pointerdown", down);
|
|
266
|
+
addEventListener("pointermove", move);
|
|
267
|
+
addEventListener("pointerup", up);
|
|
268
|
+
addEventListener("pointercancel", up);
|
|
269
|
+
el.addEventListener("click", click, true);
|
|
270
|
+
return () => {
|
|
271
|
+
el.removeEventListener("pointerdown", down);
|
|
272
|
+
removeEventListener("pointermove", move);
|
|
273
|
+
removeEventListener("pointerup", up);
|
|
274
|
+
removeEventListener("pointercancel", up);
|
|
275
|
+
el.removeEventListener("click", click, true);
|
|
276
|
+
};
|
|
277
|
+
}, [side, nav]); // eslint-disable-line react-hooks/exhaustive-deps -- onStart only resets menu and scrub state
|
|
278
|
+
}
|
|
279
|
+
// --- rendering ----------------------------------------------------------------------------------
|
|
280
|
+
export function Duet(p) {
|
|
281
|
+
const ctx = useDuet();
|
|
282
|
+
const scroller = useRef(null);
|
|
283
|
+
const minimized = useMinimized(!!p.minimizeOnScroll, scroller);
|
|
284
|
+
const [menu, setMenuNow] = useState(null);
|
|
285
|
+
// The menu that is rolling back into its button; it stays mounted until its animation ends.
|
|
286
|
+
const [closing, setClosing] = useState(null);
|
|
287
|
+
// Index of the tab under the finger while scrubbing the full pill, like Apple News' tab bar.
|
|
288
|
+
const [scrub, setScrub] = useState(null);
|
|
289
|
+
// A touch on the full pill is a tap until it is held or dragged; only then does the pill swell into a scrub.
|
|
290
|
+
const press = useRef(null);
|
|
291
|
+
// A tap glides the lens to the new tab as a clear magnifier, without swelling the pill, like Apple News.
|
|
292
|
+
const [glide, setGlide] = useState(false);
|
|
293
|
+
const glideTimer = useRef(0);
|
|
294
|
+
useEffect(() => () => { clearTimeout(glideTimer.current); if (press.current)
|
|
295
|
+
clearTimeout(press.current.timer); }, []);
|
|
296
|
+
const setMenu = (n) => {
|
|
297
|
+
if (menu && menu !== n)
|
|
298
|
+
setClosing(matchMedia("(prefers-reduced-motion: reduce)").matches ? null : menu);
|
|
299
|
+
if (n && closing === n)
|
|
300
|
+
setClosing(null);
|
|
301
|
+
setMenuNow(n);
|
|
302
|
+
};
|
|
303
|
+
const actions = (p.actions ?? []).filter((a) => !a.destructive), destructive = (p.actions ?? []).filter((a) => a.destructive);
|
|
304
|
+
const leading = p.leading ?? [];
|
|
305
|
+
const tabItems = p.tabs ? [...p.tabs.items, ...(p.tabs.search ? [p.tabs.search] : [])] : [];
|
|
306
|
+
const spec = p.close
|
|
307
|
+
? { back: true, leading: 0, actions: 0, tabs: 0, searchButton: false, accessory: false, minimized, menuExtra: 0 }
|
|
308
|
+
: {
|
|
309
|
+
back: !!p.back, leading: leading.length, actions: actions.length, tabs: tabItems.length,
|
|
310
|
+
searchButton: !p.tabs && !!p.searchButton, accessory: !!p.accessory, minimized,
|
|
311
|
+
menuExtra: (ctx.mode === "inline" ? 1 : 0) + destructive.length, primary: !!p.primary,
|
|
312
|
+
};
|
|
313
|
+
// Split pane in column mode: the controls are drawn in both of Safari's possible columns (useColumnLayer).
|
|
314
|
+
const split = ctx.mode === "column" && !!ctx.split, column = ctx.screenWidth - ctx.width;
|
|
315
|
+
const sides = split ? ["left", "right"] : [ctx.side];
|
|
316
|
+
const planFor = (side) => plan(side === ctx.side ? ctx : { ...ctx, side }, spec);
|
|
317
|
+
// The copy whose column opened the menu draws it; the other copy's column is in the other pane.
|
|
318
|
+
const [menuSide, setMenuSide] = useState(ctx.side);
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
// The page spans the window and the controls float over it; these insets are how far text and buttons keep
|
|
321
|
+
// clear on each edge (layout.ts). In Safari's column the scroller runs on under the column (an emulated
|
|
322
|
+
// column's frame already spans it), so either way the inset there is the column's width.
|
|
323
|
+
const r = document.documentElement.style;
|
|
324
|
+
const inset = ctx.mode === "strip" ? envInset(ctx.side) : ctx.mode === "inline" ? `${INLINE_WIDTH}px` : `${ctx.screenWidth - ctx.width}px`;
|
|
325
|
+
const right = ctx.side === "right" || split ? inset : envInset("right"), left = ctx.side === "left" || split ? inset : envInset("left");
|
|
326
|
+
// --duet-edge-*: the window's insets, kept for panes, which set --duet-inset-* to their own.
|
|
327
|
+
for (const [k, v] of [["right", right], ["left", left]]) {
|
|
328
|
+
r.setProperty(`--duet-inset-${k}`, v);
|
|
329
|
+
r.setProperty(`--duet-edge-${k}`, v);
|
|
330
|
+
}
|
|
331
|
+
// iOS blurs the top of a Home Screen web app (scroll-edge effect) without reporting a safe-area
|
|
332
|
+
// inset, closed and unfolded; pages pad their top content by this much to stay sharp.
|
|
333
|
+
r.setProperty("--duet-inset-top", ctx.mode === "strip" ? `${DUO.edgeBlur}px` : "0px");
|
|
334
|
+
document.documentElement.dataset.duetMode = ctx.mode;
|
|
335
|
+
document.documentElement.toggleAttribute("data-duet-emulated", !!emulated);
|
|
336
|
+
document.documentElement.toggleAttribute("data-duet-split", split);
|
|
337
|
+
}, [ctx.mode, ctx.side, ctx.screenWidth, ctx.width, split]);
|
|
338
|
+
const [nav, nav2] = useColumnLayer(ctx.mode === "column", split, column);
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
// Any touch outside the chrome closes an open menu, like a native context menu.
|
|
341
|
+
if (!menu)
|
|
342
|
+
return;
|
|
343
|
+
const close = (e) => {
|
|
344
|
+
if (![nav, nav2].some((n) => n.current?.contains(e.target)) && e.clientX >= 0 && e.clientX < innerWidth)
|
|
345
|
+
setMenu(null);
|
|
346
|
+
};
|
|
347
|
+
addEventListener("pointerdown", close);
|
|
348
|
+
return () => removeEventListener("pointerdown", close);
|
|
349
|
+
}, [menu, nav, nav2]); // eslint-disable-line react-hooks/exhaustive-deps -- setMenu only reads menu
|
|
350
|
+
useSideDrag(ctx.mode === "inline" ? ctx.side : null, nav, () => { setMenu(null); setScrub(null); });
|
|
351
|
+
const selected = p.tabs?.selected;
|
|
352
|
+
const badge = (a) => a.badge !== undefined && a.badge !== "" && _jsx("span", { className: "duet-badge", children: a.badge });
|
|
353
|
+
const tint = (a) => a.tint ? { "--duet-tint": a.tint } : undefined;
|
|
354
|
+
const item = (a) => _jsxs("button", { className: "duet-item", "aria-label": a.label, onClick: a.onPress, children: [a.icon, badge(a)] }, a.id);
|
|
355
|
+
const toggle = (m) => () => { if (split)
|
|
356
|
+
setMenuSide(tapSide); setMenu(menu === m ? null : m); };
|
|
357
|
+
const backButton = p.back && _jsx("button", { className: "duet-item", "aria-label": p.back.label ?? "Back", onClick: p.back.onPress, children: _jsx(ChevronLeft, { size: 24, strokeWidth: 2.2 }) });
|
|
358
|
+
const moreButton = _jsx("button", { className: "duet-item", "aria-label": "More", "aria-expanded": menu === "more", onClick: toggle("more"), children: _jsx(Ellipsis, { size: 22 }) });
|
|
359
|
+
const searchButton = _jsx("button", { className: "duet-item", "aria-label": "Search", onClick: p.searchButton, children: _jsx(Search, { size: 22 }) });
|
|
360
|
+
// Minimized, the selected tab opens the full list, like tapping a minimized native tab bar. In the full
|
|
361
|
+
// pill a touch selects on release (scrub below); clicks still select for keyboards and routed column taps.
|
|
362
|
+
const tabButton = (t, min, i) => (_jsxs("button", { role: "tab", "aria-selected": t.id === selected, "aria-label": t.label, className: "duet-tab", "data-on": (scrub === null ? t.id === selected : scrub === i) || undefined, "data-tint": t.tint ? "" : undefined, style: { ...tint(t), "--j": i }, onClick: (e) => { if (min)
|
|
363
|
+
toggle("tabs")();
|
|
364
|
+
else if (scrub === null && e.detail === 0)
|
|
365
|
+
glideTo(t.id); }, children: [t.icon, min && badge(t)] }, t.id));
|
|
366
|
+
// The lens keeps its glass while it travels or settles, then melts back into the selection.
|
|
367
|
+
const settle = () => {
|
|
368
|
+
setGlide(true);
|
|
369
|
+
clearTimeout(glideTimer.current);
|
|
370
|
+
glideTimer.current = window.setTimeout(() => setGlide(false), 420);
|
|
371
|
+
};
|
|
372
|
+
const glideTo = (id) => {
|
|
373
|
+
if (id !== selected)
|
|
374
|
+
settle();
|
|
375
|
+
p.tabs.onSelect(id);
|
|
376
|
+
};
|
|
377
|
+
// Scrub: holding or dragging on the full pill swells it and shows labels; a lens follows the finger over the
|
|
378
|
+
// tabs and the tab under it lights up; lifting selects it. A quick tap just glides the lens to the tab.
|
|
379
|
+
// Pointer capture keeps the drag even off the pill.
|
|
380
|
+
const HOLD_MS = 250, SLOP = 8, SWELL = { x: 16, y: 12 };
|
|
381
|
+
const endPress = () => { if (press.current)
|
|
382
|
+
clearTimeout(press.current.timer); press.current = null; };
|
|
383
|
+
const tabAt = (pill, y) => {
|
|
384
|
+
const tabs = [...pill.querySelectorAll(":scope > .duet-tab")];
|
|
385
|
+
let best = 0;
|
|
386
|
+
tabs.forEach((b, i) => { const r = b.getBoundingClientRect(); if (y >= r.top)
|
|
387
|
+
best = i; });
|
|
388
|
+
return best;
|
|
389
|
+
};
|
|
390
|
+
const scrubbing = {
|
|
391
|
+
onPointerDown: (e) => {
|
|
392
|
+
if (e.button)
|
|
393
|
+
return;
|
|
394
|
+
const pill = e.currentTarget;
|
|
395
|
+
pill.setPointerCapture(e.pointerId);
|
|
396
|
+
endPress();
|
|
397
|
+
const timer = window.setTimeout(() => { if (press.current)
|
|
398
|
+
setScrub(tabAt(pill, press.current.y)); }, HOLD_MS);
|
|
399
|
+
press.current = { y0: e.clientY, y: e.clientY, timer };
|
|
400
|
+
},
|
|
401
|
+
onPointerMove: (e) => {
|
|
402
|
+
if (press.current)
|
|
403
|
+
press.current.y = e.clientY;
|
|
404
|
+
if (scrub !== null)
|
|
405
|
+
setScrub(tabAt(e.currentTarget, e.clientY));
|
|
406
|
+
else if (press.current && Math.abs(e.clientY - press.current.y0) > SLOP) {
|
|
407
|
+
clearTimeout(press.current.timer);
|
|
408
|
+
setScrub(tabAt(e.currentTarget, e.clientY));
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
onPointerUp: (e) => {
|
|
412
|
+
const tapped = press.current && scrub === null;
|
|
413
|
+
endPress();
|
|
414
|
+
if (scrub !== null) {
|
|
415
|
+
const t = tabItems[scrub];
|
|
416
|
+
setScrub(null);
|
|
417
|
+
settle();
|
|
418
|
+
if (t)
|
|
419
|
+
p.tabs.onSelect(t.id);
|
|
420
|
+
}
|
|
421
|
+
else if (tapped) {
|
|
422
|
+
const t = tabItems[tabAt(e.currentTarget, e.clientY)];
|
|
423
|
+
if (t)
|
|
424
|
+
glideTo(t.id);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
onPointerCancel: () => { endPress(); setScrub(null); },
|
|
428
|
+
};
|
|
429
|
+
// Held, the rail grows for real (wider towards the page, taller upwards), not just scaled, so the labels
|
|
430
|
+
// have room and clear its round ends, as Apple News and Music do.
|
|
431
|
+
const swell = (b, on, side) => !on ? b
|
|
432
|
+
: { ...b, top: b.top - SWELL.y, height: b.height + SWELL.y, width: b.width + SWELL.x, left: side === "right" ? b.left - SWELL.x : b.left };
|
|
433
|
+
const lensAt = scrub ?? Math.max(0, tabItems.findIndex((t) => t.id === selected));
|
|
434
|
+
// Menus only unroll from separate round buttons: "…" and the minimized tab.
|
|
435
|
+
const anchors = (pl) => ({ more: pl.more, tabs: pl.tabs?.min ? pl.tabs : undefined });
|
|
436
|
+
// A menu whose button went away mid-roll (a tab change scrolls to the top and un-minimizes the tab bar)
|
|
437
|
+
// never fires its animationend; drop it, or its button stays hidden as "unrolled".
|
|
438
|
+
const gone = anchors(planFor(split ? menuSide : ctx.side));
|
|
439
|
+
const closingGone = (closing === "tabs" && !gone.tabs) || (closing === "more" && !gone.more);
|
|
440
|
+
useEffect(() => { if (closingGone)
|
|
441
|
+
setClosing(null); }, [closingGone]);
|
|
442
|
+
// Island: icons only; the "…" island ends in a close slot where the "…" was, so the button reads as unrolled.
|
|
443
|
+
// Morph: icon and label rows; a touch outside closes it, so it needs no close row.
|
|
444
|
+
const morph = p.menuStyle === "morph";
|
|
445
|
+
const menuEl = (kind, anchor, items, radio, menuAt, morphAt) => {
|
|
446
|
+
if ((menu !== kind && closing !== kind) || !anchor)
|
|
447
|
+
return null;
|
|
448
|
+
const close = kind === "more" && !morph, rolling = menu !== kind;
|
|
449
|
+
return (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-menu", role: "menu", "data-style": morph ? "morph" : undefined, style: morph ? morphAt(anchor, items.length) : menuAt(anchor, items.length + (close ? 1 : 0)), "data-closing": rolling || undefined, onAnimationEnd: (e) => { if (rolling && (e.animationName === "duet-roll" || e.animationName === "duet-unmorph"))
|
|
450
|
+
setClosing(null); }, children: [items.map((a, i) => (_jsxs("button", { role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? a.id === selected : undefined, "aria-label": a.label, className: "duet-menu-item", "data-on": (radio && a.id === selected) || undefined, "data-destructive": a.destructive || undefined, "data-sep": (a.destructive && i > 0 && !items[i - 1].destructive) || undefined, "data-tint": a.tint ? "" : undefined, style: { ...tint(a), ...(rolling ? undefined : { animationDelay: `${morph ? 0.12 + 0.04 * i : 0.14 + 0.06 * (items.length - i)}s` }) }, onClick: () => { setMenu(null); if (radio)
|
|
451
|
+
p.tabs.onSelect(a.id);
|
|
452
|
+
else
|
|
453
|
+
a.onPress?.(); }, children: [a.icon, morph && _jsx("span", { className: "duet-menu-label", children: a.label }), badge(a)] }, a.id))), close && _jsx("button", { className: "duet-menu-item", "aria-label": "Close", onClick: () => setMenu(null), children: _jsx(X, { size: 20 }) })] }));
|
|
454
|
+
};
|
|
455
|
+
const bar = (side, i) => {
|
|
456
|
+
const pl = planFor(side), left = side === "left", anchor = anchors(pl);
|
|
457
|
+
const at = (b) => ({ top: b.top, left: pl.left, width: DUO.control, height: b.height });
|
|
458
|
+
// A menu is the button that opens it unrolling sideways, like the Dynamic Island: a pill of icon slots
|
|
459
|
+
// as tall as the button, outer edge on the button's, widening towards the page centre only.
|
|
460
|
+
const menuAt = (b, n) => {
|
|
461
|
+
const w = 2 * ISLAND.pad + n * ISLAND.item;
|
|
462
|
+
return { top: b.top + b.height / 2 - DUO.control / 2, left: left ? pl.left : pl.left + DUO.control - w, width: w, height: DUO.control, "--x0": `${pl.left}px` };
|
|
463
|
+
};
|
|
464
|
+
// Morph: the button grows into a labelled list, outer edge on the button's, top on its top; opening upwards
|
|
465
|
+
// instead when the list would run off the bottom of the screen.
|
|
466
|
+
const morphAt = (b, n) => {
|
|
467
|
+
const w = MORPH.width, h = 2 * MORPH.pad + n * MORPH.row, y0 = b.top + b.height / 2 - DUO.control / 2;
|
|
468
|
+
const top = y0 + h > ctx.height - MORPH.pad ? Math.max(MORPH.pad, y0 + DUO.control - h) : y0;
|
|
469
|
+
return { top, left: left ? pl.left : pl.left + DUO.control - w, width: w, height: h, "--x0": `${pl.left}px`, "--y0": `${y0}px` };
|
|
470
|
+
};
|
|
471
|
+
const shownLeading = leading.slice(0, leading.length - pl.menu.leading);
|
|
472
|
+
const shownActions = actions.slice(0, actions.length - pl.menu.actions);
|
|
473
|
+
const more = [
|
|
474
|
+
...(pl.menu.primary && p.primary ? [p.primary] : []),
|
|
475
|
+
...(pl.menu.back && p.back ? [{ id: "duet-back", label: p.back.label ?? "Back", icon: _jsx(ChevronLeft, { size: 20 }), onPress: p.back.onPress }] : []),
|
|
476
|
+
...leading.slice(shownLeading.length), ...actions.slice(shownActions.length),
|
|
477
|
+
...(ctx.mode === "inline" ? [{ id: "duet-side", label: `Move controls ${left ? "right" : "left"}`, icon: _jsx(ArrowLeftRight, { size: 20 }), onPress: () => {
|
|
478
|
+
// The menu goes at once, without rolling back into "…": its button is about to move, and would stay hidden
|
|
479
|
+
// as "unrolled" until a roll that no longer lines up with it.
|
|
480
|
+
setMenuNow(null);
|
|
481
|
+
setClosing(null);
|
|
482
|
+
setScrub(null);
|
|
483
|
+
if (nav.current)
|
|
484
|
+
switchSide(nav.current, left ? "right" : "left");
|
|
485
|
+
} }] : []),
|
|
486
|
+
...destructive,
|
|
487
|
+
];
|
|
488
|
+
return (_jsxs("nav", { ref: i ? nav2 : nav, className: "duet", "data-mode": ctx.mode, "data-side": side, style: split && i ? { left: column } : undefined, children: [p.close && pl.back && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round duet-close", "data-duet-slot": "close", style: at(pl.back), children: _jsx("button", { className: "duet-item", "aria-label": p.close.label ?? "Close", onClick: p.close.onPress, children: _jsx(X, { size: 22, strokeWidth: 2.2 }) }) })), !p.close && p.back && pl.back && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "back", style: at(pl.back), children: backButton }), !p.close && p.primary && pl.primary && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round duet-primary", "data-duet-slot": "primary", style: { ...at(pl.primary), ...tint(p.primary) }, children: item(p.primary) })), pl.leading && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-group", "data-duet-slot": "leading", style: at(pl.leading), children: shownLeading.map(item) }), pl.actions && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-group", "data-duet-slot": "actions", style: at(pl.actions), children: shownActions.map(item) }), !p.close && pl.more && more.length > 0 && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "more", style: at(pl.more), "data-unrolled": menu === "more" || closing === "more" || undefined, children: moreButton }), p.tabs && pl.tabs && (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-pill", "data-duet-slot": "tabs", role: "tablist", style: { ...swell(at(pl.tabs), scrub !== null && !pl.tabs.min, side), "--li": lensAt }, "data-min": pl.tabs.min || undefined, "data-scrub": scrub !== null || undefined, "data-glide": (glide && scrub === null) || undefined, "data-unrolled": menu === "tabs" || closing === "tabs" || undefined, ...(pl.tabs.min ? {} : scrubbing), children: [!pl.tabs.min && (
|
|
489
|
+
// The lens magnifies a copy of the tab column, slid so it lines up with the real one; the real tabs
|
|
490
|
+
// are masked out under the lens (CSS), so only the enlarged copy shows through it.
|
|
491
|
+
_jsx("span", { className: "duet-lens", "aria-hidden": true, children: _jsx("span", { className: "duet-lens-view", children: _jsx("span", { className: "duet-lens-col", children: tabItems.map((t, i) => _jsx("span", { className: "duet-tab", "data-on": i === lensAt || undefined, style: tint(t), children: t.icon }, t.id)) }) }) })), !pl.tabs.min && _jsx("span", { className: "duet-pill-badges", "aria-hidden": true, children: tabItems.map((t) => _jsx("span", { children: badge(t) }, t.id)) }), pl.tabs.min ? tabItems.filter((t) => t.id === selected).map((t) => tabButton(t, true, 0)) : tabItems.map((t, i) => tabButton(t, false, i))] })), pl.searchButton && _jsx(GlassSurface, { preset: "control", className: "duet-glass duet-round", "data-duet-slot": "search", style: at(pl.searchButton), children: searchButton }), (!split || side === menuSide) && menuEl("more", anchor.more, more, false, menuAt, morphAt), p.tabs && (!split || side === menuSide) && menuEl("tabs", anchor.tabs, tabItems, true, menuAt, morphAt), p.accessory && pl.accessory && !i && (_jsx(GlassSurface, { preset: "control", className: "duet-glass duet-accessory", "data-duet-slot": "accessory", style: pl.accessory, "data-covered": menu === "tabs" || undefined, children: p.accessory }))] }, side));
|
|
492
|
+
};
|
|
493
|
+
return (_jsxs(GlassProvider, { defaultTheme: p.dark === undefined ? undefined : p.dark ? "dark" : "light", children: [p.children !== undefined && _jsx("div", { ref: scroller, className: "duet-scroller", children: p.children }), sides.map(bar)] }));
|
|
494
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type DuetItem } from "./chrome.tsx";
|
|
3
|
+
export interface DuetPanesProps {
|
|
4
|
+
/** The path of screens, shallowest first, e.g. ["mailboxes", "inbox", "message"]. */
|
|
5
|
+
path: string[];
|
|
6
|
+
/** The focused pane. Narrow shows it alone; wide shows it with the pane before it. */
|
|
7
|
+
at: string;
|
|
8
|
+
/** The focused pane takes the whole window (the pane bar's full-screen toggle). */
|
|
9
|
+
expanded?: boolean;
|
|
10
|
+
/** Each pane's content by id. Every pane stays mounted, so its state and scroll survive folding and
|
|
11
|
+
* navigation; panes off screen are inert. */
|
|
12
|
+
panes: Record<string, ReactNode>;
|
|
13
|
+
}
|
|
14
|
+
/** A path of screens that follows the fold: one pane closed or in split view, two adjacent panes unfolded.
|
|
15
|
+
* Going deeper slides along the path; unfolding slides the neighbouring pane in, folding plays it backwards.
|
|
16
|
+
* Each pane gets its own --duet-inset-left/right: only a pane on the controls' edge keeps clear of them, so
|
|
17
|
+
* column() and bleed() inside a pane work as they do on a whole page. */
|
|
18
|
+
export declare function DuetPanes({ path, at, expanded, panes }: DuetPanesProps): import("react").JSX.Element;
|
|
19
|
+
/** Navigation state for <DuetPanes>: `go` focuses a pane, `back` is ready for <Duet back>: it leaves full
|
|
20
|
+
* screen first, then steps back along the path, and is false when that would change nothing on screen.
|
|
21
|
+
* `expanded`/`toggle` drive the full-screen toggle and `close` closes the focused pane (back to the pair
|
|
22
|
+
* before it), both ready for <DuetPaneBar>. */
|
|
23
|
+
export declare function usePanes(path: string[], initial?: string): {
|
|
24
|
+
readonly at: string;
|
|
25
|
+
readonly go: (id: string) => void;
|
|
26
|
+
readonly layout: import("./geometry.ts").Layout;
|
|
27
|
+
readonly expanded: boolean;
|
|
28
|
+
readonly toggle: () => void;
|
|
29
|
+
readonly close: (() => void) | undefined;
|
|
30
|
+
readonly back: false | {
|
|
31
|
+
onPress: () => void;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
export interface DuetPaneBarProps {
|
|
35
|
+
/** Close the pane, e.g. usePanes().close. Omitted: no close button. */
|
|
36
|
+
onClose?: () => void;
|
|
37
|
+
/** Full-screen toggle, e.g. usePanes().toggle. Shown only where two panes fit. */
|
|
38
|
+
onToggle?: () => void;
|
|
39
|
+
expanded?: boolean;
|
|
40
|
+
/** Items for the "…" menu. Destructive ones go last, in red, after a separator. */
|
|
41
|
+
more?: DuetItem[];
|
|
42
|
+
}
|
|
43
|
+
/** The pane's own controls, a small pill at its top right: close, full screen, and "…". It sticks to the top of
|
|
44
|
+
* the pane as it scrolls, and keeps clear of the strip when the pane is on the controls' edge. */
|
|
45
|
+
export declare function DuetPaneBar({ onClose, onToggle, expanded, more }: DuetPaneBarProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useDuet } from "./chrome.js";
|
|
5
|
+
import { Ellipsis, Maximize, Minimize, X } from "./icons.js";
|
|
6
|
+
import { backFrom, panes as paneWindow } from "./panes.js";
|
|
7
|
+
/** A path of screens that follows the fold: one pane closed or in split view, two adjacent panes unfolded.
|
|
8
|
+
* Going deeper slides along the path; unfolding slides the neighbouring pane in, folding plays it backwards.
|
|
9
|
+
* Each pane gets its own --duet-inset-left/right: only a pane on the controls' edge keeps clear of them, so
|
|
10
|
+
* column() and bleed() inside a pane work as they do on a whole page. */
|
|
11
|
+
export function DuetPanes({ path, at, expanded = false, panes }) {
|
|
12
|
+
const { layout } = useDuet();
|
|
13
|
+
const w = paneWindow(path, at, layout, expanded);
|
|
14
|
+
const last = w.start + w.count - 1;
|
|
15
|
+
return (_jsx("div", { className: "duet-panes", "data-layout": layout, children: _jsx("div", { className: "duet-panes-track", style: { "--start": w.start, "--n": w.count }, children: path.map((id, i) => {
|
|
16
|
+
const shown = w.visible.includes(id);
|
|
17
|
+
const insets = {
|
|
18
|
+
"--duet-inset-left": i <= w.start ? "var(--duet-edge-left, 0px)" : "0px",
|
|
19
|
+
"--duet-inset-right": i >= last ? "var(--duet-edge-right, 0px)" : "0px",
|
|
20
|
+
};
|
|
21
|
+
return _jsx("section", { className: "duet-pane", style: insets, "data-pane": id, "data-at": id === at || undefined, inert: !shown, "aria-hidden": !shown, children: panes[id] }, id);
|
|
22
|
+
}) }) }));
|
|
23
|
+
}
|
|
24
|
+
/** Navigation state for <DuetPanes>: `go` focuses a pane, `back` is ready for <Duet back>: it leaves full
|
|
25
|
+
* screen first, then steps back along the path, and is false when that would change nothing on screen.
|
|
26
|
+
* `expanded`/`toggle` drive the full-screen toggle and `close` closes the focused pane (back to the pair
|
|
27
|
+
* before it), both ready for <DuetPaneBar>. */
|
|
28
|
+
export function usePanes(path, initial = path[0]) {
|
|
29
|
+
const [at, setAt] = useState(initial);
|
|
30
|
+
const [expanded, setExpanded] = useState(false);
|
|
31
|
+
const { layout } = useDuet();
|
|
32
|
+
const go = (id) => { setAt(id); setExpanded(false); };
|
|
33
|
+
const to = backFrom(path, at, layout);
|
|
34
|
+
const prev = path[path.indexOf(at) - 1];
|
|
35
|
+
return {
|
|
36
|
+
at, go, layout, expanded,
|
|
37
|
+
toggle: () => setExpanded((e) => !e),
|
|
38
|
+
close: prev === undefined ? undefined : () => go(prev),
|
|
39
|
+
back: expanded && layout === "wide" ? { onPress: () => setExpanded(false) } : to !== null && { onPress: () => go(to) },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The pane's own controls, a small pill at its top right: close, full screen, and "…". It sticks to the top of
|
|
43
|
+
* the pane as it scrolls, and keeps clear of the strip when the pane is on the controls' edge. */
|
|
44
|
+
export function DuetPaneBar({ onClose, onToggle, expanded = false, more = [] }) {
|
|
45
|
+
const { layout } = useDuet();
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
const ref = useRef(null);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!open)
|
|
50
|
+
return;
|
|
51
|
+
const off = (e) => { if (!ref.current?.contains(e.target))
|
|
52
|
+
setOpen(false); };
|
|
53
|
+
addEventListener("pointerdown", off, true);
|
|
54
|
+
return () => removeEventListener("pointerdown", off, true);
|
|
55
|
+
}, [open]);
|
|
56
|
+
const items = [...more.filter((m) => !m.destructive), ...more.filter((m) => m.destructive)];
|
|
57
|
+
const pick = (m) => () => { setOpen(false); m.onPress?.(); };
|
|
58
|
+
const full = !!onToggle && layout === "wide";
|
|
59
|
+
if (!onClose && !full && !items.length)
|
|
60
|
+
return null;
|
|
61
|
+
return (_jsxs("div", { className: "duet-pane-bar", ref: ref, children: [_jsxs("div", { className: "duet-pane-pill", children: [onClose && _jsx("button", { "aria-label": "Close", onClick: onClose, children: _jsx(X, { size: 18 }) }), full && _jsx("button", { "aria-label": expanded ? "Exit full screen" : "Full screen", "aria-pressed": expanded, onClick: onToggle, children: expanded ? _jsx(Minimize, { size: 17 }) : _jsx(Maximize, { size: 17 }) }), items.length > 0 && _jsx("button", { "aria-label": "More", "aria-expanded": open, onClick: () => setOpen(!open), children: _jsx(Ellipsis, { size: 20 }) })] }), open && (_jsx("div", { className: "duet-pane-menu", role: "menu", children: items.map((m, i) => (_jsxs("button", { role: "menuitem", onClick: pick(m), "data-destructive": m.destructive || undefined, "data-sep": (m.destructive && !items[i - 1]?.destructive && i > 0) || undefined, children: [_jsx("span", { children: m.label }), m.icon] }, m.id))) }))] }));
|
|
62
|
+
}
|