@cplieger/web-terminal-ui 5.4.0 → 5.4.2
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/css/30-tabs.css +0 -33
- package/package.json +4 -4
- package/src/features/tabs/index.ts +134 -227
- package/src/features/tabs/strip.ts +20 -20
package/css/30-tabs.css
CHANGED
|
@@ -495,39 +495,6 @@
|
|
|
495
495
|
}
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
/* ===== Pending drop edge (tabs feature, desktop drag) =====
|
|
499
|
-
The exact boundary the dragged tab will be inserted at, marked while the hold runs
|
|
500
|
-
and filling from nothing to full over the hold's own duration (the tabs feature
|
|
501
|
-
sets the duration inline from REORDER_DWELL_MS, so the bar and the timer cannot
|
|
502
|
-
drift apart).
|
|
503
|
-
|
|
504
|
-
It rides the neighbouring chip: the leading edge of the chip being pushed aside,
|
|
505
|
-
or the trailing edge of the last chip when the tab is heading past the end of the
|
|
506
|
-
strip (.wt-tab-dwell-end). The chip is already position: relative for its progress
|
|
507
|
-
bar, and its overflow clip keeps the rail inside the same rounded padding box.
|
|
508
|
-
|
|
509
|
-
3px of solid accent rather than a dashed treatment: the dashed vocabulary belongs
|
|
510
|
-
to the SLOT, which is a destination that has been decided. This is a boundary
|
|
511
|
-
still being chosen, so it reads as a caret. inset-block keeps it clear of the
|
|
512
|
-
chip's rounded corners without needing its own radius arithmetic. */
|
|
513
|
-
:where(.wt-root) .wt-tab-dwell {
|
|
514
|
-
position: absolute;
|
|
515
|
-
inset-block: var(--sp-1);
|
|
516
|
-
inset-inline-start: 0;
|
|
517
|
-
width: 3px;
|
|
518
|
-
border-radius: var(--radius-sm);
|
|
519
|
-
background: var(--accent);
|
|
520
|
-
/* Grows upward like a gauge. The feature writes scaleY inline; a transform on this
|
|
521
|
-
element cannot collide with the chip's own animations because it is a separate
|
|
522
|
-
element, which is the whole reason the rail is not a pseudo-element on the chip. */
|
|
523
|
-
transform-origin: bottom;
|
|
524
|
-
pointer-events: none;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
:where(.wt-root) .wt-tab-dwell-end {
|
|
528
|
-
inset-inline: auto 0;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
498
|
/* ===== Reorder drop slot (tabs feature, desktop drag) =====
|
|
532
499
|
The dragged chip stays in the strip's flow for the whole gesture and renders as
|
|
533
500
|
the SLOT it will land in: an empty dashed outline in the accent over a faint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.2",
|
|
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": "MPL-2.0",
|
|
@@ -50,19 +50,19 @@
|
|
|
50
50
|
"@stryker-mutator/core": "9.6.1",
|
|
51
51
|
"@stryker-mutator/typescript-checker": "9.6.1",
|
|
52
52
|
"@stryker-mutator/vitest-runner": "9.6.1",
|
|
53
|
-
"@types/node": "
|
|
53
|
+
"@types/node": "26.2.0",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
55
|
"@vitest/coverage-v8": "4.1.10",
|
|
56
56
|
"eslint": "10.8.1",
|
|
57
57
|
"fast-check": "4.9.0",
|
|
58
58
|
"happy-dom": "20.11.2",
|
|
59
59
|
"html-validate": "11.6.2",
|
|
60
|
-
"knip": "6.32.
|
|
60
|
+
"knip": "6.32.1",
|
|
61
61
|
"prettier": "3.9.6",
|
|
62
62
|
"stylelint": "17.14.1",
|
|
63
63
|
"stylelint-config-standard": "40.0.0",
|
|
64
64
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
65
|
-
"typescript-eslint": "8.
|
|
65
|
+
"typescript-eslint": "8.67.0",
|
|
66
66
|
"vitest": "4.1.10"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
@@ -43,8 +43,8 @@ import {
|
|
|
43
43
|
} from "./model.js";
|
|
44
44
|
import { browserNotifierEnv, createNotifier } from "./notify.js";
|
|
45
45
|
import {
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
REORDER_MOVE_EPS_PX,
|
|
47
|
+
REORDER_REST_MS,
|
|
48
48
|
REORDER_SETTLE_MS,
|
|
49
49
|
REORDER_SHIFT_TRANS,
|
|
50
50
|
REORDER_SLOT_FADE_MS,
|
|
@@ -666,27 +666,24 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
666
666
|
let activeId: string | null = null;
|
|
667
667
|
let draggingEl: HTMLElement | null = null;
|
|
668
668
|
// --- Desktop reorder preview state (mechanism below, near dragTargetBefore) ---
|
|
669
|
-
// The chips currently carrying an inline
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
// to hand every one of them back to the stylesheet.
|
|
669
|
+
// The chips currently carrying an inline displacement from the commit slide.
|
|
670
|
+
// A set plus one settle function, so whatever a slide wrote can always be handed
|
|
671
|
+
// back to the stylesheet — including when a second drag interrupts the first.
|
|
673
672
|
const shifted = new Set<HTMLElement>();
|
|
674
673
|
let shiftTimer: ReturnType<typeof setTimeout> | null = null;
|
|
675
|
-
// The
|
|
676
|
-
//
|
|
677
|
-
// value there meaning "past the last chip".
|
|
678
|
-
|
|
679
|
-
|
|
674
|
+
// The slot waiting to open and the rest window that will open it.
|
|
675
|
+
// `restTimer !== null` is the only "a slot is pending" flag — `restBefore` cannot
|
|
676
|
+
// be one, because null is a legitimate value there meaning "past the last chip".
|
|
677
|
+
// restX is the last pointer position seen, which is how movement is told from
|
|
678
|
+
// stillness (see trackRest).
|
|
679
|
+
let restTimer: ReturnType<typeof setTimeout> | null = null;
|
|
680
|
+
let restBefore: HTMLElement | null = null;
|
|
681
|
+
let restX: number | null = null;
|
|
680
682
|
// Whether a `drop` fired for the drag in flight. It is the exact signal for
|
|
681
683
|
// "the user released deliberately" as opposed to "the drag was abandoned":
|
|
682
684
|
// Escape and a refused release fire dragend with no drop at all. dragend
|
|
683
685
|
// without it reverts the preview, which is the cancel this reorder never had.
|
|
684
686
|
let dropped = false;
|
|
685
|
-
// The pending-drop rail: ONE element, moved to whichever edge the current hold
|
|
686
|
-
// is counting down on (see showDwellRail).
|
|
687
|
-
const dwellRail = document.createElement("span");
|
|
688
|
-
dwellRail.className = "wt-tab-dwell";
|
|
689
|
-
dwellRail.setAttribute("aria-hidden", "true");
|
|
690
687
|
// The chip mid slot-fade, and the timer that ends it.
|
|
691
688
|
let slotFadeEl: HTMLElement | null = null;
|
|
692
689
|
let slotFadeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -1826,7 +1823,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1826
1823
|
}
|
|
1827
1824
|
}
|
|
1828
1825
|
|
|
1829
|
-
// --- Desktop reorder preview:
|
|
1826
|
+
// --- Desktop reorder preview: sweep, rest, slide ------------------------
|
|
1830
1827
|
//
|
|
1831
1828
|
// The old reorder moved the dragged chip on EVERY dragover, so the strip
|
|
1832
1829
|
// rearranged itself continuously under the pointer and — every chip being
|
|
@@ -1834,22 +1831,30 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1834
1831
|
// in. The chip you were dragging looked like the slot it left, the slot it
|
|
1835
1832
|
// was over, and the slot it would end up in.
|
|
1836
1833
|
//
|
|
1837
|
-
// The
|
|
1838
|
-
// flow throughout as the slot it will land in
|
|
1839
|
-
// 30-tabs.css renders as an empty dashed outline
|
|
1840
|
-
// of the tab — the solid copy is the drag image
|
|
1841
|
-
// of the two had to stop pretending to be the tab):
|
|
1834
|
+
// The preview answers a MOVING pointer and a STOPPED one differently, and the
|
|
1835
|
+
// dragged chip stays in the flow throughout as the slot it will land in
|
|
1836
|
+
// (`.wt-tab-dragging`, which 30-tabs.css renders as an empty dashed outline
|
|
1837
|
+
// rather than a dimmed copy of the tab — the solid copy is the drag image
|
|
1838
|
+
// under the pointer, and one of the two had to stop pretending to be the tab):
|
|
1842
1839
|
//
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
1848
|
-
// DOM move, the displaced chips slide from their old positions to their
|
|
1849
|
-
// new ones, and the slot fades in at its new home.
|
|
1840
|
+
// - SWEEPING across the strip rearranges NOTHING. Crossing five tabs used to
|
|
1841
|
+
// move all five, which is the whole complaint.
|
|
1842
|
+
// - COMING TO REST opens the slot (trackRest -> commitSlot): one DOM move,
|
|
1843
|
+
// the displaced chips slide from their old positions to their new ones, and
|
|
1844
|
+
// the slot fades in at its new home.
|
|
1850
1845
|
//
|
|
1851
|
-
//
|
|
1852
|
-
//
|
|
1846
|
+
// Rest is DETECTED, not waited out: REORDER_REST_MS is re-armed by movement, so
|
|
1847
|
+
// it expires a rest window after the last MOVEMENT rather than a fixed time
|
|
1848
|
+
// after a decision. Two shapes were tried and removed before this one, and both
|
|
1849
|
+
// failures are worth keeping: a fixed one-second HOLD (wrong shape for a direct
|
|
1850
|
+
// manipulation — it delayed the one case that should be instant, and grew a
|
|
1851
|
+
// progress bar to explain the wait, which is the tell that the wait should not
|
|
1852
|
+
// have been there), and a sweep-time LEAN of the chips a commit would displace
|
|
1853
|
+
// (it read as a second, competing preview and its distance from the real move
|
|
1854
|
+
// was too small to tell them apart).
|
|
1855
|
+
//
|
|
1856
|
+
// A release never waits either: `drop` commits whatever slot is pending
|
|
1857
|
+
// (flushRest), so dropping mid-sweep still lands the tab where it was headed.
|
|
1853
1858
|
//
|
|
1854
1859
|
// dropTargetBefore returns the first tab whose horizontal midpoint is past
|
|
1855
1860
|
// x (the element the dragged tab should sit before), or null to drop at the
|
|
@@ -1859,8 +1864,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1859
1864
|
//
|
|
1860
1865
|
// It hit-tests LAYOUT geometry (offsetLeft/offsetWidth), never
|
|
1861
1866
|
// getBoundingClientRect. That is not an optimisation, it is what makes an
|
|
1862
|
-
// animated reorder hit-testable at all: a rect read while a chip is mid-
|
|
1863
|
-
//
|
|
1867
|
+
// animated reorder hit-testable at all: a rect read while a chip is mid-slide
|
|
1868
|
+
// returns the INTERPOLATED position, so the preview's own motion feeds
|
|
1864
1869
|
// back into the decision that produced it and the strip oscillates between two
|
|
1865
1870
|
// slots for as long as the pointer sits near a boundary. Layout offsets are the
|
|
1866
1871
|
// chip's position in the FLOW, which a transform does not affect at all.
|
|
@@ -1932,163 +1937,70 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1932
1937
|
shifted.clear();
|
|
1933
1938
|
}
|
|
1934
1939
|
|
|
1935
|
-
//
|
|
1936
|
-
// destination, for as long as the hold lasts. No settle timer: the lean is a
|
|
1937
|
-
// held state, not an animation with an end, and a timer that expired mid-hold
|
|
1938
|
-
// would snap it away while the user is still waiting on it.
|
|
1940
|
+
// trackRest is the whole gate, called on every dragover over the strip.
|
|
1939
1941
|
//
|
|
1940
|
-
//
|
|
1941
|
-
//
|
|
1942
|
-
//
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1942
|
+
// Sweeping and settling are DIFFERENT actions and get different answers. A
|
|
1943
|
+
// travelling pointer rearranges nothing at all; the moment it comes to REST the
|
|
1944
|
+
// slot opens.
|
|
1945
|
+
//
|
|
1946
|
+
// Rest is detected, not waited out: the timer is re-armed by MOVEMENT, so it
|
|
1947
|
+
// expires a rest window after the last movement rather than a fixed time after a
|
|
1948
|
+
// decision. The window has a hard FLOOR set by the platform rather than by taste —
|
|
1949
|
+
// HTML5 drag-and-drop only guarantees a `dragover` every 350ms, so a window at or
|
|
1950
|
+
// below that cadence expires between two events of a fast sweep and commits every
|
|
1951
|
+
// slot the pointer crosses (see REORDER_REST_MS).
|
|
1952
|
+
function trackRest(clientX: number): void {
|
|
1953
|
+
const dragged = draggingEl;
|
|
1954
|
+
if (!dragged) {
|
|
1952
1955
|
return;
|
|
1953
1956
|
}
|
|
1954
|
-
const
|
|
1955
|
-
|
|
1956
|
-
const
|
|
1957
|
-
|
|
1957
|
+
const moved = restX === null || Math.abs(clientX - restX) > REORDER_MOVE_EPS_PX;
|
|
1958
|
+
restX = clientX;
|
|
1959
|
+
const before = dropTargetBefore(clientX);
|
|
1960
|
+
// Already the slot the tab holds, so there is nothing to preview or commit.
|
|
1961
|
+
// Withdraw anything pending: the pointer came back.
|
|
1962
|
+
if (before === dragged || before === dragged.nextElementSibling) {
|
|
1963
|
+
cancelRest();
|
|
1958
1964
|
return;
|
|
1959
1965
|
}
|
|
1960
|
-
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
}
|
|
1969
|
-
for (const el of shifted) {
|
|
1970
|
-
if (!next.has(el)) {
|
|
1971
|
-
next.set(el, 0);
|
|
1966
|
+
restBefore = before;
|
|
1967
|
+
// Movement pushes the commit out; stillness lets the armed timer run down to it.
|
|
1968
|
+
// A stationary pointer that has nothing armed still needs one — a browser may
|
|
1969
|
+
// deliver dragover repeatedly at an unchanged position, and the first of those is
|
|
1970
|
+
// the only signal that the pointer arrived and stopped.
|
|
1971
|
+
if (moved || restTimer === null) {
|
|
1972
|
+
if (restTimer !== null) {
|
|
1973
|
+
clearTimeout(restTimer);
|
|
1972
1974
|
}
|
|
1975
|
+
restTimer = setTimeout(() => {
|
|
1976
|
+
restTimer = null;
|
|
1977
|
+
const target = restBefore;
|
|
1978
|
+
restBefore = null;
|
|
1979
|
+
commitSlot(target);
|
|
1980
|
+
}, REORDER_REST_MS);
|
|
1973
1981
|
}
|
|
1974
|
-
applyShift(next, REORDER_SHIFT_TRANS);
|
|
1975
|
-
}
|
|
1976
|
-
// releaseLean eases every leaning chip back to rest and settles it, for when
|
|
1977
|
-
// a candidate is withdrawn (the pointer came back to the committed slot, or
|
|
1978
|
-
// left the strip). endShift would be wrong here: it strips the transition in
|
|
1979
|
-
// the same write, so the lean would snap home.
|
|
1980
|
-
function releaseLean(): void {
|
|
1981
|
-
if (shifted.size === 0) {
|
|
1982
|
-
return;
|
|
1983
|
-
}
|
|
1984
|
-
// ...except under reduced motion, where snapping IS the requested behaviour and
|
|
1985
|
-
// easing back would animate the very motion the preference opted out of.
|
|
1986
|
-
if (prefersReduce()) {
|
|
1987
|
-
endShift();
|
|
1988
|
-
return;
|
|
1989
|
-
}
|
|
1990
|
-
const rest = new Map<HTMLElement, number>();
|
|
1991
|
-
for (const el of shifted) {
|
|
1992
|
-
rest.set(el, 0);
|
|
1993
|
-
}
|
|
1994
|
-
applyShift(rest, REORDER_SHIFT_TRANS);
|
|
1995
|
-
shiftTimer = setTimeout(endShift, REORDER_SETTLE_MS);
|
|
1996
1982
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
//
|
|
2000
|
-
// Without it the hold was a second of near-silence, and the silence was worse than
|
|
2001
|
-
// it sounds: the most authoritative thing on screen is the dashed slot, and the
|
|
2002
|
-
// slot sits at the tab's CURRENT position, so the strongest cue was describing the
|
|
2003
|
-
// state being left rather than the one being chosen. The 7px lean says which
|
|
2004
|
-
// DIRECTION the strip will open without saying which boundary, and nothing at all
|
|
2005
|
-
// said how much longer. The rail answers both of those, which makes the gesture
|
|
2006
|
-
// read as three ordered states: the tab under the pointer, the edge about to open,
|
|
2007
|
-
// then the gap that opened.
|
|
2008
|
-
//
|
|
2009
|
-
// The fill duration comes from REORDER_DWELL_MS, so the bar and the timer it
|
|
2010
|
-
// depicts cannot drift apart. That is also why the rail is a real element rather
|
|
2011
|
-
// than a pseudo-element on the chip: a pseudo-element's duration would have to
|
|
2012
|
-
// travel through a custom property, which is a second place to keep in step.
|
|
2013
|
-
function showDwellRail(before: HTMLElement | null): void {
|
|
2014
|
-
// The leading edge of the chip being pushed aside, or the trailing edge of the
|
|
2015
|
-
// last chip when the tab is heading past the end of the strip.
|
|
2016
|
-
const host = before ?? scroller.querySelector<HTMLElement>(".wt-tab:last-of-type");
|
|
2017
|
-
if (host === null || host === draggingEl) {
|
|
2018
|
-
hideDwellRail();
|
|
1983
|
+
function cancelRest(): void {
|
|
1984
|
+
if (restTimer === null) {
|
|
2019
1985
|
return;
|
|
2020
1986
|
}
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
// No countdown, but the boundary still has to be legible, so show it filled.
|
|
2025
|
-
dwellRail.style.transition = "none";
|
|
2026
|
-
dwellRail.style.transform = "scaleY(1)";
|
|
2027
|
-
return;
|
|
2028
|
-
}
|
|
2029
|
-
dwellRail.style.transition = "none";
|
|
2030
|
-
dwellRail.style.transform = "scaleY(0)";
|
|
2031
|
-
dwellRail.getBoundingClientRect(); // commit the from-state before the fill
|
|
2032
|
-
dwellRail.style.transition = `transform ${String(REORDER_DWELL_MS)}ms linear`;
|
|
2033
|
-
dwellRail.style.transform = "scaleY(1)";
|
|
1987
|
+
clearTimeout(restTimer);
|
|
1988
|
+
restTimer = null;
|
|
1989
|
+
restBefore = null;
|
|
2034
1990
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
// and idempotent for an unchanged candidate: restarting the timer on each
|
|
2042
|
-
// event would mean a hand that never holds perfectly still could never
|
|
2043
|
-
// commit anything, which is the bug a naive dwell always ships with.
|
|
2044
|
-
function armDwell(before: HTMLElement | null): void {
|
|
2045
|
-
if (!draggingEl) {
|
|
2046
|
-
return;
|
|
2047
|
-
}
|
|
2048
|
-
// Already the committed slot, so there is nothing to preview. Withdraw any
|
|
2049
|
-
// pending commitment — the pointer came back.
|
|
2050
|
-
if (before === draggingEl || before === draggingEl.nextElementSibling) {
|
|
2051
|
-
cancelDwell();
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2054
|
-
if (dwellTimer !== null && before === dwellBefore) {
|
|
2055
|
-
return; // same candidate, still holding
|
|
2056
|
-
}
|
|
2057
|
-
if (dwellTimer !== null) {
|
|
2058
|
-
clearTimeout(dwellTimer);
|
|
2059
|
-
}
|
|
2060
|
-
dwellBefore = before;
|
|
2061
|
-
leanToward(before);
|
|
2062
|
-
showDwellRail(before);
|
|
2063
|
-
dwellTimer = setTimeout(() => {
|
|
2064
|
-
dwellTimer = null;
|
|
2065
|
-
commitSlot(before);
|
|
2066
|
-
}, REORDER_DWELL_MS);
|
|
2067
|
-
}
|
|
2068
|
-
function cancelDwell(): void {
|
|
2069
|
-
hideDwellRail();
|
|
2070
|
-
if (dwellTimer === null) {
|
|
2071
|
-
return;
|
|
2072
|
-
}
|
|
2073
|
-
clearTimeout(dwellTimer);
|
|
2074
|
-
dwellTimer = null;
|
|
2075
|
-
dwellBefore = null;
|
|
2076
|
-
releaseLean();
|
|
2077
|
-
}
|
|
2078
|
-
// flushDwell commits a pending slot at once. A release is a decision, so a
|
|
2079
|
-
// drop does not have to wait out a hold it interrupted: this is what keeps a
|
|
2080
|
-
// quick drag-and-drop reordering exactly as it did before the preview
|
|
2081
|
-
// existed, and it is why the hold can afford to be a full second. Only a
|
|
2082
|
-
// `drop` calls it; an abandoned drag reaches dragend without one and reverts.
|
|
2083
|
-
function flushDwell(): void {
|
|
2084
|
-
if (dwellTimer === null) {
|
|
1991
|
+
// flushRest commits a pending slot at once, because a release is a decision. It is
|
|
1992
|
+
// what stops the rest window from ever being felt as a floor on the gesture: drop
|
|
1993
|
+
// mid-sweep and the tab still lands where it was headed. Only a `drop` calls it;
|
|
1994
|
+
// an abandoned drag reaches dragend without one and reverts.
|
|
1995
|
+
function flushRest(): void {
|
|
1996
|
+
if (restTimer === null) {
|
|
2085
1997
|
return;
|
|
2086
1998
|
}
|
|
2087
|
-
const
|
|
2088
|
-
clearTimeout(
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
commitSlot(
|
|
1999
|
+
const target = restBefore;
|
|
2000
|
+
clearTimeout(restTimer);
|
|
2001
|
+
restTimer = null;
|
|
2002
|
+
restBefore = null;
|
|
2003
|
+
commitSlot(target);
|
|
2092
2004
|
}
|
|
2093
2005
|
|
|
2094
2006
|
// flipTo runs a rearranging mutation and animates its result: every chip that
|
|
@@ -2108,18 +2020,18 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2108
2020
|
// belongs HERE and not only in the CSS, because these transitions are written
|
|
2109
2021
|
// inline and no stylesheet gate can reach them; the switcher's release reel
|
|
2110
2022
|
// guards its own call site the same way. Read live, so toggling the OS setting
|
|
2111
|
-
// mid-drag takes effect on the next commit
|
|
2023
|
+
// mid-drag takes effect on the very next commit.
|
|
2112
2024
|
if (prefersReduce()) {
|
|
2113
2025
|
endShift();
|
|
2114
2026
|
mutate();
|
|
2115
2027
|
return;
|
|
2116
2028
|
}
|
|
2117
|
-
// FIRST — where each chip is right now,
|
|
2118
|
-
//
|
|
2119
|
-
// release reel captures its live swipe preview the
|
|
2120
|
-
// same reason. Rects, not layout offsets: this is VISUAL
|
|
2121
|
-
// what the animation interpolates (hit-testing wants the
|
|
2122
|
-
// dropTargetBefore).
|
|
2029
|
+
// FIRST — where each chip is right now, MID-SLIDE INCLUDED, so a second commit
|
|
2030
|
+
// continues from wherever the first one got to instead of jumping back through
|
|
2031
|
+
// rest first. The switcher's release reel captures its live swipe preview the
|
|
2032
|
+
// same way, and for the same reason. Rects, not layout offsets: this is VISUAL
|
|
2033
|
+
// position, which is what the animation interpolates (hit-testing wants the
|
|
2034
|
+
// opposite, see dropTargetBefore).
|
|
2123
2035
|
const first = new Map<HTMLElement, number>();
|
|
2124
2036
|
for (const el of scroller.querySelectorAll<HTMLElement>(".wt-tab")) {
|
|
2125
2037
|
if (el !== hold) {
|
|
@@ -2164,31 +2076,26 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2164
2076
|
// an OSC title) renders the committed order rather than flickering through
|
|
2165
2077
|
// whatever the pointer is hovering.
|
|
2166
2078
|
//
|
|
2167
|
-
//
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
//
|
|
2079
|
+
// The caller nulls `restTimer` BEFORE calling in (both the rest timer and
|
|
2080
|
+
// flushRest do), so `cancelRest` is already a no-op by the time control arrives
|
|
2081
|
+
// here: an early return must leave nothing behind that only cancelRest would
|
|
2082
|
+
// Nothing exists pre-commit for it to have cleared, and a slide already in flight
|
|
2083
|
+
// is owned by its own settle timer.
|
|
2171
2084
|
function commitSlot(before: HTMLElement | null): void {
|
|
2172
|
-
// The countdown is over however this call ends: the rail's whole job was to
|
|
2173
|
-
// depict a hold that has now expired.
|
|
2174
|
-
hideDwellRail();
|
|
2175
2085
|
const dragged = draggingEl;
|
|
2176
2086
|
if (!dragged?.isConnected) {
|
|
2177
|
-
|
|
2178
|
-
return; // the dragged session was closed elsewhere mid-hold
|
|
2087
|
+
return; // the dragged session was closed elsewhere mid-gesture
|
|
2179
2088
|
}
|
|
2180
|
-
// The
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2183
|
-
//
|
|
2089
|
+
// The pending slot was chosen a rest window ago, and a session closed in another
|
|
2090
|
+
// window (an SSE push, or the poll reconcile) removes chips through dropTab while
|
|
2091
|
+
// this gesture is still open — so the reference node may be gone by now, and
|
|
2092
|
+
// insertBefore throws NotFoundError on a reference that is no longer a
|
|
2184
2093
|
// child, which would abandon the drop mid-way and leave DOM order and tabList
|
|
2185
2094
|
// disagreeing. null stays legal: it means "past the last chip".
|
|
2186
2095
|
if (before !== null && before.parentNode !== scroller) {
|
|
2187
|
-
releaseLean();
|
|
2188
2096
|
return;
|
|
2189
2097
|
}
|
|
2190
2098
|
if (before === dragged || before === dragged.nextElementSibling) {
|
|
2191
|
-
releaseLean();
|
|
2192
2099
|
return; // already in that slot
|
|
2193
2100
|
}
|
|
2194
2101
|
flipTo(() => {
|
|
@@ -2278,8 +2185,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2278
2185
|
}
|
|
2279
2186
|
}
|
|
2280
2187
|
|
|
2281
|
-
// endReorderPreview ends the GESTURE's state: the pending
|
|
2282
|
-
//
|
|
2188
|
+
// endReorderPreview ends the GESTURE's state: the pending rest and the slot's
|
|
2189
|
+
// fade.
|
|
2283
2190
|
//
|
|
2284
2191
|
// It deliberately does NOT stop a slide that is already playing. A slide is an
|
|
2285
2192
|
// animation with its own settle timer, and cutting it here is exactly what
|
|
@@ -2289,8 +2196,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2289
2196
|
// start from rest, and a torn-down feature may leave no inline style on an
|
|
2290
2197
|
// element it no longer owns.
|
|
2291
2198
|
function endReorderPreview(): void {
|
|
2292
|
-
|
|
2293
|
-
|
|
2199
|
+
cancelRest();
|
|
2200
|
+
restX = null;
|
|
2294
2201
|
endSlotFade();
|
|
2295
2202
|
}
|
|
2296
2203
|
|
|
@@ -2299,9 +2206,9 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2299
2206
|
// sweep). Two removals matter, for different reasons:
|
|
2300
2207
|
//
|
|
2301
2208
|
// - the pending reference chip: commitSlot would hand a detached node to
|
|
2302
|
-
// insertBefore. It guards that too, but
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2209
|
+
// insertBefore. It guards that too, but withdrawing the pending slot here means
|
|
2210
|
+
// a fresh dragover arms against a chip that still exists rather than waiting out
|
|
2211
|
+
// a rest window against one that does not.
|
|
2305
2212
|
// - the DRAGGED chip: the gesture is over and there is no source left to deliver
|
|
2306
2213
|
// a dragend. A browser is not obliged to fire one for a removed source, and
|
|
2307
2214
|
// without this the feature would keep `draggingEl` set to a detached node for
|
|
@@ -2309,8 +2216,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2309
2216
|
// in progress" and so would preventDefault every unrelated file or text drop on
|
|
2310
2217
|
// the page from then on.
|
|
2311
2218
|
function abortReorderFor(removed: HTMLElement): void {
|
|
2312
|
-
if (removed ===
|
|
2313
|
-
|
|
2219
|
+
if (removed === restBefore) {
|
|
2220
|
+
cancelRest();
|
|
2314
2221
|
}
|
|
2315
2222
|
if (removed !== draggingEl) {
|
|
2316
2223
|
return;
|
|
@@ -3360,9 +3267,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3360
3267
|
}
|
|
3361
3268
|
return false;
|
|
3362
3269
|
});
|
|
3363
|
-
//
|
|
3364
|
-
//
|
|
3365
|
-
// hold that commits it (see the reorder-preview block above).
|
|
3270
|
+
// Drive the reorder preview while dragging a tab over the strip. A sweep only
|
|
3271
|
+
// rearranges nothing; the slot opens when the pointer comes to rest (trackRest).
|
|
3366
3272
|
bar.addEventListener("dragover", (e) => {
|
|
3367
3273
|
if (!draggingEl) {
|
|
3368
3274
|
return;
|
|
@@ -3371,7 +3277,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3371
3277
|
if (e.dataTransfer) {
|
|
3372
3278
|
e.dataTransfer.dropEffect = "move";
|
|
3373
3279
|
}
|
|
3374
|
-
|
|
3280
|
+
trackRest(e.clientX);
|
|
3375
3281
|
});
|
|
3376
3282
|
// Commit on drop — and, load-bearing, CANCEL the drop's default action. An
|
|
3377
3283
|
// uncancelled drop leaves the browser free to act on the drag payload
|
|
@@ -3386,16 +3292,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3386
3292
|
}
|
|
3387
3293
|
e.preventDefault();
|
|
3388
3294
|
dropped = true;
|
|
3389
|
-
|
|
3295
|
+
flushRest(); // a release decides: never make a drop wait on the rest window
|
|
3390
3296
|
syncOrderFromDom();
|
|
3391
3297
|
announceMoved(moved); // the ONE completed-move announcement per gesture
|
|
3392
3298
|
});
|
|
3393
|
-
// Leaving the strip
|
|
3394
|
-
//
|
|
3395
|
-
//
|
|
3396
|
-
//
|
|
3397
|
-
//
|
|
3398
|
-
//
|
|
3299
|
+
// Leaving the strip withdraws a pending slot. The document dragover below does
|
|
3300
|
+
// this too and covers more ground, but it cannot cover the case that matters most
|
|
3301
|
+
// here: the strip is docked at the viewport EDGE, so a pointer can leave it by
|
|
3302
|
+
// leaving the window entirely, and then no other element in the document receives a
|
|
3303
|
+
// dragover to notice with. A pointer that leaves the window is also, by definition,
|
|
3304
|
+
// one that has stopped moving over the strip — so without this the rest window
|
|
3305
|
+
// would run down and open a slot the pointer had already abandoned.
|
|
3399
3306
|
//
|
|
3400
3307
|
// relatedTarget is what makes dragleave usable at all: it fires on every
|
|
3401
3308
|
// child-to-child transition inside the bar (chip to label, label to close), and
|
|
@@ -3408,7 +3315,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3408
3315
|
}
|
|
3409
3316
|
const next = e.relatedTarget;
|
|
3410
3317
|
if (next === null || !bar.contains(next as Node)) {
|
|
3411
|
-
|
|
3318
|
+
cancelRest();
|
|
3412
3319
|
}
|
|
3413
3320
|
});
|
|
3414
3321
|
// A tab released anywhere OTHER than the strip — over the terminal, over
|
|
@@ -3435,17 +3342,17 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
3435
3342
|
// refused release fell through to dragend and reverted. That distinction is
|
|
3436
3343
|
// invisible to the person doing the dragging.
|
|
3437
3344
|
//
|
|
3438
|
-
// So: leave `dropped` false,
|
|
3345
|
+
// So: leave `dropped` false, withdraw anything pending, and let dragend run the
|
|
3439
3346
|
// revert. Nothing is lost silently — the revert animates and announces, so a
|
|
3440
3347
|
// mis-release reads as "that did not take" rather than as a mystery.
|
|
3441
|
-
|
|
3348
|
+
cancelRest();
|
|
3442
3349
|
return;
|
|
3443
3350
|
}
|
|
3444
|
-
// dragover, off the strip: no candidate slot exists out here, so
|
|
3445
|
-
// pending
|
|
3446
|
-
// has already left. A slot already COMMITTED stays put; the pointer may yet
|
|
3447
|
-
//
|
|
3448
|
-
|
|
3351
|
+
// dragover, off the strip: no candidate slot exists out here, so withdraw a
|
|
3352
|
+
// pending one rather than let the rest window open a gap for a target the pointer
|
|
3353
|
+
// has already left. A slot already COMMITTED stays put; the pointer may yet come
|
|
3354
|
+
// back onto the strip and release there.
|
|
3355
|
+
cancelRest();
|
|
3449
3356
|
};
|
|
3450
3357
|
document.addEventListener("dragover", onDocTabDrop);
|
|
3451
3358
|
document.addEventListener("drop", onDocTabDrop);
|
|
@@ -21,29 +21,29 @@ import { statusPhrase, statusRevealsDot } from "./model.js";
|
|
|
21
21
|
// motion is spelled in — index.ts owns the mechanism, strip.ts owns the values,
|
|
22
22
|
// the same split switcher.ts keeps for the mobile swipe.
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
* opens
|
|
24
|
+
/** The quiet window (ms) that means the pointer has STOPPED. When it elapses with no
|
|
25
|
+
* movement, the strip opens the slot the pointer is over.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* mouse at the right millisecond. A hold is a deliberate act, so the strip only
|
|
31
|
-
* rearranges where the pointer actually rests.
|
|
27
|
+
* This is a rest detector, not a hold: it is re-armed by MOVEMENT, so it expires a
|
|
28
|
+
* rest window after the last movement rather than a fixed time after a decision.
|
|
29
|
+
* Sweeping the strip rearranges nothing; stopping is what commits.
|
|
32
30
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/** How far (px) each chip a commit would displace leans toward its destination
|
|
40
|
-
* while the hold runs.
|
|
31
|
+
* **The floor is set by the drag loop, not by taste.** HTML5 drag-and-drop only
|
|
32
|
+
* guarantees a `dragover` every 350ms — the processing model runs on that cadence,
|
|
33
|
+
* not per mouse movement — so any window at or below it expires BETWEEN two events
|
|
34
|
+
* of a fast sweep, and the strip commits every slot the pointer crosses. That is
|
|
35
|
+
* exactly what 120ms did in production: a quick pass over five tabs moved all five.
|
|
36
|
+
* 500ms clears the cadence with margin and is the reason this number is not smaller.
|
|
41
37
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
* It costs a decisive user nothing, which is what makes the margin affordable: a
|
|
39
|
+
* release commits whatever slot is pending at once (flushRest), so the window is only
|
|
40
|
+
* ever felt by a pointer that has stopped and is waiting to see the gap open. */
|
|
41
|
+
export const REORDER_REST_MS = 500;
|
|
42
|
+
|
|
43
|
+
/** Movement (px) between two dragover events below which the pointer counts as still.
|
|
44
|
+
* A hand resting on a mouse is never perfectly still, and treating a 1px tremor as a
|
|
45
|
+
* sweep would keep pushing the commit out for as long as someone held the tab. */
|
|
46
|
+
export const REORDER_MOVE_EPS_PX = 3;
|
|
47
47
|
|
|
48
48
|
/** The one transition both preview stages use (the lean, and the slide that
|
|
49
49
|
* commits it).
|