staffa 0.13.0 → 0.14.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.
@@ -1,6 +1,6 @@
1
1
  import A from "aberdeen";
2
2
  import { current as currentRoute } from "aberdeen/route";
3
- import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
3
+ import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX, MIN_PX } from "../core.js";
4
4
  import { type MenuOptions, drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent } from "./menu.js";
5
5
  // The shell's own chrome glyphs, from the same Lucide set an app draws with —
6
6
  // so a nav trigger sits beside app icons as an equal. Named imports, so a
@@ -8,7 +8,7 @@ import { type MenuOptions, drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCu
8
8
  import { menu as menuIcon, x as closeIcon } from "../icons.js";
9
9
  import { iconButton } from "./button.js";
10
10
  import { isDialogOpen } from "./dialog.js";
11
- import { PanelStackController, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
11
+ import { PanelStackController, SMALL_MAX_PX, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
12
12
 
13
13
  /** Options for {@link main}. */
14
14
  export interface MainOptions<R = Routes> {
@@ -94,16 +94,15 @@ export interface MainOptions<R = Routes> {
94
94
  * Navigating is just links: the shell handles the clicks itself, so do *not*
95
95
  * also call Aberdeen's `interceptLinks()`. A link opens its target on top of
96
96
  * the panel it sits in, closing everything after that panel first. The
97
- * `data-panel` attribute picks another of the three {@link PanelStack}
98
- * navigations instead: `replace` puts the target in place of the link's own
99
- * panel, and `open` leaves that panel behind and gives the target its own
100
- * stack, the way a nav item does. A link
101
- * to something already open goes back to it rather than opening it twice —
102
- * a move along the stack that closes nothing: the panels right of it stay
103
- * open, parked past the viewport's right edge, until a *new* panel prunes
104
- * them (pinned panels excepted — see {@link Panel.pinned}).
105
- * From code, use {@link pushPanel} and friends: navigating
106
- * with `aberdeen/route`'s own `go()` works too — a panel with
97
+ * `data-panel` attribute keeps less of that context instead: `replace`
98
+ * drops the link's own panel too, putting the target in its place, and
99
+ * `open` drops it all, giving the target its own stack, the way a nav item
100
+ * does. A plain link to something already open goes back to it rather than
101
+ * opening it twice, closing whatever was stacked on top — pinned panels
102
+ * excepted (see {@link Panel.pinned}), and panels holding unsaved work,
103
+ * which park instead; a `replace` or `open` applies its usual shape, the
104
+ * open panel moving into it alive. From code, use {@link pushPanel} and
105
+ * friends: navigating with `aberdeen/route`'s own `go()` works too — a panel with
107
106
  * {@link Panel.unsaved} work still survives it — but builds the whole stack
108
107
  * from the path. A navigation guard the app registered with
109
108
  * `route.setGuard` (an auth redirect, say) keeps working: the shell
@@ -113,11 +112,11 @@ export interface MainOptions<R = Routes> {
113
112
  * is called ({@link Panel.title} — unset, its first line of text stands in)
114
113
  * and what it can do ({@link Panel.actions}); everything else in a column is
115
114
  * the panel's own content, boxes included. The shell writes the stack of
116
- * open panels as breadcrumbs in the top bar — click one to go back to it,
117
- * closing nothing — and places each panel's actions where the room is: on
118
- * its own column while several fit, in the bar once the shell is narrow and
119
- * the current panel *is* the screen. Nothing in an app measures the
120
- * viewport to lay its screens out twice.
115
+ * open panels as breadcrumbs in the top bar — click one to go back to it —
116
+ * and places each panel's actions where the room is: on its own column while
117
+ * several fit, in the bar once the shell is narrow and the current panel *is*
118
+ * the screen. Nothing in an app measures the viewport to lay its screens out
119
+ * twice.
121
120
  *
122
121
  * Only one routed shell can be mounted at a time (a second one throws) —
123
122
  * the URL is global, so two of them would fight over it. Nothing else is:
@@ -192,9 +191,10 @@ export interface MainOptions<R = Routes> {
192
191
  * How many panels are *shown* at a time. `"auto"` (the default) shows as
193
192
  * many columns, side by side, as comfortably fit, ending at the current
194
193
  * panel; `"single"` shows only the current panel, however wide the screen
195
- * — the phone experience at every size (the nav sidebar still sits beside
196
- * it). Only the display differs: the stack, the breadcrumbs, the URL,
197
- * Escape and the back button behave identically in both. Routed mode only.
194
+ * — one screen at a time at every size, each still at its asked width,
195
+ * centred (the nav sidebar still sits beside it). Only the display
196
+ * differs: the stack, the breadcrumbs, the URL, Escape and the back
197
+ * button behave identically in both. Routed mode only.
198
198
  *
199
199
  * Live: pass a proxied options object (or make this field a getter) and a
200
200
  * change is adopted in place — one layout pass, every panel keeping its
@@ -203,12 +203,13 @@ export interface MainOptions<R = Routes> {
203
203
  columns?: "auto" | "single";
204
204
  /**
205
205
  * What a link *without* a `data-panel` attribute does — the per-link
206
- * attribute always wins. `"push"` (the default) opens the target on top of
207
- * the panel the link sits in; `"replace"` opens it in that panel's place;
208
- * `"open"` gives it its own stack, the way a nav item does. With `"open"`
209
- * every click replaces the content as a whole — which, with flat routes,
210
- * is the conventional sidebar-and-content app: one pane, swapped on every
211
- * click, the crumb line simply naming it. Routed mode only.
206
+ * attribute always wins. The three keep less and less of the link's own
207
+ * context: `"push"` (the default) builds on the panel the link sits in,
208
+ * `"replace"` swaps that panel out, and `"open"` ignores it and gives the
209
+ * target its own stack, the way a nav item does. So with `"open"` every
210
+ * click replaces the content as a whole — which, with flat routes, is the
211
+ * conventional sidebar-and-content app: one pane, swapped on every click,
212
+ * the crumb line simply naming it. Routed mode only.
212
213
  *
213
214
  * Live, like {@link MainOptions.columns}: change it and the next click
214
215
  * uses the new default.
@@ -217,32 +218,23 @@ export interface MainOptions<R = Routes> {
217
218
  /** Footer content, pinned below the scroll area. */
218
219
  footer?: Slot;
219
220
  /**
220
- * Max width for the panel's *content*, e.g. `"60rem"`. The header and footer
221
+ * Max width for the shell's *content*, e.g. `"80rem"`. The header and footer
221
222
  * backgrounds still span the full shell width, but their contents — and the
222
223
  * sidebar + separator + content trio (or just the content when there's no
223
224
  * sidebar) — cap to this width and centre horizontally. When unset, everything
224
225
  * fills the available width. Either way the content shares the panel surface —
225
226
  * it is not boxed.
226
227
  *
227
- * Ignored when you pass {@link MainOptions.routes}: there the open panels
228
- * decide the width (see {@link Panel.maxWidth}), and the header and footer line
229
- * themselves up with them.
230
- */
231
- maxWidth?: string;
232
- /**
233
- * How wide a `"full"` panel gets, in pixels — and with it the whole content
234
- * area, since a `"full"` fills it exactly. A `"half"` gets half of this, and
235
- * a `"screen"` ignores it and takes the window. Defaults to 1080; the window
236
- * caps it when there is less room than that. Routed mode only.
237
- *
238
- * This plus {@link MainOptions.navWidth} is the app's standard page — see
239
- * there.
228
+ * In routed mode this is what the columns divide up (see
229
+ * {@link Panel.maxWidth}), which is the reason to set it on a very wide
230
+ * screen: left uncapped, a stack of small panels will happily march right
231
+ * across a 4K display.
240
232
  *
241
233
  * Live, like {@link MainOptions.columns}: pass a proxied options object (or
242
- * make this field a getter) and a change is adopted in one layout pass,
243
- * every panel keeping its state.
234
+ * make this field a getter) and a change is adopted in one layout pass, every
235
+ * open panel keeping its state.
244
236
  */
245
- fullWidth?: number;
237
+ maxWidth?: string;
246
238
  /** Aberdeen attr/style string applied to the content area. */
247
239
  contentAttrs?: Attributes;
248
240
  /** Aberdeen attr/style string applied to the top bar. */
@@ -271,13 +263,9 @@ export interface MainOptions<R = Routes> {
271
263
  navPosition?: "left" | "right";
272
264
  /**
273
265
  * How wide the nav sidebar column is, in pixels — its hairline included.
274
- * Defaults to 200.
275
- *
276
- * Together with {@link MainOptions.fullWidth} this is the app's *standard
277
- * page*: the width the top bar and footer keep to, and the width the
278
- * columns settle back to. The defaults come to the familiar 1280px.
266
+ * Defaults to 200. Whatever it takes comes off the content area beside it.
279
267
  *
280
- * Live, like {@link MainOptions.fullWidth}.
268
+ * Live, like {@link MainOptions.maxWidth}.
281
269
  */
282
270
  navWidth?: number;
283
271
  /** Aberdeen attr/style string applied to the sidebar nav panel. */
@@ -286,20 +274,15 @@ export interface MainOptions<R = Routes> {
286
274
  navPageAttrs?: Attributes;
287
275
  }
288
276
 
289
- /**
290
- * The default nav column (hairline included) and the default width of a
291
- * `"full"` panel — see {@link MainOptions.navWidth} and
292
- * {@link MainOptions.fullWidth}. Side by side they come to the 1280px page the
293
- * shell is usually seen as, but that figure lives nowhere: the browser adds
294
- * these two up, and an app that changes either simply gets a different page.
295
- */
277
+ /** The default nav column, hairline included — see {@link MainOptions.navWidth}. */
296
278
  const NAV_W = 200;
297
- const FULL_W = 1080;
298
279
 
299
280
  A.insertGlobalCss({
300
281
  ".s-main": {
301
282
  // container-type so @container queries below can respond to shell width.
302
- "&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
283
+ // The vh divided by --s-zoom: viewport units shrink with the page-fitting
284
+ // zoom (see `watchScale`), and this height means the window.
285
+ "&": "display:flex flex-direction:column min-height:calc(100vh/var(--s-zoom,1)) max-height:calc(100vh/var(--s-zoom,1)) container-type:inline-size",
303
286
  // <body> carries a default $3 padding; when the shell is a direct child of it,
304
287
  // cancel that padding with matching negative margins so the chrome still spans
305
288
  // edge to edge (and the 100vh sizing stays exact).
@@ -375,28 +358,6 @@ A.insertGlobalCss({
375
358
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
376
359
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
377
360
  ".s-body main.s-scroll-y": "margin-right:$3",
378
- // Routed mode takes its width from the stack instead of from
379
- // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
380
- // separator + content area) as --s-shell-w — the standard page
381
- // normally, wider while the columns outgrow it (a "screen" page, or
382
- // extra columns fitting a wide window) — and the body row caps itself
383
- // to it, staying centred around the columns. Changing the custom
384
- // property animates the max-width consuming it, with no JS in the loop:
385
- // the body recentres in step with the panel whose arrival or departure
386
- // moved it, over the same --s-panel-ms (see panels.ts). During a window
387
- // resize (and the very first pass) the layout engine raises
388
- // `.s-shell-snap` so the new width is adopted instantly instead of
389
- // chasing the window through a transition.
390
- "&.s-routed > .s-body > .s-body-inner":
391
- "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
392
- "&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
393
- // The bars don't follow the ensemble past the standard page: a header
394
- // stretching to the window's edges and back with every "screen" panel
395
- // reads as the whole app flexing, so the chrome holds still and only
396
- // the columns grow. (Below the standard width the ensemble is simply
397
- // the window, which only a resize changes — so the bars never animate,
398
- // and take no part in the transition above.)
399
- "&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "max-width: calc(var(--s-nav-w) + var(--s-full-w))",
400
361
  },
401
362
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
402
363
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -443,13 +404,20 @@ A.insertGlobalCss({
443
404
  // A phone's bar holds two lines of chrome in a screen's width, so it buys
444
405
  // the stack and the screen's actions room by spending less on air.
445
406
  ".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
446
- // On phones a top-level content box becomes a full-bleed block: pull it out
447
- // to negate the content padding and drop the rounded corners.
448
- ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
449
- // At narrow widths, content boxes are full-bleed so there's no inset to
450
- // align the scrollbar with — cancel the right margin.
407
+ // The narrow bar tucks its content in to $2 (above), so the $3 scrollbar
408
+ // inset no longer has a chrome edge to align with — cancel it.
451
409
  ".s-main .s-body main.s-scroll-y": "margin-right:0",
452
410
  },
411
+ // On phones a top-level content box becomes a full-bleed block: pull it out
412
+ // to negate the content padding and drop the rounded corners. Keyed on
413
+ // SMALL_MAX_PX, not the narrow threshold above: at or below it a column can
414
+ // never be narrower than the window (every size caps at the content area,
415
+ // and a lone column's cap is exactly this — see panels.ts), while just above
416
+ // it a small column floats centred, where a bleeding box would shed its card
417
+ // chrome over open ground.
418
+ [`@container (max-width: ${SMALL_MAX_PX}px)`]: {
419
+ ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
420
+ },
453
421
  });
454
422
 
455
423
  /**
@@ -464,7 +432,8 @@ A.insertGlobalCss({
464
432
  * Instead of a single `content` slot, pass {@link MainOptions.routes} and the
465
433
  * shell takes over navigation: each route draws one screen, called a panel,
466
434
  * and as many columns as fit are shown at a time, side by side on a wide screen
467
- * and one at a time on a phone. Each panel *declares* its chrome — its
435
+ * and one at a time on a phone; {@link MainOptions.maxWidth} caps how much room
436
+ * they have between them. Each panel *declares* its chrome — its
468
437
  * {@link Panel.title} and its {@link Panel.actions} — and this shell places it:
469
438
  * the stack of titles as breadcrumbs in the bar, the actions on the panel's
470
439
  * column while several fit and in the bar once the shell is narrow enough
@@ -539,9 +508,6 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
539
508
  notFound: opts.notFound,
540
509
  ancestors: opts.ancestors,
541
510
  title: opts.title,
542
- // Corrected below, and on every change, from the app's own option:
543
- // read here it would subscribe the whole shell to it.
544
- fullWidth: FULL_W,
545
511
  $shell,
546
512
  })
547
513
  : null;
@@ -553,22 +519,18 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
553
519
  // the new link default. Nothing else of the shell is touched.
554
520
  A(() => ctl.setColumns(opts.columns));
555
521
  A(() => ctl.setLinkNavigation(opts.linkNavigation));
556
- A(() => ctl.setFullWidth(opts.fullWidth ?? FULL_W));
557
522
  }
558
523
  // Where the brand mark and the app's name link — or nowhere, when the app
559
524
  // said `home: null` (a title slot holding a control of its own, say).
560
525
  const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
561
- // Routed mode caps the shell to the ensemble width the layout engine publishes,
562
- // rather than to `maxWidth`.
563
- const capWidth = ctl ? null : opts.maxWidth;
564
-
565
- const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
566
- // The two widths the CSS above works from, and with them the standard page
567
- // the bars keep to. Each sits in a scope of its own — one that draws
568
- // nothing, so re-running it is a single style write: an app that changes
569
- // either on a proxied options object resizes the shell in place, panels
570
- // and their state untouched.
571
- A(() => A(`--s-full-w: ${opts.fullWidth ?? FULL_W}px`));
526
+ // The shell's one width cap, applied to the body row and to both bars, so the
527
+ // chrome and the content always line up. Each of the three reads it in a
528
+ // scope of its own — one that draws nothing, so re-running it is a single
529
+ // style write: an app that changes it on a proxied options object resizes the
530
+ // shell in place, panels and their state untouched.
531
+ const capWidth = () => { if (opts.maxWidth != null) A("max-width:", opts.maxWidth); };
532
+
533
+ const root = A("div.s-main", opts.attrs, () => {
572
534
  // `--s-nav-w` is the sidebar's whole column, and nothing at all when there
573
535
  // is no sidebar to give it to — a shell without one lines its bars up with
574
536
  // the content. This scope also tags the shell with the side the sidebar is
@@ -598,9 +560,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
598
560
  A("header.s-s.neutral", opts.topbarAttrs, () => {
599
561
  A("div.s-bar", () => {
600
562
  // Cap the bar's content to maxWidth and centre it within the full-width header.
601
- A(() => {
602
- if (capWidth != null) A("max-width:", capWidth);
603
- });
563
+ A(capWidth);
604
564
 
605
565
  // Leading: the ☰ once the nav has collapsed, the logo otherwise.
606
566
  // Deliberately no back button, at any width: going back is the
@@ -661,9 +621,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
661
621
  // are identical with and without a sidebar nav.
662
622
  A("div.s-body", () => {
663
623
  A("div.s-body-inner", () => {
664
- A(() => {
665
- if (capWidth != null) A("max-width:", capWidth);
666
- });
624
+ A(capWidth);
667
625
  // The sidebar, in its own scope so a changing item list redraws just
668
626
  // it — never the content area beside it (see `nav` above).
669
627
  A(() => {
@@ -686,9 +644,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
686
644
  if (opts.footer != null) {
687
645
  A("footer", () => {
688
646
  A("div.s-bar", () => {
689
- A(() => {
690
- if (capWidth != null) A("max-width:", capWidth);
691
- });
647
+ A(capWidth);
692
648
  drawSlot(opts.footer);
693
649
  });
694
650
  });
@@ -697,6 +653,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
697
653
  }) as HTMLElement;
698
654
 
699
655
  watchNarrow(root, $shell);
656
+ watchScale();
700
657
 
701
658
  // Escape peels back a panel of UI, and finally jumps to the navigation: into
702
659
  // the sidebar's current item when the sidebar is showing, or — when it has
@@ -831,6 +788,48 @@ function taglineFits(ctl: PanelStackController, nav: MenuOptions | undefined, $s
831
788
  return anyCurrent(nav.items);
832
789
  }
833
790
 
791
+ /** How many mounted shells are watching the window; the listener is one per page. */
792
+ let scaleShells = 0;
793
+
794
+ /** Lay the page out at a virtual {@link MIN_PX} and zoom it down to the window. */
795
+ function applyScale(): void {
796
+ // The real window width: <html> is never zoomed, so this stays unscaled.
797
+ const w = document.documentElement.clientWidth;
798
+ const f = w && w < MIN_PX ? w / MIN_PX : 0;
799
+ document.body.style.zoom = f ? String(f) : "";
800
+ // Published for the vh/vw lengths in the library's CSS, which zoom shrinks
801
+ // and a `/var(--s-zoom,1)` restores to meaning the window.
802
+ if (f) document.body.style.setProperty("--s-zoom", String(f));
803
+ else document.body.style.removeProperty("--s-zoom");
804
+ }
805
+
806
+ /**
807
+ * Below {@link MIN_PX} of window the shell stops squeezing and starts scaling:
808
+ * the page keeps its {@link MIN_PX} layout and CSS `zoom` shrinks it to fit,
809
+ * so a 180px window shows the 360px layout at half size. The zoom goes on
810
+ * `<body>`, so the overlays that portal there — dialogs, menus, toasts,
811
+ * tooltips — scale with the shell. `zoom` rather than `transform:scale`,
812
+ * because zoom keeps layout, container queries and the top layer in one
813
+ * system; what it splits instead is coordinate spaces — window-space rects
814
+ * against element-space lengths — which the few places mixing those bridge
815
+ * with {@link cssZoom}, and vh/vw lengths with `--s-zoom` (see `applyScale`).
816
+ * Browsers that predate `currentCSSZoom` (mid-2024) keep the squeeze.
817
+ */
818
+ function watchScale(): void {
819
+ if (typeof window === "undefined" || !("currentCSSZoom" in document.documentElement)) return;
820
+ if (++scaleShells === 1) {
821
+ window.addEventListener("resize", applyScale);
822
+ applyScale();
823
+ }
824
+ A.clean(() => {
825
+ if (--scaleShells === 0) {
826
+ window.removeEventListener("resize", applyScale);
827
+ document.body.style.zoom = "";
828
+ document.body.style.removeProperty("--s-zoom");
829
+ }
830
+ });
831
+ }
832
+
834
833
  /**
835
834
  * Track whether the shell is narrow, for everything that has to agree about it.
836
835
  *
@@ -1,8 +1,9 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
3
- import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
4
- import { menu as menuIcon, chevronRight } from "../icons.js";
3
+ import { cssZoom, type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
4
+ import { menu as menuIcon, chevronRight, externalLink as newTabIcon, link as linkIcon } from "../icons.js";
5
5
  import { button, type ButtonOptions } from "./button.js";
6
+ import { toast } from "./toast.js";
6
7
 
7
8
  /**
8
9
  * A clickable item in a menu or sidebar nav.
@@ -130,6 +131,14 @@ export interface FloatingMenuOptions {
130
131
  * context menu — whose anchor has no click handler — wants the click to close.
131
132
  */
132
133
  closeOnAnchorClick?: boolean;
134
+ /**
135
+ * The link this menu stands on, as a path or URL. A menu that takes over a
136
+ * link's right-click takes the browser's own link menu away, so it owes the
137
+ * two entries anyone actually reaches for there: with this set, **Open in
138
+ * new tab** and **Copy link** are prepended above a separator, where that
139
+ * menu would have had them. The shell's breadcrumbs use it.
140
+ */
141
+ link?: string;
133
142
  /** Aberdeen attr/style string on the floating panel. */
134
143
  dropdownAttrs?: Attributes;
135
144
  }
@@ -152,7 +161,7 @@ A.insertGlobalCss({
152
161
  ".s-menu-list":
153
162
  "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
154
163
  "r:$s-radius-lg " +
155
- "overflow-y:auto max-height:min(80vh,28rem) " +
164
+ "overflow-y:auto max-height:min(calc(80vh/var(--s-zoom,1)),28rem) " +
156
165
  "transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
157
166
  ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
158
167
  // One class for both the `<a>` (link) and `<button>` forms — they look
@@ -529,23 +538,58 @@ export function closeFloatingMenu(anchor?: HTMLElement): void {
529
538
  }
530
539
 
531
540
  function positionMenu(menuEl: HTMLElement, rect: { left: number; right: number; top: number; bottom: number }): void {
541
+ // The rect arrives in window coordinates (an anchor's rect, or a pointer
542
+ // position); the left/top set below live in the menu's own space. The two
543
+ // differ when the shell has zoomed the page (see `watchScale` in main.ts),
544
+ // so everything is brought into the menu's space first.
545
+ const z = cssZoom(menuEl);
532
546
  const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
533
- const vw = window.innerWidth, vh = window.innerHeight;
547
+ const vw = window.innerWidth / z, vh = window.innerHeight / z;
534
548
  const gap = 4;
535
- let x = rect.left;
536
- if (x + mw > vw - 8) x = Math.max(8, rect.right - mw);
537
- let y = rect.bottom + gap;
538
- if (y + mh > vh - 8 && rect.top - mh - gap >= 8) y = rect.top - mh - gap;
549
+ let x = rect.left / z;
550
+ if (x + mw > vw - 8) x = Math.max(8, rect.right / z - mw);
551
+ let y = rect.bottom / z + gap;
552
+ if (y + mh > vh - 8 && rect.top / z - mh - gap >= 8) y = rect.top / z - mh - gap;
539
553
  menuEl.style.left = Math.max(8, x) + "px";
540
554
  menuEl.style.top = Math.max(8, y) + "px";
541
555
  }
542
556
 
557
+ /**
558
+ * The standard entries for the link a menu stands on (see
559
+ * {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so
560
+ * the target arrives cold, exactly as the link middle-clicked would.
561
+ */
562
+ function linkItems(href: string): MenuEntry[] {
563
+ return [
564
+ { label: "Open in new tab", icon: newTabIcon, click: () => { window.open(href, "_blank", "noopener"); } },
565
+ { label: "Copy link", icon: linkIcon, click: () => void copyLink(href) },
566
+ ];
567
+ }
568
+
569
+ /**
570
+ * Put the link's address on the clipboard, as the absolute URL someone can
571
+ * paste anywhere — what the browser's own "Copy link" would have given them.
572
+ * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
573
+ * needs a secure context, so a failure says so rather than lying.
574
+ */
575
+ async function copyLink(href: string): Promise<void> {
576
+ const url = new URL(href, location.href).href;
577
+ try {
578
+ await navigator.clipboard.writeText(url);
579
+ toast({ message: "Link copied." });
580
+ } catch {
581
+ toast({ message: "Couldn't copy the link.", type: "danger" });
582
+ }
583
+ }
584
+
543
585
  mountPortal(() => {
544
586
  const f = $floating.opts;
545
587
  if (!f) return;
546
588
 
547
589
  const menuEl = A("div.s-menu-list.s-s.neutral.shadow create=hidden destroy=hidden", f.dropdownAttrs, () => {
548
- drawMenu(f.items, closeFloating);
590
+ // One drawMenu call, not one per section: it owns the container's roving
591
+ // keyboard focus, and two of them would move it twice per keypress.
592
+ drawMenu(f.link != null ? [...linkItems(f.link), { separator: true }, ...f.items] : f.items, closeFloating);
549
593
  }) as HTMLElement;
550
594
 
551
595
  // Capture-phase document handlers replace an invisible backdrop element:
@@ -672,13 +716,13 @@ export function addContextMenu(opts: ContextMenuOptions): void {
672
716
  e.preventDefault();
673
717
  myEl = e.currentTarget as HTMLElement;
674
718
  // Anchor at the exact click/tap point, and close on a plain click of the
675
- // element (it has no toggle handler of its own).
719
+ // element (it has no toggle handler of its own). The rest of the options
720
+ // pass through whole, so a shared option can't be dropped on the way.
676
721
  showFloatingMenu({
677
- items: opts.items,
722
+ ...opts,
678
723
  anchor: myEl,
679
724
  at: { x: e.clientX, y: e.clientY },
680
725
  closeOnAnchorClick: true,
681
- dropdownAttrs: opts.dropdownAttrs,
682
726
  });
683
727
  });
684
728
  }