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/README.md
CHANGED
|
@@ -154,11 +154,11 @@ The first key that matches wins, and a segment a param refuses simply doesn't ma
|
|
|
154
154
|
|
|
155
155
|
**Navigating is just links.** Write ordinary `<a href="/...">` links; Staffa handles the clicks (so don't also call Aberdeen's `interceptLinks()`).
|
|
156
156
|
|
|
157
|
-
The open panels form a **stack**, and
|
|
157
|
+
The open panels form a **stack**, and its last panel is the **current** one: the panel the URL names, and the rightmost column on screen. Whatever you navigate to lands there.
|
|
158
158
|
|
|
159
|
-
- A link inside a panel opens its target on top of that panel, closing everything after it first. That's why clicking a second project replaces the open project instead of adding a third column
|
|
160
|
-
- A `data-panel` attribute on the link picks a different one of the three navigations
|
|
161
|
-
- A link to something that's already open goes back to it instead of opening it twice
|
|
159
|
+
- A link inside a panel opens its target on top of that panel, closing everything after it first. That's why clicking a second project replaces the open project instead of adding a third column.
|
|
160
|
+
- A `data-panel` attribute on the link picks a different one of the three navigations, which differ only in how much of the link's own context the target keeps: `push` (the default just described) keeps the link's panel and builds on it; `replace` keeps everything under that panel but not the panel itself — what prev/next buttons want; and `open` keeps none of it, giving the target its own stack exactly as a nav item would — for a search hit or a mention, where the panel you clicked from is coincidence, not context.
|
|
161
|
+
- A plain link to something that's already open goes back to it instead of opening it twice, closing whatever was stacked on top of it; a `replace` or `open` applies its usual shape instead, the open panel moving into place with its state intact. Either way the same path is never in the stack twice.
|
|
162
162
|
- A link that isn't inside a panel (a nav item, or one in a dialog) has no panel to build on, so it replaces the stack as a whole: the panel you asked for, with its ancestor panels opened beneath it (see [below](#ancestors)). Panels that the new stack also contains stay as they are, so clicking the nav item for the section you're already in won't reset it. Clicking a nav item and opening that same URL in a fresh tab therefore give you the same columns.
|
|
163
163
|
|
|
164
164
|
**The stack is an object, not a global.** In routed mode `S.main()` hands back the panel stack, and every panel gets the same object as `$panel.stack` — which is what a route handler uses, since it runs while the `S.main()` call is still going and can't see its return value yet.
|
|
@@ -180,21 +180,24 @@ Navigations settle asynchronously (closes travel through the browser's history),
|
|
|
180
180
|
|
|
181
181
|
Navigating faster than the shell can settle is fine: closing travels through the browser's history, so it takes a moment to land, and anything asked for in the meantime waits for it rather than being dropped. Two quick Escapes (or back gestures) peel two panels, each aimed at the stack the one before it was heading for.
|
|
182
182
|
|
|
183
|
-
**
|
|
183
|
+
**The content area is the window**, minus the nav sidebar — or, when `S.main({ maxWidth })` says so, that much of it, centred. Either way it is the same width whatever is open, so the sidebar, the top bar and the footer never move.
|
|
184
|
+
|
|
185
|
+
The shell divides that area into columns: the narrowest whole number of them that keeps each one at least **360px** wide, and no column ever wider than **540**. A 1080px content area is three columns of 360; a 1520px one is four of 380; below 720px there is a single column — of at most 540, centred. `$panel.maxWidth` counts in those columns:
|
|
184
186
|
|
|
185
187
|
| `maxWidth` | How wide the panel gets | Good for |
|
|
186
188
|
| --- | --- | --- |
|
|
187
|
-
| `"
|
|
188
|
-
| `"
|
|
189
|
-
| `"
|
|
189
|
+
| `"small"` | One column — never above 540px. | lists, detail forms — anything that reads well at phone width |
|
|
190
|
+
| `"medium"` (default) | Two columns — never above 1080px. | ordinary screens; the safe default |
|
|
191
|
+
| `"large"` | Three columns — never above 1620px. | wide tables, dense forms |
|
|
192
|
+
| `"none"` | The whole content area, unbounded. | boards, dashboards |
|
|
190
193
|
|
|
191
|
-
|
|
194
|
+
The ask is a promise in both directions: the shell never draws a panel wider than its column count × 540px, and every size is also capped at the content area — so a draw function has to look right from 360px up to its own ceiling, and nowhere past it. There is nothing below 360 to handle either: a narrower window is shown the 360px layout scaled down to fit (dialogs, menus and toasts scaling along), so 360 really is the floor.
|
|
192
195
|
|
|
193
|
-
|
|
196
|
+
A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. As many columns as fit are shown, ending at the current panel; when they don't fill the content area they sit centred in it, so an arriving column nudges the others over to share the room.
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
`S.main({ navWidth, maxWidth })` sets the sidebar's width and the cap. Leave `maxWidth` off and the app simply fills the window — worth capping (`"1600px"`, say) if you'd rather it didn't march across a 4K display.
|
|
196
199
|
|
|
197
|
-
Columns tile
|
|
200
|
+
Columns tile the area they're given, separated by a hairline and no gutter — a column brings its own padding, so their contents stay comfortably apart regardless.
|
|
198
201
|
|
|
199
202
|
The panel is sized before your handler runs, and `$panel.width` is the resolved figure in pixels — so a chart, a virtualised list or a column count has the real width from the first frame, with nothing to measure. Set `maxWidth` at the top of your handler and you draw at the new width; set it later and the panel reflows without being redrawn, so nothing in it is rebuilt or loses its state.
|
|
200
203
|
|
|
@@ -212,11 +215,11 @@ function drawTask($panel: S.Panel<{ taskId: number }>) {
|
|
|
212
215
|
|
|
213
216
|
On a wide screen the title becomes the stack's last crumb and the Save button sits in a quiet strip at the top of the column. On a phone the crumb is still there and Save moves into the top bar, where the app menu was. Nothing in your code measures the viewport, and no screen is written twice.
|
|
214
217
|
|
|
215
|
-
**The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold.
|
|
218
|
+
**The breadcrumbs are the navigation.** The top bar's second line writes the open panels out as breadcrumbs — `Projects / Trackle / Task 42` — with the panels currently on screen in bold. A crumb is an ordinary link to its panel, so clicking one goes back to that panel and closes what was stacked on top of it — and the browser's back button brings those columns back. The app's name and logo link to the app's home (the `home` option, `/` by default; `null` links neither), going back to it when it's already open and opening it when it isn't. A stack too long for the bar scrolls sideways, in an `S.scrollStrip` like the tab strip's.
|
|
216
219
|
|
|
217
220
|
That line is the `subtitle`'s while the stack has nothing to add: one panel open, reachable from a nav item that is already highlighted in a visible sidebar. Otherwise the stack takes it, since it is then the only thing naming the screen.
|
|
218
221
|
|
|
219
|
-
Right-click (or long-press) a crumb for **Close** — which takes just that panel out, wherever it sits in the stack — and **Pin**. A pinned panel — its crumb wears a pin — never closes as a side effect of navigation elsewhere: where
|
|
222
|
+
Right-click (or long-press) a crumb for **Close** — which takes just that panel out, wherever it sits in the stack — and **Pin**. A pinned panel — its crumb wears a pin — never closes as a side effect of navigation elsewhere: where a navigation would prune it, it rides along beneath the new panel instead (or parks out of sight, when the panel you went back to was already beneath it), one crumb click away. Pin the reference you keep coming back to, then navigate freely. An *explicit* close (Escape, `close()`, the crumb menu, `data-panel=replace`) still closes it, and it's yours from code as `$panel.pinned`. Because a crumb is a real link whose right-click the menu takes over, the menu also offers **Open in new tab** and **Copy link**.
|
|
220
223
|
|
|
221
224
|
A crumb can also wear a **●**: the panel holds unsaved work, and nothing will close it (see `$panel.unsaved` below).
|
|
222
225
|
|
|
@@ -261,7 +264,7 @@ Closing the current panel hands the focus to the panel on its left. Closing one
|
|
|
261
264
|
|
|
262
265
|
A closed panel is torn down at once: its `A.clean()` hooks run the moment it closes, so subscriptions, timers and requests stop there and then. Only its element hangs around, inert and frozen, for the length of the exit animation.
|
|
263
266
|
|
|
264
|
-
Escape
|
|
267
|
+
Escape closes the current panel — or just steps left, when it holds unsaved work or panels sit parked beyond it — and at the stack's start it jumps to the navigation. The browser's back button replays whole arrangements, re-opening what a navigation closed.
|
|
265
268
|
|
|
266
269
|
<a id="ancestors"></a>
|
|
267
270
|
|
|
@@ -293,7 +296,7 @@ Search params and the `#hash` belong to the current panel only. Anything another
|
|
|
293
296
|
|
|
294
297
|
**A few more things.**
|
|
295
298
|
|
|
296
|
-
- `columns: "single"` shows only the current panel, however wide the screen —
|
|
299
|
+
- `columns: "single"` shows only the current panel, however wide the screen — one screen at a time at every size, each still at its asked width, centred. Only the display changes: the URL, the back button, unsaved panels and the panels' own close buttons all behave the same.
|
|
297
300
|
- `linkNavigation` sets what a link *without* a `data-panel` attribute does: `"push"` (the default), `"replace"`, or `"open"`. With `"open"` every click replaces the content as a whole — which, with flat routes, is the conventional sidebar-and-content app: one pane, swapped on every click, the crumb line simply naming it.
|
|
298
301
|
- Both are live: pass a proxied options object (or make the field a getter) and a change is adopted in place, every open panel keeping its state.
|
|
299
302
|
- Only one routed `S.main()` can be mounted at a time; a second one throws — the URL is global, so two of them would fight over it. Nothing else is global: the stack belongs to its shell, and each handler gets its own `$panel`, since several panels are alive at once.
|
|
@@ -14,7 +14,9 @@ A.insertGlobalCss({
|
|
|
14
14
|
"&": "position:fixed z-index:200 top:50% left:50% " +
|
|
15
15
|
"display:flex flex-direction:column " +
|
|
16
16
|
"transform:translate(-50%,-50%) " +
|
|
17
|
-
|
|
17
|
+
// vw/vh divided by --s-zoom: viewport units shrink with the page-fitting
|
|
18
|
+
// zoom (see `watchScale` in main.ts), and these caps mean the window.
|
|
19
|
+
"min-width:20rem max-width:min(calc(90vw/var(--s-zoom,1)),44rem) max-height:min(calc(88vh/var(--s-zoom,1)),800px) " +
|
|
18
20
|
"r: $s-radius-lg; overflow:hidden " +
|
|
19
21
|
"transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
|
|
20
22
|
"> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
|
|
@@ -85,16 +85,15 @@ export interface MainOptions<R = Routes> {
|
|
|
85
85
|
* Navigating is just links: the shell handles the clicks itself, so do *not*
|
|
86
86
|
* also call Aberdeen's `interceptLinks()`. A link opens its target on top of
|
|
87
87
|
* the panel it sits in, closing everything after that panel first. The
|
|
88
|
-
* `data-panel` attribute
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* with `aberdeen/route`'s own `go()` works too — a panel with
|
|
88
|
+
* `data-panel` attribute keeps less of that context instead: `replace`
|
|
89
|
+
* drops the link's own panel too, putting the target in its place, and
|
|
90
|
+
* `open` drops it all, giving the target its own stack, the way a nav item
|
|
91
|
+
* does. A plain link to something already open goes back to it rather than
|
|
92
|
+
* opening it twice, closing whatever was stacked on top — pinned panels
|
|
93
|
+
* excepted (see {@link Panel.pinned}), and panels holding unsaved work,
|
|
94
|
+
* which park instead; a `replace` or `open` applies its usual shape, the
|
|
95
|
+
* open panel moving into it alive. From code, use {@link pushPanel} and
|
|
96
|
+
* friends: navigating with `aberdeen/route`'s own `go()` works too — a panel with
|
|
98
97
|
* {@link Panel.unsaved} work still survives it — but builds the whole stack
|
|
99
98
|
* from the path. A navigation guard the app registered with
|
|
100
99
|
* `route.setGuard` (an auth redirect, say) keeps working: the shell
|
|
@@ -104,11 +103,11 @@ export interface MainOptions<R = Routes> {
|
|
|
104
103
|
* is called ({@link Panel.title} — unset, its first line of text stands in)
|
|
105
104
|
* and what it can do ({@link Panel.actions}); everything else in a column is
|
|
106
105
|
* the panel's own content, boxes included. The shell writes the stack of
|
|
107
|
-
* open panels as breadcrumbs in the top bar — click one to go back to it
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* the
|
|
111
|
-
*
|
|
106
|
+
* open panels as breadcrumbs in the top bar — click one to go back to it —
|
|
107
|
+
* and places each panel's actions where the room is: on its own column while
|
|
108
|
+
* several fit, in the bar once the shell is narrow and the current panel *is*
|
|
109
|
+
* the screen. Nothing in an app measures the viewport to lay its screens out
|
|
110
|
+
* twice.
|
|
112
111
|
*
|
|
113
112
|
* Only one routed shell can be mounted at a time (a second one throws) —
|
|
114
113
|
* the URL is global, so two of them would fight over it. Nothing else is:
|
|
@@ -180,9 +179,10 @@ export interface MainOptions<R = Routes> {
|
|
|
180
179
|
* How many panels are *shown* at a time. `"auto"` (the default) shows as
|
|
181
180
|
* many columns, side by side, as comfortably fit, ending at the current
|
|
182
181
|
* panel; `"single"` shows only the current panel, however wide the screen
|
|
183
|
-
* —
|
|
184
|
-
*
|
|
185
|
-
*
|
|
182
|
+
* — one screen at a time at every size, each still at its asked width,
|
|
183
|
+
* centred (the nav sidebar still sits beside it). Only the display
|
|
184
|
+
* differs: the stack, the breadcrumbs, the URL, Escape and the back
|
|
185
|
+
* button behave identically in both. Routed mode only.
|
|
186
186
|
*
|
|
187
187
|
* Live: pass a proxied options object (or make this field a getter) and a
|
|
188
188
|
* change is adopted in place — one layout pass, every panel keeping its
|
|
@@ -191,12 +191,13 @@ export interface MainOptions<R = Routes> {
|
|
|
191
191
|
columns?: "auto" | "single";
|
|
192
192
|
/**
|
|
193
193
|
* What a link *without* a `data-panel` attribute does — the per-link
|
|
194
|
-
* attribute always wins.
|
|
195
|
-
*
|
|
196
|
-
* `"
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
194
|
+
* attribute always wins. The three keep less and less of the link's own
|
|
195
|
+
* context: `"push"` (the default) builds on the panel the link sits in,
|
|
196
|
+
* `"replace"` swaps that panel out, and `"open"` ignores it and gives the
|
|
197
|
+
* target its own stack, the way a nav item does. So with `"open"` every
|
|
198
|
+
* click replaces the content as a whole — which, with flat routes, is the
|
|
199
|
+
* conventional sidebar-and-content app: one pane, swapped on every click,
|
|
200
|
+
* the crumb line simply naming it. Routed mode only.
|
|
200
201
|
*
|
|
201
202
|
* Live, like {@link MainOptions.columns}: change it and the next click
|
|
202
203
|
* uses the new default.
|
|
@@ -205,32 +206,23 @@ export interface MainOptions<R = Routes> {
|
|
|
205
206
|
/** Footer content, pinned below the scroll area. */
|
|
206
207
|
footer?: Slot;
|
|
207
208
|
/**
|
|
208
|
-
* Max width for the
|
|
209
|
+
* Max width for the shell's *content*, e.g. `"80rem"`. The header and footer
|
|
209
210
|
* backgrounds still span the full shell width, but their contents — and the
|
|
210
211
|
* sidebar + separator + content trio (or just the content when there's no
|
|
211
212
|
* sidebar) — cap to this width and centre horizontally. When unset, everything
|
|
212
213
|
* fills the available width. Either way the content shares the panel surface —
|
|
213
214
|
* it is not boxed.
|
|
214
215
|
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
|
|
219
|
-
maxWidth?: string;
|
|
220
|
-
/**
|
|
221
|
-
* How wide a `"full"` panel gets, in pixels — and with it the whole content
|
|
222
|
-
* area, since a `"full"` fills it exactly. A `"half"` gets half of this, and
|
|
223
|
-
* a `"screen"` ignores it and takes the window. Defaults to 1080; the window
|
|
224
|
-
* caps it when there is less room than that. Routed mode only.
|
|
225
|
-
*
|
|
226
|
-
* This plus {@link MainOptions.navWidth} is the app's standard page — see
|
|
227
|
-
* there.
|
|
216
|
+
* In routed mode this is what the columns divide up (see
|
|
217
|
+
* {@link Panel.maxWidth}), which is the reason to set it on a very wide
|
|
218
|
+
* screen: left uncapped, a stack of small panels will happily march right
|
|
219
|
+
* across a 4K display.
|
|
228
220
|
*
|
|
229
221
|
* Live, like {@link MainOptions.columns}: pass a proxied options object (or
|
|
230
|
-
* make this field a getter) and a change is adopted in one layout pass,
|
|
231
|
-
*
|
|
222
|
+
* make this field a getter) and a change is adopted in one layout pass, every
|
|
223
|
+
* open panel keeping its state.
|
|
232
224
|
*/
|
|
233
|
-
|
|
225
|
+
maxWidth?: string;
|
|
234
226
|
/** Aberdeen attr/style string applied to the content area. */
|
|
235
227
|
contentAttrs?: Attributes;
|
|
236
228
|
/** Aberdeen attr/style string applied to the top bar. */
|
|
@@ -259,13 +251,9 @@ export interface MainOptions<R = Routes> {
|
|
|
259
251
|
navPosition?: "left" | "right";
|
|
260
252
|
/**
|
|
261
253
|
* How wide the nav sidebar column is, in pixels — its hairline included.
|
|
262
|
-
* Defaults to 200.
|
|
263
|
-
*
|
|
264
|
-
* Together with {@link MainOptions.fullWidth} this is the app's *standard
|
|
265
|
-
* page*: the width the top bar and footer keep to, and the width the
|
|
266
|
-
* columns settle back to. The defaults come to the familiar 1280px.
|
|
254
|
+
* Defaults to 200. Whatever it takes comes off the content area beside it.
|
|
267
255
|
*
|
|
268
|
-
* Live, like {@link MainOptions.
|
|
256
|
+
* Live, like {@link MainOptions.maxWidth}.
|
|
269
257
|
*/
|
|
270
258
|
navWidth?: number;
|
|
271
259
|
/** Aberdeen attr/style string applied to the sidebar nav panel. */
|
|
@@ -285,7 +273,8 @@ export interface MainOptions<R = Routes> {
|
|
|
285
273
|
* Instead of a single `content` slot, pass {@link MainOptions.routes} and the
|
|
286
274
|
* shell takes over navigation: each route draws one screen, called a panel,
|
|
287
275
|
* and as many columns as fit are shown at a time, side by side on a wide screen
|
|
288
|
-
* and one at a time on a phone
|
|
276
|
+
* and one at a time on a phone; {@link MainOptions.maxWidth} caps how much room
|
|
277
|
+
* they have between them. Each panel *declares* its chrome — its
|
|
289
278
|
* {@link Panel.title} and its {@link Panel.actions} — and this shell places it:
|
|
290
279
|
* the stack of titles as breadcrumbs in the bar, the actions on the panel's
|
|
291
280
|
* column while several fit and in the bar once the shell is narrow enough
|
package/dist/components/main.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { current as currentRoute } from "aberdeen/route";
|
|
3
|
-
import { drawSlot, focusFirst, NARROW_PX } from "../core.js";
|
|
3
|
+
import { drawSlot, focusFirst, NARROW_PX, MIN_PX } from "../core.js";
|
|
4
4
|
import { 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,20 +8,15 @@ import { drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent } from "./me
|
|
|
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 } from "./panels.js";
|
|
12
|
-
/**
|
|
13
|
-
* The default nav column (hairline included) and the default width of a
|
|
14
|
-
* `"full"` panel — see {@link MainOptions.navWidth} and
|
|
15
|
-
* {@link MainOptions.fullWidth}. Side by side they come to the 1280px page the
|
|
16
|
-
* shell is usually seen as, but that figure lives nowhere: the browser adds
|
|
17
|
-
* these two up, and an app that changes either simply gets a different page.
|
|
18
|
-
*/
|
|
11
|
+
import { PanelStackController, SMALL_MAX_PX } from "./panels.js";
|
|
12
|
+
/** The default nav column, hairline included — see {@link MainOptions.navWidth}. */
|
|
19
13
|
const NAV_W = 200;
|
|
20
|
-
const FULL_W = 1080;
|
|
21
14
|
A.insertGlobalCss({
|
|
22
15
|
".s-main": {
|
|
23
16
|
// container-type so @container queries below can respond to shell width.
|
|
24
|
-
|
|
17
|
+
// The vh divided by --s-zoom: viewport units shrink with the page-fitting
|
|
18
|
+
// zoom (see `watchScale`), and this height means the window.
|
|
19
|
+
"&": "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",
|
|
25
20
|
// <body> carries a default $3 padding; when the shell is a direct child of it,
|
|
26
21
|
// cancel that padding with matching negative margins so the chrome still spans
|
|
27
22
|
// edge to edge (and the 100vh sizing stays exact).
|
|
@@ -96,27 +91,6 @@ A.insertGlobalCss({
|
|
|
96
91
|
// and the bar already comes from `.s-content`'s padding. Without a scrollbar
|
|
97
92
|
// there's no margin, so the content keeps its single $3 edge — not 2×$3.
|
|
98
93
|
".s-body main.s-scroll-y": "margin-right:$3",
|
|
99
|
-
// Routed mode takes its width from the stack instead of from
|
|
100
|
-
// `maxWidth`: the layout engine publishes the ensemble width (sidebar +
|
|
101
|
-
// separator + content area) as --s-shell-w — the standard page
|
|
102
|
-
// normally, wider while the columns outgrow it (a "screen" page, or
|
|
103
|
-
// extra columns fitting a wide window) — and the body row caps itself
|
|
104
|
-
// to it, staying centred around the columns. Changing the custom
|
|
105
|
-
// property animates the max-width consuming it, with no JS in the loop:
|
|
106
|
-
// the body recentres in step with the panel whose arrival or departure
|
|
107
|
-
// moved it, over the same --s-panel-ms (see panels.ts). During a window
|
|
108
|
-
// resize (and the very first pass) the layout engine raises
|
|
109
|
-
// `.s-shell-snap` so the new width is adopted instantly instead of
|
|
110
|
-
// chasing the window through a transition.
|
|
111
|
-
"&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
|
|
112
|
-
"&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
|
|
113
|
-
// The bars don't follow the ensemble past the standard page: a header
|
|
114
|
-
// stretching to the window's edges and back with every "screen" panel
|
|
115
|
-
// reads as the whole app flexing, so the chrome holds still and only
|
|
116
|
-
// the columns grow. (Below the standard width the ensemble is simply
|
|
117
|
-
// the window, which only a resize changes — so the bars never animate,
|
|
118
|
-
// and take no part in the transition above.)
|
|
119
|
-
"&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "max-width: calc(var(--s-nav-w) + var(--s-full-w))",
|
|
120
94
|
},
|
|
121
95
|
// Sidebar nav panel. Items reuse the shared `.s-menu-item` /
|
|
122
96
|
// `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
|
|
@@ -163,13 +137,20 @@ A.insertGlobalCss({
|
|
|
163
137
|
// A phone's bar holds two lines of chrome in a screen's width, so it buys
|
|
164
138
|
// the stack and the screen's actions room by spending less on air.
|
|
165
139
|
".s-main > header > .s-bar": "gap:$1 padding: $1 $2;",
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
169
|
-
// At narrow widths, content boxes are full-bleed so there's no inset to
|
|
170
|
-
// align the scrollbar with — cancel the right margin.
|
|
140
|
+
// The narrow bar tucks its content in to $2 (above), so the $3 scrollbar
|
|
141
|
+
// inset no longer has a chrome edge to align with — cancel it.
|
|
171
142
|
".s-main .s-body main.s-scroll-y": "margin-right:0",
|
|
172
143
|
},
|
|
144
|
+
// On phones a top-level content box becomes a full-bleed block: pull it out
|
|
145
|
+
// to negate the content padding and drop the rounded corners. Keyed on
|
|
146
|
+
// SMALL_MAX_PX, not the narrow threshold above: at or below it a column can
|
|
147
|
+
// never be narrower than the window (every size caps at the content area,
|
|
148
|
+
// and a lone column's cap is exactly this — see panels.ts), while just above
|
|
149
|
+
// it a small column floats centred, where a bleeding box would shed its card
|
|
150
|
+
// chrome over open ground.
|
|
151
|
+
[`@container (max-width: ${SMALL_MAX_PX}px)`]: {
|
|
152
|
+
".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
|
|
153
|
+
},
|
|
173
154
|
});
|
|
174
155
|
export function main(opts = {}) {
|
|
175
156
|
// Whether there is a nav to show is deliberately NOT worked out here: `items`
|
|
@@ -208,9 +189,6 @@ export function main(opts = {}) {
|
|
|
208
189
|
notFound: opts.notFound,
|
|
209
190
|
ancestors: opts.ancestors,
|
|
210
191
|
title: opts.title,
|
|
211
|
-
// Corrected below, and on every change, from the app's own option:
|
|
212
|
-
// read here it would subscribe the whole shell to it.
|
|
213
|
-
fullWidth: FULL_W,
|
|
214
192
|
$shell,
|
|
215
193
|
})
|
|
216
194
|
: null;
|
|
@@ -222,21 +200,18 @@ export function main(opts = {}) {
|
|
|
222
200
|
// the new link default. Nothing else of the shell is touched.
|
|
223
201
|
A(() => ctl.setColumns(opts.columns));
|
|
224
202
|
A(() => ctl.setLinkNavigation(opts.linkNavigation));
|
|
225
|
-
A(() => ctl.setFullWidth(opts.fullWidth ?? FULL_W));
|
|
226
203
|
}
|
|
227
204
|
// Where the brand mark and the app's name link — or nowhere, when the app
|
|
228
205
|
// said `home: null` (a title slot holding a control of its own, say).
|
|
229
206
|
const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// and their state untouched.
|
|
239
|
-
A(() => A(`--s-full-w: ${opts.fullWidth ?? FULL_W}px`));
|
|
207
|
+
// The shell's one width cap, applied to the body row and to both bars, so the
|
|
208
|
+
// chrome and the content always line up. Each of the three reads it in a
|
|
209
|
+
// scope of its own — one that draws nothing, so re-running it is a single
|
|
210
|
+
// style write: an app that changes it on a proxied options object resizes the
|
|
211
|
+
// shell in place, panels and their state untouched.
|
|
212
|
+
const capWidth = () => { if (opts.maxWidth != null)
|
|
213
|
+
A("max-width:", opts.maxWidth); };
|
|
214
|
+
const root = A("div.s-main", opts.attrs, () => {
|
|
240
215
|
// `--s-nav-w` is the sidebar's whole column, and nothing at all when there
|
|
241
216
|
// is no sidebar to give it to — a shell without one lines its bars up with
|
|
242
217
|
// the content. This scope also tags the shell with the side the sidebar is
|
|
@@ -267,10 +242,7 @@ export function main(opts = {}) {
|
|
|
267
242
|
A("header.s-s.neutral", opts.topbarAttrs, () => {
|
|
268
243
|
A("div.s-bar", () => {
|
|
269
244
|
// Cap the bar's content to maxWidth and centre it within the full-width header.
|
|
270
|
-
A(
|
|
271
|
-
if (capWidth != null)
|
|
272
|
-
A("max-width:", capWidth);
|
|
273
|
-
});
|
|
245
|
+
A(capWidth);
|
|
274
246
|
// Leading: the ☰ once the nav has collapsed, the logo otherwise.
|
|
275
247
|
// Deliberately no back button, at any width: going back is the
|
|
276
248
|
// stack's job in both regimes (plus Escape and the browser's own
|
|
@@ -332,10 +304,7 @@ export function main(opts = {}) {
|
|
|
332
304
|
// are identical with and without a sidebar nav.
|
|
333
305
|
A("div.s-body", () => {
|
|
334
306
|
A("div.s-body-inner", () => {
|
|
335
|
-
A(
|
|
336
|
-
if (capWidth != null)
|
|
337
|
-
A("max-width:", capWidth);
|
|
338
|
-
});
|
|
307
|
+
A(capWidth);
|
|
339
308
|
// The sidebar, in its own scope so a changing item list redraws just
|
|
340
309
|
// it — never the content area beside it (see `nav` above).
|
|
341
310
|
A(() => {
|
|
@@ -359,10 +328,7 @@ export function main(opts = {}) {
|
|
|
359
328
|
if (opts.footer != null) {
|
|
360
329
|
A("footer", () => {
|
|
361
330
|
A("div.s-bar", () => {
|
|
362
|
-
A(
|
|
363
|
-
if (capWidth != null)
|
|
364
|
-
A("max-width:", capWidth);
|
|
365
|
-
});
|
|
331
|
+
A(capWidth);
|
|
366
332
|
drawSlot(opts.footer);
|
|
367
333
|
});
|
|
368
334
|
});
|
|
@@ -370,6 +336,7 @@ export function main(opts = {}) {
|
|
|
370
336
|
});
|
|
371
337
|
});
|
|
372
338
|
watchNarrow(root, $shell);
|
|
339
|
+
watchScale();
|
|
373
340
|
// Escape peels back a panel of UI, and finally jumps to the navigation: into
|
|
374
341
|
// the sidebar's current item when the sidebar is showing, or — when it has
|
|
375
342
|
// collapsed to the ☰ — open the full-page nav, which focuses its current item.
|
|
@@ -501,6 +468,48 @@ function taglineFits(ctl, nav, $shell) {
|
|
|
501
468
|
return false;
|
|
502
469
|
return anyCurrent(nav.items);
|
|
503
470
|
}
|
|
471
|
+
/** How many mounted shells are watching the window; the listener is one per page. */
|
|
472
|
+
let scaleShells = 0;
|
|
473
|
+
/** Lay the page out at a virtual {@link MIN_PX} and zoom it down to the window. */
|
|
474
|
+
function applyScale() {
|
|
475
|
+
// The real window width: <html> is never zoomed, so this stays unscaled.
|
|
476
|
+
const w = document.documentElement.clientWidth;
|
|
477
|
+
const f = w && w < MIN_PX ? w / MIN_PX : 0;
|
|
478
|
+
document.body.style.zoom = f ? String(f) : "";
|
|
479
|
+
// Published for the vh/vw lengths in the library's CSS, which zoom shrinks
|
|
480
|
+
// and a `/var(--s-zoom,1)` restores to meaning the window.
|
|
481
|
+
if (f)
|
|
482
|
+
document.body.style.setProperty("--s-zoom", String(f));
|
|
483
|
+
else
|
|
484
|
+
document.body.style.removeProperty("--s-zoom");
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Below {@link MIN_PX} of window the shell stops squeezing and starts scaling:
|
|
488
|
+
* the page keeps its {@link MIN_PX} layout and CSS `zoom` shrinks it to fit,
|
|
489
|
+
* so a 180px window shows the 360px layout at half size. The zoom goes on
|
|
490
|
+
* `<body>`, so the overlays that portal there — dialogs, menus, toasts,
|
|
491
|
+
* tooltips — scale with the shell. `zoom` rather than `transform:scale`,
|
|
492
|
+
* because zoom keeps layout, container queries and the top layer in one
|
|
493
|
+
* system; what it splits instead is coordinate spaces — window-space rects
|
|
494
|
+
* against element-space lengths — which the few places mixing those bridge
|
|
495
|
+
* with {@link cssZoom}, and vh/vw lengths with `--s-zoom` (see `applyScale`).
|
|
496
|
+
* Browsers that predate `currentCSSZoom` (mid-2024) keep the squeeze.
|
|
497
|
+
*/
|
|
498
|
+
function watchScale() {
|
|
499
|
+
if (typeof window === "undefined" || !("currentCSSZoom" in document.documentElement))
|
|
500
|
+
return;
|
|
501
|
+
if (++scaleShells === 1) {
|
|
502
|
+
window.addEventListener("resize", applyScale);
|
|
503
|
+
applyScale();
|
|
504
|
+
}
|
|
505
|
+
A.clean(() => {
|
|
506
|
+
if (--scaleShells === 0) {
|
|
507
|
+
window.removeEventListener("resize", applyScale);
|
|
508
|
+
document.body.style.zoom = "";
|
|
509
|
+
document.body.style.removeProperty("--s-zoom");
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
}
|
|
504
513
|
/**
|
|
505
514
|
* Track whether the shell is narrow, for everything that has to agree about it.
|
|
506
515
|
*
|
|
@@ -124,6 +124,14 @@ export interface FloatingMenuOptions {
|
|
|
124
124
|
* context menu — whose anchor has no click handler — wants the click to close.
|
|
125
125
|
*/
|
|
126
126
|
closeOnAnchorClick?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* The link this menu stands on, as a path or URL. A menu that takes over a
|
|
129
|
+
* link's right-click takes the browser's own link menu away, so it owes the
|
|
130
|
+
* two entries anyone actually reaches for there: with this set, **Open in
|
|
131
|
+
* new tab** and **Copy link** are prepended above a separator, where that
|
|
132
|
+
* menu would have had them. The shell's breadcrumbs use it.
|
|
133
|
+
*/
|
|
134
|
+
link?: string;
|
|
127
135
|
/** Aberdeen attr/style string on the floating panel. */
|
|
128
136
|
dropdownAttrs?: Attributes;
|
|
129
137
|
}
|
package/dist/components/menu.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
|
|
3
|
-
import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
4
|
-
import { menu as menuIcon, chevronRight } from "../icons.js";
|
|
3
|
+
import { cssZoom, 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 } from "./button.js";
|
|
6
|
+
import { toast } from "./toast.js";
|
|
6
7
|
// Styles shared by the floating dropdown and the sidebar nav, so both look
|
|
7
8
|
// identical. The item styles aren't scoped to a container, so `drawMenu` can
|
|
8
9
|
// render its items into either one.
|
|
@@ -16,7 +17,7 @@ A.insertGlobalCss({
|
|
|
16
17
|
// invisible yet still hittable by tests and read by assistive tech.
|
|
17
18
|
".s-menu-list": "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
|
|
18
19
|
"r:$s-radius-lg " +
|
|
19
|
-
"overflow-y:auto max-height:min(80vh,28rem) " +
|
|
20
|
+
"overflow-y:auto max-height:min(calc(80vh/var(--s-zoom,1)),28rem) " +
|
|
20
21
|
"transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
|
|
21
22
|
".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
|
|
22
23
|
// One class for both the `<a>` (link) and `<button>` forms — they look
|
|
@@ -395,24 +396,58 @@ export function closeFloatingMenu(anchor) {
|
|
|
395
396
|
closeFloating();
|
|
396
397
|
}
|
|
397
398
|
function positionMenu(menuEl, rect) {
|
|
399
|
+
// The rect arrives in window coordinates (an anchor's rect, or a pointer
|
|
400
|
+
// position); the left/top set below live in the menu's own space. The two
|
|
401
|
+
// differ when the shell has zoomed the page (see `watchScale` in main.ts),
|
|
402
|
+
// so everything is brought into the menu's space first.
|
|
403
|
+
const z = cssZoom(menuEl);
|
|
398
404
|
const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
|
|
399
|
-
const vw = window.innerWidth, vh = window.innerHeight;
|
|
405
|
+
const vw = window.innerWidth / z, vh = window.innerHeight / z;
|
|
400
406
|
const gap = 4;
|
|
401
|
-
let x = rect.left;
|
|
407
|
+
let x = rect.left / z;
|
|
402
408
|
if (x + mw > vw - 8)
|
|
403
|
-
x = Math.max(8, rect.right - mw);
|
|
404
|
-
let y = rect.bottom + gap;
|
|
405
|
-
if (y + mh > vh - 8 && rect.top - mh - gap >= 8)
|
|
406
|
-
y = rect.top - mh - gap;
|
|
409
|
+
x = Math.max(8, rect.right / z - mw);
|
|
410
|
+
let y = rect.bottom / z + gap;
|
|
411
|
+
if (y + mh > vh - 8 && rect.top / z - mh - gap >= 8)
|
|
412
|
+
y = rect.top / z - mh - gap;
|
|
407
413
|
menuEl.style.left = Math.max(8, x) + "px";
|
|
408
414
|
menuEl.style.top = Math.max(8, y) + "px";
|
|
409
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* The standard entries for the link a menu stands on (see
|
|
418
|
+
* {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so
|
|
419
|
+
* the target arrives cold, exactly as the link middle-clicked would.
|
|
420
|
+
*/
|
|
421
|
+
function linkItems(href) {
|
|
422
|
+
return [
|
|
423
|
+
{ label: "Open in new tab", icon: newTabIcon, click: () => { window.open(href, "_blank", "noopener"); } },
|
|
424
|
+
{ label: "Copy link", icon: linkIcon, click: () => void copyLink(href) },
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Put the link's address on the clipboard, as the absolute URL someone can
|
|
429
|
+
* paste anywhere — what the browser's own "Copy link" would have given them.
|
|
430
|
+
* Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
|
|
431
|
+
* needs a secure context, so a failure says so rather than lying.
|
|
432
|
+
*/
|
|
433
|
+
async function copyLink(href) {
|
|
434
|
+
const url = new URL(href, location.href).href;
|
|
435
|
+
try {
|
|
436
|
+
await navigator.clipboard.writeText(url);
|
|
437
|
+
toast({ message: "Link copied." });
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
toast({ message: "Couldn't copy the link.", type: "danger" });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
410
443
|
mountPortal(() => {
|
|
411
444
|
const f = $floating.opts;
|
|
412
445
|
if (!f)
|
|
413
446
|
return;
|
|
414
447
|
const menuEl = A("div.s-menu-list.s-s.neutral.shadow create=hidden destroy=hidden", f.dropdownAttrs, () => {
|
|
415
|
-
drawMenu
|
|
448
|
+
// One drawMenu call, not one per section: it owns the container's roving
|
|
449
|
+
// keyboard focus, and two of them would move it twice per keypress.
|
|
450
|
+
drawMenu(f.link != null ? [...linkItems(f.link), { separator: true }, ...f.items] : f.items, closeFloating);
|
|
416
451
|
});
|
|
417
452
|
// Capture-phase document handlers replace an invisible backdrop element:
|
|
418
453
|
// any click outside the panel + anchor closes; Escape/Tab close.
|
|
@@ -539,13 +574,13 @@ export function addContextMenu(opts) {
|
|
|
539
574
|
e.preventDefault();
|
|
540
575
|
myEl = e.currentTarget;
|
|
541
576
|
// Anchor at the exact click/tap point, and close on a plain click of the
|
|
542
|
-
// element (it has no toggle handler of its own).
|
|
577
|
+
// element (it has no toggle handler of its own). The rest of the options
|
|
578
|
+
// pass through whole, so a shared option can't be dropped on the way.
|
|
543
579
|
showFloatingMenu({
|
|
544
|
-
|
|
580
|
+
...opts,
|
|
545
581
|
anchor: myEl,
|
|
546
582
|
at: { x: e.clientX, y: e.clientY },
|
|
547
583
|
closeOnAnchorClick: true,
|
|
548
|
-
dropdownAttrs: opts.dropdownAttrs,
|
|
549
584
|
});
|
|
550
585
|
});
|
|
551
586
|
}
|