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.
- package/README.md +19 -16
- package/dist/components/dialog.js +3 -1
- package/dist/components/main.d.ts +37 -48
- package/dist/components/main.js +72 -63
- package/dist/components/menu.d.ts +8 -0
- package/dist/components/menu.js +48 -13
- package/dist/components/panels.d.ts +103 -82
- package/dist/components/panels.js +180 -227
- package/dist/components/select.js +4 -2
- package/dist/components/tabs.js +9 -5
- package/dist/components/toast.js +3 -1
- package/dist/components/tooltip.js +12 -5
- package/dist/core.d.ts +15 -0
- package/dist/core.js +17 -0
- package/dist/index.d.ts +1 -1
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +13 -65
- package/dist/theme.js +21 -8
- package/package.json +1 -1
- package/skill/FloatingMenuOptions.md +10 -0
- package/skill/MainOptions.md +35 -49
- package/skill/Panel.md +31 -32
- package/skill/PanelStack.md +4 -4
- package/skill/SKILL.md +29 -16
- package/skill/main.md +2 -1
- package/src/components/dialog.ts +3 -1
- package/src/components/main.ts +109 -110
- package/src/components/menu.ts +56 -12
- package/src/components/panels.ts +247 -287
- package/src/components/select.ts +4 -2
- package/src/components/tabs.ts +7 -3
- package/src/components/toast.ts +3 -1
- package/src/components/tooltip.ts +12 -5
- package/src/core.ts +19 -0
- package/src/index.ts +1 -1
- package/src/theme.ts +24 -9
package/src/components/main.ts
CHANGED
|
@@ -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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* the
|
|
120
|
-
*
|
|
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
|
-
* —
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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.
|
|
207
|
-
*
|
|
208
|
-
* `"
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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
|
|
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
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
447
|
-
//
|
|
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
|
|
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
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
*
|
package/src/components/menu.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
}
|