@cplieger/web-terminal-ui 4.6.0 → 4.7.1
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 +14 -6
- package/css/02-terminal.css +9 -4
- package/css/30-tabs.css +120 -10
- package/css/31-switcher.css +19 -4
- package/package.json +7 -4
- package/src/features/context-menu.ts +9 -12
- package/src/features/menu-position.ts +29 -0
- package/src/features/tabs/index.ts +708 -99
- package/src/features/tabs/model.ts +284 -182
- package/src/kernel/a11y.ts +16 -0
- package/src/kernel/kernel.ts +119 -47
- package/src/kernel/types.ts +41 -11
- package/src/presets/agent-tabbed.ts +4 -6
- package/src/presets/tabbed.ts +8 -3
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
|
|
|
143
143
|
| `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
|
|
144
144
|
| `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
|
|
145
145
|
| `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
|
|
146
|
-
| `onFatalError` | _(built-in recovery)_ | Called with
|
|
146
|
+
| `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
|
|
147
147
|
| `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input`. The library ships neutral defaults. |
|
|
148
148
|
|
|
149
149
|
`createTerminal()` returns a handle: `focus()` re-focuses the terminal input
|
|
@@ -154,11 +154,19 @@ screen without injecting keystrokes (send a redraw keystroke yourself if you
|
|
|
154
154
|
want one, for example Ctrl+L); and `destroy()` tears every feature down and
|
|
155
155
|
releases the kernel.
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
Startup can fail in two phases, and both end at the same recovery surface. If a
|
|
158
|
+
feature's setup throws or rejects (`phase: "feature-setup"`), the kernel stops the
|
|
159
|
+
connection, tears down every completed feature and core listener, clears the
|
|
160
|
+
broken subtree, and shows a reload surface. If `createTerminal` itself throws
|
|
161
|
+
(`phase: "kernel-init"` — an invalid feature list, a DOM invariant), it shows the
|
|
162
|
+
same surface, lowers your loading overlay so the surface is visible, and then
|
|
163
|
+
rethrows, so a caller with its own error handling still sees the error. Either
|
|
164
|
+
way the surface is modal when the terminal owns the viewport and non-modal when
|
|
165
|
+
it fills an embedded container.
|
|
166
|
+
|
|
167
|
+
`onFatalError` receives the failure after cleanup. Discriminate on `phase`:
|
|
168
|
+
`feature-setup` names the offending `feature`, `kernel-init` does not, because
|
|
169
|
+
feature composition never began. Return `true` only when the host has rendered
|
|
162
170
|
replacement recovery UI into the terminal root.
|
|
163
171
|
|
|
164
172
|
## What ships
|
package/css/02-terminal.css
CHANGED
|
@@ -24,10 +24,15 @@
|
|
|
24
24
|
scrollbar-gutter: stable;
|
|
25
25
|
/* The scroll controller (vterm scroll.ts) is the single owner of
|
|
26
26
|
scrollTop: it pins to the bottom only while the user is following,
|
|
27
|
-
and does nothing while they are scrolled up reading.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
and does nothing while they are scrolled up reading.
|
|
28
|
+
Content ABOVE the reading position DOES change: rows are evicted
|
|
29
|
+
from the top of history at the retention cap, and the trim marker
|
|
30
|
+
is inserted there. Native scroll anchoring compensates for that on
|
|
31
|
+
Chrome and Firefox, so it stays enabled here — but WebKit has never
|
|
32
|
+
implemented it, which is why the renderer also corrects the drift
|
|
33
|
+
itself (render.ts captureReadAnchor / restoreReadAnchor). That
|
|
34
|
+
correction measures residual drift, so it is a no-op wherever this
|
|
35
|
+
declaration already did the job. */
|
|
31
36
|
overflow-anchor: auto;
|
|
32
37
|
/* .term is the scroll container, so the touch-action intersection for a
|
|
33
38
|
touch on the output stops here (the html/body pan-x pan-y does not reach
|
package/css/30-tabs.css
CHANGED
|
@@ -86,6 +86,12 @@
|
|
|
86
86
|
cursor: pointer;
|
|
87
87
|
white-space: nowrap;
|
|
88
88
|
user-select: none;
|
|
89
|
+
/* Kills the 300ms double-tap-to-zoom delay, which is what makes a double-TAP
|
|
90
|
+
usable as the touch rename entry: on iPadOS a long-press on this chip starts
|
|
91
|
+
a reorder drag (it is draggable), so the context menu is unreachable by touch
|
|
92
|
+
and the double-tap is the only path in. Also removes the tap delay for the
|
|
93
|
+
ordinary switch tap. */
|
|
94
|
+
touch-action: manipulation;
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
/* Inactive tabs read as outlined chips with a subtle dark fill (so adjacent tabs
|
|
@@ -131,17 +137,37 @@
|
|
|
131
137
|
overflow: hidden;
|
|
132
138
|
white-space: nowrap;
|
|
133
139
|
text-overflow: ellipsis;
|
|
134
|
-
/* Center the INK, not the line box: UI fonts carry more ascent than
|
|
135
|
-
(Noto Sans at 13px: 14px over / 4px under
|
|
136
|
-
centered line box leaves the visible text ~1.5px
|
|
137
|
-
baseline-aligned with the activity dot's bottom instead of
|
|
138
|
-
Trimming the box
|
|
139
|
-
the
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
/* Center the INK, not the line box: UI fonts carry far more ascent than
|
|
141
|
+
descent (the -apple-system/Noto Sans stack at 13px: 14px over / 4px under
|
|
142
|
+
the baseline), so the flex-centered line box leaves the visible text ~1.5px
|
|
143
|
+
low — reading as baseline-aligned with the activity dot's bottom instead of
|
|
144
|
+
its center. Trimming the box makes flexbox center the glyph band instead of
|
|
145
|
+
the font's metrics box.
|
|
146
|
+
|
|
147
|
+
`ex`, not `cap`, is the over edge, because a tab title is sentence case and
|
|
148
|
+
lowercase-dominant ("workspace", "refactor the auth module"). Centering the
|
|
149
|
+
x-height/baseline band centers the total ink of such a string, since a
|
|
150
|
+
Latin font's ascender overshoot above x-height is about its descender depth
|
|
151
|
+
below the baseline (measured on this stack at 13px: 3.0px over vs 3.0px
|
|
152
|
+
under, ink center 0.02px off the chip's center). `cap alphabetic` — the
|
|
153
|
+
right choice for an ALL-CAPS label — leaves lowercase text 1.14px low,
|
|
154
|
+
which is the original complaint barely moved.
|
|
155
|
+
|
|
156
|
+
padding-block is LOAD-BEARING, not spacing: trimming puts the box's under
|
|
157
|
+
edge ON the baseline, and `overflow: hidden` clips at the padding edge, so
|
|
158
|
+
with no padding every descender and every tall diacritic is cut off (
|
|
159
|
+
measured: 3.3px of p/y/g/j tail and 2.3px of an acute accent, i.e. "pygmy
|
|
160
|
+
jaguar Ápple" rendered as "pyqmy iaquar Apple"). 0.5em restores the ink
|
|
161
|
+
overhang the trim removes from the box; being symmetric it does not move
|
|
162
|
+
the centered band, and being em-relative it tracks the font size. The
|
|
163
|
+
grown box (~20px) still sits inside the close button's 1.5rem, so the tab
|
|
164
|
+
height is unchanged. A browser without text-box support keeps the untrimmed
|
|
165
|
+
box, where the padding is harmless slack.
|
|
166
|
+
|
|
167
|
+
Shared by all three chip labels (this one + the two switcher labels in
|
|
143
168
|
31-switcher.css). */
|
|
144
|
-
text-box: trim-both
|
|
169
|
+
text-box: trim-both ex alphabetic;
|
|
170
|
+
padding-block: 0.5em;
|
|
145
171
|
}
|
|
146
172
|
|
|
147
173
|
/* A little breathing room after the activity dot, before the label. */
|
|
@@ -294,6 +320,20 @@
|
|
|
294
320
|
margin-left: auto;
|
|
295
321
|
}
|
|
296
322
|
|
|
323
|
+
/* Hover wash, matching every other bar control (.wt-tab-new, the switcher's
|
|
324
|
+
buttons) — without it this was the one control in the chrome that gave no
|
|
325
|
+
pointer feedback at all. Gated to real hover pointers because the button only
|
|
326
|
+
SHOWS on a coarse pointer (the iPad strip): a plain touchscreen has no
|
|
327
|
+
pointer-leave, so :hover would stick after a tap and the wash would linger;
|
|
328
|
+
the feedback there is :active, from .wt-btn. Before .wt-active / .wt-armed so
|
|
329
|
+
those toggled fills win on a hovered button (equal specificity, source order
|
|
330
|
+
decides). */
|
|
331
|
+
@media (hover: hover) {
|
|
332
|
+
:where(.wt-root) .wt-tab-kb:hover {
|
|
333
|
+
background: var(--tab-hover-bg);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
297
337
|
/* Grid open: filled so the toggled state reads at a glance (mirrors the
|
|
298
338
|
switcher keyboard button). */
|
|
299
339
|
:where(.wt-root) .wt-tab-kb.wt-active {
|
|
@@ -322,6 +362,66 @@
|
|
|
322
362
|
opacity: 0.5;
|
|
323
363
|
}
|
|
324
364
|
|
|
365
|
+
/* ===== Inline rename (tabs feature) =====
|
|
366
|
+
The edit surface is the LABEL's own box, so the width the user types into is
|
|
367
|
+
the width the label will have. The field takes the label's flex slot exactly;
|
|
368
|
+
the label is [hidden] and the close button is hidden for the duration (a 44px
|
|
369
|
+
destructive target next to a live text field is a mis-click waiting to happen,
|
|
370
|
+
and the freed width goes to the field). */
|
|
371
|
+
:where(.wt-root) .wt-tab-rename {
|
|
372
|
+
flex: 1 1 auto;
|
|
373
|
+
min-width: 0;
|
|
374
|
+
background: transparent;
|
|
375
|
+
border: none;
|
|
376
|
+
padding: 0;
|
|
377
|
+
color: var(--tab-active-fg);
|
|
378
|
+
font: inherit;
|
|
379
|
+
/* .wt-tab sets user-select: none, and WebKit CASCADES that into descendants
|
|
380
|
+
even though the property is non-inherited per spec (the same deviation the
|
|
381
|
+
touch model documents for .term-output). Without this, caret placement and
|
|
382
|
+
text selection break inside the field in Safari — i.e. on the iPad. */
|
|
383
|
+
user-select: text;
|
|
384
|
+
/* The chip is cursor: pointer; a text field must not inherit that. */
|
|
385
|
+
cursor: text;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* The family focus ring is scoped to button:focus-visible in 10-primitives.css,
|
|
389
|
+
so the field needs its own. It is focused programmatically the instant it
|
|
390
|
+
appears, so :focus (not :focus-visible) is correct here — the ring IS the
|
|
391
|
+
affordance that says "type here". */
|
|
392
|
+
:where(.wt-root) .wt-tab-rename:focus {
|
|
393
|
+
outline: 2px solid var(--accent);
|
|
394
|
+
outline-offset: 1px;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* While editing, hide the close button: a 44px destructive target adjacent to a
|
|
398
|
+
live text field is a mis-click waiting to happen, and the freed width goes to
|
|
399
|
+
the field.
|
|
400
|
+
|
|
401
|
+
The chip is NOT widened. An earlier draft gave the edited chip its full 300px
|
|
402
|
+
so a crowded strip (tabs shrink toward a 100px floor) would not offer a 100px
|
|
403
|
+
edit box — but nothing in this design system transitions `width` on a tab, so
|
|
404
|
+
that was an instant ~200px shove of every neighbouring tab plus a scroll jump,
|
|
405
|
+
at the exact moment the user is trying to read what they are typing. A cramped
|
|
406
|
+
field on a crowded strip is the lesser problem. */
|
|
407
|
+
:where(.wt-root) .wt-tab-editing .wt-tab-close {
|
|
408
|
+
display: none;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* The drag preview clone (setDragGhost): a full-opacity copy of the chip parked
|
|
412
|
+
under .wt-root — OUTSIDE the bar's backdrop-filter, which is what stops WebKit
|
|
413
|
+
from rendering the automatic drag image (webkit.org/b/22787) — and laid exactly
|
|
414
|
+
over the real chip via inline left/top/width/height. It exists for less than a
|
|
415
|
+
frame (the browser snapshots it, then a rAF removes it), so it must not be
|
|
416
|
+
hidden (an invisible element makes no snapshot) and must not intercept the
|
|
417
|
+
pointer. z-index clears the bar so the copy, not the dimmed original, is what
|
|
418
|
+
would show if it ever painted. */
|
|
419
|
+
:where(.wt-root) .wt-tab-ghost {
|
|
420
|
+
position: absolute;
|
|
421
|
+
z-index: 16;
|
|
422
|
+
pointer-events: none;
|
|
423
|
+
}
|
|
424
|
+
|
|
325
425
|
/* Tab context menu (tabs feature, desktop right-click): Close / Close others /
|
|
326
426
|
Close to the right / Close to the left / Close all. Replaces the old bar
|
|
327
427
|
"Close all" button. Mirrors .wt-ctx-menu (a fixed surface popover, clamped
|
|
@@ -375,6 +475,16 @@
|
|
|
375
475
|
cursor: default;
|
|
376
476
|
}
|
|
377
477
|
|
|
478
|
+
/* Group separator: the naming pair (Rename / Use automatic name) sits above the
|
|
479
|
+
move and close groups, so a hairline keeps the non-destructive actions visually
|
|
480
|
+
apart from the destructive ones. The menu is otherwise a flat flex column of
|
|
481
|
+
buttons with no separator vocabulary. */
|
|
482
|
+
:where(.wt-root) .wt-tab-menu-sep {
|
|
483
|
+
height: 1px;
|
|
484
|
+
margin: var(--sp-1) 0;
|
|
485
|
+
background: var(--border);
|
|
486
|
+
}
|
|
487
|
+
|
|
378
488
|
/* Pull the terminal surface up off the docked BOTTOM tab strip so it does not
|
|
379
489
|
overlap the last rows (a mirror of the old top offset). The tabs feature
|
|
380
490
|
marks the surface (.wt-with-tabbar) and keeps the measured strip height in
|
package/css/31-switcher.css
CHANGED
|
@@ -178,8 +178,11 @@
|
|
|
178
178
|
text-overflow: ellipsis;
|
|
179
179
|
white-space: nowrap;
|
|
180
180
|
text-align: left;
|
|
181
|
-
/* Ink-centering trim
|
|
182
|
-
|
|
181
|
+
/* Ink-centering trim + its mandatory descender allowance, same as
|
|
182
|
+
.wt-tab-label (see 30-tabs.css for why `ex` and why the padding is not
|
|
183
|
+
optional). */
|
|
184
|
+
text-box: trim-both ex alphabetic;
|
|
185
|
+
padding-block: 0.5em;
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
/* Keyboard button: hidden until a keyboardToggle is wired; filled while the key
|
|
@@ -208,6 +211,15 @@
|
|
|
208
211
|
overflow: hidden;
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
/* Hover wash like the switch and "+" buttons beside it; gated to real hover
|
|
215
|
+
pointers for the same reason they are (a sticky :hover after a tap). Before
|
|
216
|
+
.wt-active / .wt-armed so those fills win on a hovered button. */
|
|
217
|
+
@media (hover: hover) {
|
|
218
|
+
:where(.wt-root) .wt-switcher-kb:hover {
|
|
219
|
+
background: var(--tab-hover-bg);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
211
223
|
:where(.wt-root) .wt-switcher-kb[hidden] {
|
|
212
224
|
display: none;
|
|
213
225
|
}
|
|
@@ -420,8 +432,11 @@
|
|
|
420
432
|
overflow: hidden;
|
|
421
433
|
text-overflow: ellipsis;
|
|
422
434
|
white-space: nowrap;
|
|
423
|
-
/* Ink-centering trim
|
|
424
|
-
|
|
435
|
+
/* Ink-centering trim + its mandatory descender allowance, same as
|
|
436
|
+
.wt-tab-label (see 30-tabs.css for why `ex` and why the padding is not
|
|
437
|
+
optional). */
|
|
438
|
+
text-box: trim-both ex alphabetic;
|
|
439
|
+
padding-block: 0.5em;
|
|
425
440
|
}
|
|
426
441
|
|
|
427
442
|
/* The close sits flush at the row's right edge (no gap), vertically centered. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
"verify": "bash scripts/verify.sh"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@cplieger/web-terminal-engine": "^3.
|
|
41
|
+
"@cplieger/web-terminal-engine": "^3.2.0"
|
|
42
|
+
},
|
|
43
|
+
"overrides": {
|
|
44
|
+
"qs": "^6.15.2"
|
|
42
45
|
},
|
|
43
46
|
"devDependencies": {
|
|
44
47
|
"@eslint/js": "10.0.1",
|
|
@@ -50,9 +53,9 @@
|
|
|
50
53
|
"@vitest/coverage-v8": "4.1.10",
|
|
51
54
|
"eslint": "10.7.0",
|
|
52
55
|
"fast-check": "4.9.0",
|
|
53
|
-
"happy-dom": "20.11.
|
|
56
|
+
"happy-dom": "20.11.1",
|
|
54
57
|
"html-validate": "11.5.6",
|
|
55
|
-
"knip": "6.
|
|
58
|
+
"knip": "6.29.0",
|
|
56
59
|
"prettier": "3.9.6",
|
|
57
60
|
"stylelint": "17.14.1",
|
|
58
61
|
"stylelint-config-standard": "40.0.0",
|
|
@@ -38,19 +38,16 @@
|
|
|
38
38
|
|
|
39
39
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
40
40
|
import type { ClipboardApi } from "./clipboard.js";
|
|
41
|
-
import { placeMenuAt } from "./menu-position.js";
|
|
41
|
+
import { createClickSwallow, placeMenuAt } from "./menu-position.js";
|
|
42
42
|
|
|
43
43
|
// Slightly longer than the browser's ~500ms native long-press so a native word
|
|
44
44
|
// selection (which we defer to) has registered — and fired selectionchange —
|
|
45
45
|
// before this fires.
|
|
46
46
|
const LONG_PRESS_MS = 550;
|
|
47
47
|
const LONG_PRESS_MOVE_PX = 10;
|
|
48
|
-
// Viewport clamping
|
|
49
|
-
// point-anchored
|
|
50
|
-
//
|
|
51
|
-
// the same gesture emits on finger-release for this window, so the menu does not
|
|
52
|
-
// immediately dismiss itself (the classic contextmenu-then-touchend race).
|
|
53
|
-
const SWALLOW_MS = 350;
|
|
48
|
+
// Viewport clamping, the flip-above-the-fingertip gap, and the trailing-click
|
|
49
|
+
// swallow all live in the shared point-anchored menu module (menu-position.ts),
|
|
50
|
+
// shared with the tab menu.
|
|
54
51
|
|
|
55
52
|
export interface ContextMenuOptions {
|
|
56
53
|
/** The clipboard feature value, so the menu can offer Copy/Paste through its
|
|
@@ -98,8 +95,8 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
98
95
|
// Computed once: on an Apple touch device, defer a touch contextmenu to
|
|
99
96
|
// native text selection (see isAppleTouchDevice).
|
|
100
97
|
const appleTouch = isAppleTouchDevice();
|
|
101
|
-
//
|
|
102
|
-
|
|
98
|
+
// Swallows the trailing click a touch long-press emits on release.
|
|
99
|
+
const swallow = createClickSwallow();
|
|
103
100
|
// A native selection was made during the current touch press (tracked via
|
|
104
101
|
// selectionchange). Gates the touch menu: a selected long-press belongs to
|
|
105
102
|
// the native callout, an empty one to us (Paste).
|
|
@@ -212,9 +209,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
212
209
|
menu.classList.add("visible");
|
|
213
210
|
placeMenuAt(menu, x, y);
|
|
214
211
|
// A touch long-press is immediately followed by a synthetic click on
|
|
215
|
-
// release; swallow it so the menu stays open
|
|
212
|
+
// release; swallow it so the menu stays open.
|
|
216
213
|
if (touch) {
|
|
217
|
-
|
|
214
|
+
swallow.arm();
|
|
218
215
|
}
|
|
219
216
|
}
|
|
220
217
|
|
|
@@ -295,7 +292,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
295
292
|
|
|
296
293
|
const onDocClick = (): void => {
|
|
297
294
|
// Swallow the release click of the long-press that just opened the menu.
|
|
298
|
-
if (
|
|
295
|
+
if (swallow.swallowing()) {
|
|
299
296
|
return;
|
|
300
297
|
}
|
|
301
298
|
hide();
|
|
@@ -13,6 +13,35 @@ const EDGE_MARGIN = 8;
|
|
|
13
13
|
* hidden under the fingertip that long-pressed near the bottom edge. */
|
|
14
14
|
const FLIP_GAP = 16;
|
|
15
15
|
|
|
16
|
+
/** After a touch long-press opens a menu, the SAME gesture emits a click on
|
|
17
|
+
* finger-release, and an outside-click dismiss reads that click as "the user
|
|
18
|
+
* clicked away" — the classic contextmenu-then-touchend race. Swallow document
|
|
19
|
+
* clicks for this window after a touch-opened menu. 350ms comfortably covers
|
|
20
|
+
* the release without eating a deliberate follow-up tap. */
|
|
21
|
+
const SWALLOW_MS = 350;
|
|
22
|
+
|
|
23
|
+
/** createClickSwallow returns the two halves a point-anchored menu needs to
|
|
24
|
+
* survive the contextmenu-then-touchend race: `arm()` right after a TOUCH-opened
|
|
25
|
+
* menu becomes visible, and `swallowing()` as the first guard in the
|
|
26
|
+
* outside-click dismiss.
|
|
27
|
+
*
|
|
28
|
+
* It lives here, beside the shared placement math, because both point-anchored
|
|
29
|
+
* menus need it and a hand-synced second copy is what this module was extracted
|
|
30
|
+
* to stop. The tab menu shipped without the guard entirely, so on iPadOS Safari
|
|
31
|
+
* — which fires `contextmenu` on a long-press, and where the desktop tab strip
|
|
32
|
+
* is the chrome that shows — a long-pressed tab menu dismissed itself on
|
|
33
|
+
* release. Arm it only for touch: a mouse right-click emits no trailing click,
|
|
34
|
+
* and swallowing there would eat a genuine dismiss. */
|
|
35
|
+
export function createClickSwallow(): { arm: () => void; swallowing: () => boolean } {
|
|
36
|
+
let until = 0;
|
|
37
|
+
return {
|
|
38
|
+
arm: (): void => {
|
|
39
|
+
until = performance.now() + SWALLOW_MS;
|
|
40
|
+
},
|
|
41
|
+
swallowing: (): boolean => performance.now() < until,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
/** Place an already-visible `menu` at viewport point (x, y): open just below
|
|
17
46
|
* the point, flip above it when that would overflow the visible bottom, and
|
|
18
47
|
* clamp into the visual viewport on both axes. The menu is absolute-positioned
|