@cplieger/web-terminal-ui 7.3.2 → 8.0.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 +114 -18
- package/css/30-tabs.css +21 -0
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// reuses, the strip tab template, the shared control markup factories, the
|
|
4
|
-
// status-dot painter, and the required-descendant picker. Everything here is
|
|
5
|
-
// closure-free markup + tiny pure helpers; element construction with event wiring
|
|
6
|
-
// stays in index.ts (it closes over the feature state). The mobile chrome
|
|
7
|
-
// templates and its gesture constants live in switcher.ts; the session model in
|
|
8
|
-
// model.ts.
|
|
1
|
+
// Closure-free markup and pure helpers only; element construction with event
|
|
2
|
+
// wiring stays in index.ts, where it closes over the feature state.
|
|
9
3
|
|
|
10
4
|
// The status vocabulary itself (which statuses reveal a dot, and how each one is
|
|
11
5
|
// worded for a human) lives in the DOM-free model, so the painters here and the
|
|
12
6
|
// accessible names index.ts builds read one definition.
|
|
13
7
|
import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } from "./model.js";
|
|
14
8
|
|
|
15
|
-
// --- Reorder preview -------------------------------------------------------
|
|
16
9
|
// Every chip in this strip is the same width (see .wt-tab in 30-tabs.css: a
|
|
17
10
|
// definite 300px with flex-grow 0, so a title changes the label and never the
|
|
18
11
|
// box). That is what makes a reorder hard to read: the chip a release would
|
|
@@ -22,33 +15,18 @@ import { activityPhrase, normalizeActivity, statusPhrase, statusRevealsDot } fro
|
|
|
22
15
|
// the same split switcher.ts keeps for the mobile swipe.
|
|
23
16
|
|
|
24
17
|
/** How long (ms) the pointer must have been un-moved before a stationary `dragover` is
|
|
25
|
-
* believed as "stopped".
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* evidence than the absence of events, which is all the fallback below has. The drag
|
|
30
|
-
* loop keeps delivering events while the pointer is held still, so a genuinely stopped
|
|
31
|
-
* pointer announces itself; this only filters the coincidence where one event of a
|
|
32
|
-
* sweep happens to land within REORDER_MOVE_EPS_PX of the previous one (a direction
|
|
33
|
-
* reversal, or a frame where the motion was almost entirely vertical). */
|
|
18
|
+
* believed as "stopped". Small, because a `dragover` at an UNCHANGED position is
|
|
19
|
+
* positive evidence of rest (the drag loop keeps delivering events while the pointer
|
|
20
|
+
* is held still); this only filters the coincidence where one event of a sweep lands
|
|
21
|
+
* within REORDER_MOVE_EPS_PX of the previous one. */
|
|
34
22
|
export const REORDER_STILL_MS = 50;
|
|
35
23
|
|
|
36
24
|
/** Fallback guard (ms): commit the pending slot this long after the last MOVEMENT, for
|
|
37
|
-
* the case where `dragover` stops arriving altogether
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* loop runs on that cadence, not per mouse movement — so a guard at or below it can
|
|
43
|
-
* expire BETWEEN two events of a fast sweep, and the strip then commits every slot the
|
|
44
|
-
* pointer crosses. Measured in production: at 120ms a quick pass over five tabs moved
|
|
45
|
-
* all five.
|
|
46
|
-
*
|
|
47
|
-
* A pure quiet-window design has to make this ONE number carry both jobs, which is why
|
|
48
|
-
* it could not be shortened — the same timer that decides responsiveness was the one
|
|
49
|
-
* that had to out-wait the cadence, so 500ms was safe and felt slow. Splitting rest
|
|
50
|
-
* DETECTION (the stationary event above) from the no-events NET (here) lets each be
|
|
51
|
-
* sized for its own job. This one is rarely the deciding signal at all. */
|
|
25
|
+
* the case where `dragover` stops arriving altogether. Its floor is the platform's:
|
|
26
|
+
* HTML5 drag-and-drop only guarantees a `dragover` every 350ms, so a guard at or
|
|
27
|
+
* below it can expire BETWEEN two events of a fast sweep and commit every slot the
|
|
28
|
+
* pointer crosses (measured: at 120ms a quick pass over five tabs moved all five).
|
|
29
|
+
* Rest DETECTION is the stationary event above, so this net can stay slow. */
|
|
52
30
|
export const REORDER_REST_MS = 450;
|
|
53
31
|
|
|
54
32
|
/** Movement (px) between two dragover events below which the pointer counts as still.
|
|
@@ -57,19 +35,12 @@ export const REORDER_REST_MS = 450;
|
|
|
57
35
|
export const REORDER_MOVE_EPS_PX = 3;
|
|
58
36
|
|
|
59
37
|
/** The one transition both preview stages use (the lean, and the slide that
|
|
60
|
-
* commits it)
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* The property is `translate`, NOT `transform`, and that is load-bearing rather
|
|
68
|
-
* than stylistic. Declarations from a running CSS animation out-rank every normal
|
|
69
|
-
* author declaration, inline style included, so a chip in the middle of
|
|
70
|
-
* `wt-slot-in` or `wt-tab-in` (both animate `transform: scale`) would ignore an
|
|
71
|
-
* inline `transform` outright and refuse to move. `translate` is a separate
|
|
72
|
-
* property that composes with `transform` instead of competing for it. */
|
|
38
|
+
* commits it): --dur-standard and --ease-standard as literals, since JS-driven
|
|
39
|
+
* motion here reads no token back out of the cascade (mirror a token edit by
|
|
40
|
+
* hand). The property is `translate`, NOT `transform`: declarations from a
|
|
41
|
+
* running CSS animation out-rank inline style, so a chip mid `wt-slot-in` or
|
|
42
|
+
* `wt-tab-in` (both animate `transform: scale`) would ignore an inline
|
|
43
|
+
* `transform`; `translate` composes with it instead of competing for it. */
|
|
73
44
|
export const REORDER_SHIFT_TRANS = "translate 0.2s cubic-bezier(0.2, 0, 0, 1)";
|
|
74
45
|
|
|
75
46
|
/** When (ms after the slide starts) the inline transform and transition come off
|
|
@@ -103,31 +74,17 @@ const KB_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" wi
|
|
|
103
74
|
// background-tab notification dot rides on it (see switchButtonHTML). Same
|
|
104
75
|
// viewBox + stroke=currentcolor treatment as the others.
|
|
105
76
|
const SWITCH_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="13" height="13" rx="2"/><path d="M8 8V6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3"/></svg>`;
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// site drops into its own structure.
|
|
118
|
-
//
|
|
119
|
-
// The progress bar rides in the dotLabel fragment rather than taking a per-site
|
|
120
|
-
// class: it is positioned against the chip, not laid out in its flex row, so one
|
|
121
|
-
// class (.wt-progress-bar) styles it everywhere and every chip site gets it for
|
|
122
|
-
// free. It starts `hidden` — a session with no percentage must render NO bar
|
|
123
|
-
// (paintProgress).
|
|
124
|
-
//
|
|
125
|
-
// The secondary activity mark (.wt-activity-mark) rides here for the same reason
|
|
126
|
-
// and takes no per-site class either. It carries NO `hidden` attribute, unlike
|
|
127
|
-
// the bar: `hidden` is display:none, and an element out of layout cannot animate
|
|
128
|
-
// into it. Absence is the absence of `data-activity`, which the CSS answers by
|
|
129
|
-
// cancelling the mark's own footprint (see 30-tabs.css), so a chip with no
|
|
130
|
-
// secondary activity measures exactly as it did before this element existed.
|
|
77
|
+
// Two rectangles side by side, the same glyph whether the split is open or
|
|
78
|
+
// closed: the button's state is its aria-expanded, not its icon.
|
|
79
|
+
const SPLIT_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="8" height="14" rx="2"/><rect x="13" y="5" width="8" height="14" rx="2"/></svg>`;
|
|
80
|
+
|
|
81
|
+
/** The ONE builder for a tab chip's content (status dot, label, progress bar,
|
|
82
|
+
* close), shared by the strip chip, the mobile active row and each mobile list
|
|
83
|
+
* row. Two fragments, because the mobile chips place the close as a SIBLING of
|
|
84
|
+
* their button (a button cannot nest in a button). The bar starts `hidden` (no
|
|
85
|
+
* percentage renders no bar); the activity mark carries no `hidden`, since an
|
|
86
|
+
* element out of layout cannot animate into it, and its absence is the absence
|
|
87
|
+
* of `data-activity`, which the CSS answers by cancelling its footprint. */
|
|
131
88
|
export function chipContent(v: { dot: string; label: string; close: string; closeAttr?: string }): {
|
|
132
89
|
dotLabel: string;
|
|
133
90
|
close: string;
|
|
@@ -152,6 +109,12 @@ export function newButtonHTML(cls: string): string {
|
|
|
152
109
|
export function kbButtonHTML(cls: string): string {
|
|
153
110
|
return `<button type="button" class="${cls}" aria-label="Keyboard keys" aria-expanded="false" hidden>${KB_SVG}</button>`;
|
|
154
111
|
}
|
|
112
|
+
/** The split-view toggle: a stable label with `aria-expanded` as its state, the
|
|
113
|
+
* keyboard button's pattern, so a screen reader hears the change on the focused
|
|
114
|
+
* button after a keyboard toggle. */
|
|
115
|
+
export function splitButtonHTML(cls: string): string {
|
|
116
|
+
return `<button type="button" class="${cls}" aria-label="Split view" aria-expanded="false">${SPLIT_SVG}</button>`;
|
|
117
|
+
}
|
|
155
118
|
// The mobile switcher's dedicated open/close button. It toggles the tab list and
|
|
156
119
|
// carries a latest-wins notification dot (a child span) — amber when a background
|
|
157
120
|
// terminal needs input, green when a background turn finished — cleared when the
|
|
@@ -180,22 +143,12 @@ export const TAB_HTML = `
|
|
|
180
143
|
${TAB_CHIP.close}
|
|
181
144
|
</div>`;
|
|
182
145
|
|
|
183
|
-
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* same source the tab's accessible name uses, so hover text and announced text
|
|
190
|
-
* cannot drift.
|
|
191
|
-
*
|
|
192
|
-
* Visibility: the dot is hidden by default and shown once the session has
|
|
193
|
-
* reported activity (OSC 9;4 progress or a classified OSC 9 notification), so a
|
|
194
|
-
* plain shell's tabs stay clean and label-only while an agent's light up. The
|
|
195
|
-
* reveal is FLOORED by the status itself (statusRevealsDot) for the states that
|
|
196
|
-
* are self-evidently news — a plain shell that crashes never reported activity
|
|
197
|
-
* in its life, and hiding its red dot would hide the only signal it ever
|
|
198
|
-
* produced. */
|
|
146
|
+
/** A status dot's three orthogonal bits: data-status drives its appearance, the
|
|
147
|
+
* .wt-reports class its visibility, and `title` a tooltip worded by statusPhrase,
|
|
148
|
+
* the source the tab's accessible name uses, so hover and announced text cannot
|
|
149
|
+
* drift. Hidden until the session reports activity, so a plain shell's tabs stay
|
|
150
|
+
* label-only; FLOORED by the status itself for the states that are news on their
|
|
151
|
+
* own, since a plain shell that crashes never reported activity in its life. */
|
|
199
152
|
export function paintStatusDot(el: HTMLElement, status: string, reports: boolean): void {
|
|
200
153
|
const value = status || "idle";
|
|
201
154
|
el.dataset["status"] = value;
|
|
@@ -203,16 +156,11 @@ export function paintStatusDot(el: HTMLElement, status: string, reports: boolean
|
|
|
203
156
|
el.title = statusPhrase(value);
|
|
204
157
|
}
|
|
205
158
|
|
|
206
|
-
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* The reveal is the mark's OWN state, deliberately not the status dot's
|
|
213
|
-
* reportsActivity gate: a background run is a fact about the session that is
|
|
214
|
-
* independent of whether its program ever spoke OSC 9, which is the whole point
|
|
215
|
-
* of a second mark. */
|
|
159
|
+
/** The secondary mark's two bits: data-activity drives its appearance and `title`
|
|
160
|
+
* a tooltip worded by activityPhrase. No state REMOVES the attribute, which is
|
|
161
|
+
* what collapses the mark's footprint to zero. The reveal is the mark's OWN
|
|
162
|
+
* state, not the status dot's reportsActivity gate: a background run is a fact
|
|
163
|
+
* independent of whether the program ever spoke OSC 9. */
|
|
216
164
|
export function paintActivityMark(el: HTMLElement, state: string, count: number): void {
|
|
217
165
|
const phrase = activityPhrase(state, count);
|
|
218
166
|
if (phrase === "") {
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the release reel) stays in index.ts: it mutates drag state shared with the
|
|
5
|
-
// switch/expand paths, so extracting it would spread one interaction across two
|
|
6
|
-
// files. The desktop chrome vocabulary lives in strip.ts; the session model in
|
|
7
|
-
// model.ts.
|
|
1
|
+
// The swipe state machine (axis lock, live preview, the release reel) stays in
|
|
2
|
+
// index.ts: it mutates drag state shared with the switch and expand paths, so
|
|
3
|
+
// extracting it would spread one interaction across two files.
|
|
8
4
|
|
|
5
|
+
import { windowOf } from "../../kernel/realm.js";
|
|
9
6
|
import { chipContent } from "./strip.js";
|
|
10
7
|
|
|
11
8
|
// Swipe recognition on the mobile switcher bar: a mostly-horizontal drag past
|
|
@@ -31,24 +28,11 @@ export const VELOCITY_STALE_MS = 32;
|
|
|
31
28
|
export const PREVIEW_DRAG_RATIO = 0.1;
|
|
32
29
|
export const PREVIEW_PEEK_MAX = 10;
|
|
33
30
|
|
|
34
|
-
// Mobile bottom bar
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// up and the
|
|
38
|
-
//
|
|
39
|
-
// active row. This replaces the old separate modal overview sheet ("one element"
|
|
40
|
-
// per the user): the bar itself lifts rather than opening a distinct surface.
|
|
41
|
-
// The bar is the FIRST child and the list the SECOND: the switcher is bottom-
|
|
42
|
-
// anchored, so a column with the list last grows the container upward, lifting
|
|
43
|
-
// the bar and revealing the list below it (DOM order = visual order top-to-bottom).
|
|
44
|
-
// - .wt-switcher-current-wrap: the active-tab row — a select/swipe surface
|
|
45
|
-
// (.wt-switcher-current with dot + label) plus a close (x) overlaid at the
|
|
46
|
-
// right, mirroring the listed rows. No "n / m" counter: the list below is a
|
|
47
|
-
// rotating circular queue, so an absolute position number is meaningless.
|
|
48
|
-
// - The keyboard button (.wt-switcher-kb, opens the key grid above the bar,
|
|
49
|
-
// only wired + shown when a keyboardToggle feature is provided) and the
|
|
50
|
-
// accent "+" (.wt-switcher-new, spawns a terminal) are appended to the bar
|
|
51
|
-
// row from the shared factories in index.ts (see makeKbButton / makeNewButton).
|
|
31
|
+
// Mobile bottom bar: one element, the always-visible bar row (active tab as a
|
|
32
|
+
// tap/swipe surface, keyboard, "+") FIRST and the list of the other tabs SECOND.
|
|
33
|
+
// The switcher is bottom-anchored, so a column with the list last grows upward
|
|
34
|
+
// on swipe-up and lifts the bar to reveal the list beneath it. No "n / m"
|
|
35
|
+
// counter: the list is a rotating circular queue, so a position is meaningless.
|
|
52
36
|
const CURRENT_CHIP = chipContent({
|
|
53
37
|
dot: "wt-switcher-dot",
|
|
54
38
|
label: "wt-switcher-label",
|
|
@@ -85,45 +69,83 @@ export const SWITCHER_ROW_HTML = `
|
|
|
85
69
|
// the row's own max-height grows from 0 (fading in) on add, and collapses
|
|
86
70
|
// to 0 (fading out, then removed) on close. The flex list's height follows
|
|
87
71
|
// the row, so adding/closing a tab animates the tray height rather than
|
|
88
|
-
// snapping. Inline-driven (cleared when done); the caller gates motion
|
|
72
|
+
// snapping. Inline-driven (cleared when done); the caller gates motion, and
|
|
73
|
+
// its `signal` ends a motion still running when the owner goes away.
|
|
89
74
|
const ROW_ANIM_MS = 220;
|
|
90
75
|
const ROW_ANIM_EASE = "cubic-bezier(0.2, 0, 0, 1)";
|
|
91
76
|
|
|
92
|
-
|
|
77
|
+
/** The frame that writes the "to" state and the timer that ends the motion,
|
|
78
|
+
* both cancelled by `signal`. The timer also cancels the frame: a background
|
|
79
|
+
* tab suspends frames while timers run, and a frame released after `finish()`
|
|
80
|
+
* would write the "to" state onto a row nobody owns any more. A motion that
|
|
81
|
+
* finished releases its abort listener so a long-lived signal collects nothing. */
|
|
82
|
+
function rowMotion(
|
|
83
|
+
row: HTMLElement,
|
|
84
|
+
signal: AbortSignal,
|
|
85
|
+
toState: () => void,
|
|
86
|
+
finish: () => void,
|
|
87
|
+
): void {
|
|
88
|
+
const win = windowOf(row.ownerDocument);
|
|
89
|
+
const frame = win.requestAnimationFrame(toState);
|
|
90
|
+
const timer = win.setTimeout(() => {
|
|
91
|
+
win.cancelAnimationFrame(frame);
|
|
92
|
+
signal.removeEventListener("abort", cancel);
|
|
93
|
+
finish();
|
|
94
|
+
}, ROW_ANIM_MS + 60);
|
|
95
|
+
const cancel = (): void => {
|
|
96
|
+
win.cancelAnimationFrame(frame);
|
|
97
|
+
win.clearTimeout(timer);
|
|
98
|
+
};
|
|
99
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function animateRowIn(row: HTMLElement, signal: AbortSignal): void {
|
|
93
103
|
const h = row.getBoundingClientRect().height;
|
|
94
|
-
if (h <= 0) {
|
|
104
|
+
if (h <= 0 || signal.aborted) {
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
row.style.overflow = "hidden";
|
|
98
108
|
row.style.transition = "none";
|
|
99
109
|
row.style.maxHeight = "0";
|
|
100
110
|
row.style.opacity = "0";
|
|
101
|
-
|
|
102
|
-
row
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
rowMotion(
|
|
112
|
+
row,
|
|
113
|
+
signal,
|
|
114
|
+
() => {
|
|
115
|
+
row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
|
|
116
|
+
row.style.maxHeight = `${String(Math.ceil(h))}px`;
|
|
117
|
+
row.style.opacity = "1";
|
|
118
|
+
},
|
|
119
|
+
() => {
|
|
120
|
+
row.style.transition = "";
|
|
121
|
+
row.style.maxHeight = "";
|
|
122
|
+
row.style.opacity = "";
|
|
123
|
+
row.style.overflow = "";
|
|
124
|
+
},
|
|
125
|
+
);
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
export function animateRowOut(row: HTMLElement): void {
|
|
128
|
+
export function animateRowOut(row: HTMLElement, signal: AbortSignal): void {
|
|
129
|
+
if (signal.aborted) {
|
|
130
|
+
row.remove();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
115
133
|
const h = row.getBoundingClientRect().height;
|
|
116
134
|
row.style.overflow = "hidden";
|
|
117
135
|
row.style.pointerEvents = "none";
|
|
118
136
|
row.style.transition = "none";
|
|
119
137
|
row.style.maxHeight = `${String(Math.ceil(h))}px`;
|
|
120
138
|
row.style.opacity = "1";
|
|
121
|
-
|
|
122
|
-
row
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
139
|
+
rowMotion(
|
|
140
|
+
row,
|
|
141
|
+
signal,
|
|
142
|
+
() => {
|
|
143
|
+
row.style.transition = `max-height ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}, opacity ${String(ROW_ANIM_MS)}ms ${ROW_ANIM_EASE}`;
|
|
144
|
+
row.style.maxHeight = "0";
|
|
145
|
+
row.style.opacity = "0";
|
|
146
|
+
},
|
|
147
|
+
() => {
|
|
148
|
+
row.remove();
|
|
149
|
+
},
|
|
150
|
+
);
|
|
129
151
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
// @cplieger/web-terminal-ui — the reference touch-first browser UI built on the
|
|
2
|
-
// @cplieger/web-terminal-engine engine.
|
|
3
|
-
//
|
|
4
|
-
// v3 entry: createTerminal(root, { features }) builds a small kernel (display
|
|
5
|
-
// output, hidden textarea, IME, engine wiring, input funnel, connection-state,
|
|
6
|
-
// layout regions) plus opt-in feature modules. Feature bundles live at
|
|
7
|
-
// "@cplieger/web-terminal-ui/presets" (presetSingle / presetTouch /
|
|
8
|
-
// presetTabbed); a consumer that hand-picks features imports them individually.
|
|
9
|
-
//
|
|
10
|
-
// A consumer who wants a different UI depends on @cplieger/web-terminal-engine
|
|
11
|
-
// directly and wires the engine's render/scroll/connection/keyboard modules to
|
|
12
|
-
// their own DOM.
|
|
13
|
-
|
|
14
1
|
export { createTerminal } from "./kernel/kernel.js";
|
|
15
2
|
export { localScrollbackStorage } from "./kernel/scrollback-storage.js";
|
|
16
3
|
export type { LocalScrollbackStorageOptions } from "./kernel/scrollback-storage.js";
|
|
@@ -26,6 +13,19 @@ export type {
|
|
|
26
13
|
PersistedScrollback,
|
|
27
14
|
ScrollbackPersistence,
|
|
28
15
|
SessionOwnerRegistration,
|
|
16
|
+
PaneLayoutOwnerRegistration,
|
|
17
|
+
PaneSide,
|
|
18
|
+
PaneHandle,
|
|
19
|
+
ShellContext,
|
|
20
|
+
AttentionOptions,
|
|
21
|
+
AttentionState,
|
|
22
|
+
AttentionReporter,
|
|
23
|
+
NotificationEvent,
|
|
24
|
+
NotificationView,
|
|
25
|
+
Notifier,
|
|
26
|
+
SplitState,
|
|
27
|
+
SplitController,
|
|
28
|
+
ModeReaders,
|
|
29
29
|
TerminalContext,
|
|
30
30
|
TerminalEvents,
|
|
31
31
|
RegionName,
|
package/src/kernel/a11y.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// Kernel-owned accessibility primitives
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Kernel-owned accessibility primitives: one polite and one assertive live
|
|
2
|
+
// region so features never spawn competing aria-live regions, and one
|
|
3
|
+
// tablist/tabpanel controller wired on the kernel's output surface so `tabs`
|
|
4
|
+
// gets the ARIA seam without crossing into kernel-owned DOM.
|
|
5
5
|
|
|
6
|
-
import type { TablistController, TabHandle } from "./types.js";
|
|
6
|
+
import type { PaneSide, TablistController, TabHandle } from "./types.js";
|
|
7
|
+
import { windowOf } from "./realm.js";
|
|
7
8
|
|
|
8
9
|
/** Visually-hidden style applied inline so it holds without external CSS
|
|
9
10
|
* (tests run without the stylesheet). Standard sr-only clip technique. */
|
|
@@ -32,11 +33,13 @@ const REANNOUNCE_DELAY_MS = 100;
|
|
|
32
33
|
|
|
33
34
|
/** Build the single pair of live regions inside root. */
|
|
34
35
|
export function createAnnouncer(root: HTMLElement): Announcer {
|
|
35
|
-
const
|
|
36
|
+
const doc = root.ownerDocument;
|
|
37
|
+
const win = windowOf(doc);
|
|
38
|
+
const polite = doc.createElement("div");
|
|
36
39
|
polite.setAttribute("aria-live", "polite");
|
|
37
40
|
polite.setAttribute("aria-atomic", "true");
|
|
38
41
|
hideVisually(polite);
|
|
39
|
-
const assertive =
|
|
42
|
+
const assertive = doc.createElement("div");
|
|
40
43
|
assertive.setAttribute("aria-live", "assertive");
|
|
41
44
|
assertive.setAttribute("aria-atomic", "true");
|
|
42
45
|
hideVisually(assertive);
|
|
@@ -44,7 +47,7 @@ export function createAnnouncer(root: HTMLElement): Announcer {
|
|
|
44
47
|
|
|
45
48
|
// Pending re-set timers, one per region, so a rapid follow-up announcement
|
|
46
49
|
// replaces the pending one instead of interleaving with it.
|
|
47
|
-
const timers = new Map<HTMLElement,
|
|
50
|
+
const timers = new Map<HTMLElement, number>();
|
|
48
51
|
|
|
49
52
|
function announce(message: string, politeness: "polite" | "assertive" = "polite"): void {
|
|
50
53
|
const el = politeness === "assertive" ? assertive : polite;
|
|
@@ -57,11 +60,11 @@ export function createAnnouncer(root: HTMLElement): Announcer {
|
|
|
57
60
|
el.textContent = "";
|
|
58
61
|
const pending = timers.get(el);
|
|
59
62
|
if (pending !== undefined) {
|
|
60
|
-
clearTimeout(pending);
|
|
63
|
+
win.clearTimeout(pending);
|
|
61
64
|
}
|
|
62
65
|
timers.set(
|
|
63
66
|
el,
|
|
64
|
-
setTimeout(() => {
|
|
67
|
+
win.setTimeout(() => {
|
|
65
68
|
timers.delete(el);
|
|
66
69
|
el.textContent = message;
|
|
67
70
|
}, REANNOUNCE_DELAY_MS),
|
|
@@ -70,7 +73,7 @@ export function createAnnouncer(root: HTMLElement): Announcer {
|
|
|
70
73
|
|
|
71
74
|
function destroy(): void {
|
|
72
75
|
for (const t of timers.values()) {
|
|
73
|
-
clearTimeout(t);
|
|
76
|
+
win.clearTimeout(t);
|
|
74
77
|
}
|
|
75
78
|
timers.clear();
|
|
76
79
|
polite.remove();
|
|
@@ -83,37 +86,111 @@ export function createAnnouncer(root: HTMLElement): Announcer {
|
|
|
83
86
|
let panelSeq = 0;
|
|
84
87
|
let tabSeq = 0;
|
|
85
88
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
89
|
+
/** One pane's tabpanel: labelled by the tab it shows, described by the shell's
|
|
90
|
+
* selection text. */
|
|
91
|
+
export interface PaneTablist {
|
|
92
|
+
panelId(): string;
|
|
93
|
+
labelBy(tabId: string): void;
|
|
94
|
+
/** Drop the label when `tabId` is the tab labelling the panel. */
|
|
95
|
+
unlabel(tabId: string): void;
|
|
96
|
+
/** The panel's `aria-describedby` text; empty removes it. */
|
|
97
|
+
setDescription(text: string): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Wire the tabpanel half of the ARIA seam on a pane's output surface. */
|
|
101
|
+
export function createPaneTablist(panel: HTMLElement): PaneTablist {
|
|
88
102
|
panelSeq += 1;
|
|
89
103
|
const panelId = panel.id || `wt-panel-${String(panelSeq)}`;
|
|
90
104
|
panel.id = panelId;
|
|
91
105
|
panel.setAttribute("role", "tabpanel");
|
|
106
|
+
let description: HTMLElement | null = null;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
panelId: () => panelId,
|
|
110
|
+
labelBy(tabId) {
|
|
111
|
+
panel.setAttribute("aria-labelledby", tabId);
|
|
112
|
+
},
|
|
113
|
+
unlabel(tabId) {
|
|
114
|
+
if (panel.getAttribute("aria-labelledby") === tabId) {
|
|
115
|
+
panel.removeAttribute("aria-labelledby");
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
setDescription(text) {
|
|
119
|
+
if (text === "") {
|
|
120
|
+
description?.remove();
|
|
121
|
+
description = null;
|
|
122
|
+
panel.removeAttribute("aria-describedby");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (description === null) {
|
|
126
|
+
description = panel.ownerDocument.createElement("span");
|
|
127
|
+
description.id = `${panelId}-description`;
|
|
128
|
+
hideVisually(description);
|
|
129
|
+
panel.parentElement?.appendChild(description);
|
|
130
|
+
panel.setAttribute("aria-describedby", description.id);
|
|
131
|
+
}
|
|
132
|
+
description.textContent = text;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
92
136
|
|
|
137
|
+
/** Wire the tab-row half of the ARIA seam. `panelFor(side)` answers the pane
|
|
138
|
+
* showing a tab, and `panelFor(null)` the pane a click on an unshown tab would
|
|
139
|
+
* fill; either may be null before that pane is built. */
|
|
140
|
+
export function createTablist(
|
|
141
|
+
panelFor: (side: PaneSide | null) => PaneTablist | null,
|
|
142
|
+
): TablistController {
|
|
93
143
|
function registerTab(tab: HTMLElement): TabHandle {
|
|
94
144
|
tabSeq += 1;
|
|
95
145
|
const tabId = tab.id || `wt-tab-${String(tabSeq)}`;
|
|
96
146
|
tab.id = tabId;
|
|
97
147
|
tab.setAttribute("role", "tab");
|
|
98
|
-
tab.setAttribute("aria-controls", panelId);
|
|
99
148
|
tab.setAttribute("aria-selected", "false");
|
|
100
149
|
// Roving tabindex (WAI-ARIA APG Tabs pattern): only the selected tab is in
|
|
101
150
|
// the Tab sequence; the others are focusable programmatically (arrow keys).
|
|
102
151
|
tab.tabIndex = -1;
|
|
152
|
+
let side: PaneSide | null = null;
|
|
153
|
+
let selected = false;
|
|
154
|
+
let labelled: PaneTablist | null = null;
|
|
155
|
+
function paintPanel(): void {
|
|
156
|
+
const panel = panelFor(side);
|
|
157
|
+
if (panel === null) {
|
|
158
|
+
tab.removeAttribute("aria-controls");
|
|
159
|
+
} else {
|
|
160
|
+
tab.setAttribute("aria-controls", panel.panelId());
|
|
161
|
+
}
|
|
162
|
+
// A panel is labelled by the selected tab that controls it, and by nothing else.
|
|
163
|
+
const labelling = selected ? panel : null;
|
|
164
|
+
if (labelling !== labelled) {
|
|
165
|
+
labelled?.unlabel(tabId);
|
|
166
|
+
labelling?.labelBy(tabId);
|
|
167
|
+
labelled = labelling;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
paintPanel();
|
|
103
171
|
|
|
104
172
|
return {
|
|
105
|
-
setSelected(
|
|
106
|
-
|
|
107
|
-
tab.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
173
|
+
setSelected(on: boolean): void {
|
|
174
|
+
selected = on;
|
|
175
|
+
tab.setAttribute("aria-selected", on ? "true" : "false");
|
|
176
|
+
tab.tabIndex = on ? 0 : -1;
|
|
177
|
+
paintPanel();
|
|
178
|
+
},
|
|
179
|
+
setPanel(next: PaneSide | null): void {
|
|
180
|
+
side = next;
|
|
181
|
+
paintPanel();
|
|
182
|
+
},
|
|
183
|
+
setExpanded(state: boolean | null): void {
|
|
184
|
+
if (state === null) {
|
|
185
|
+
tab.removeAttribute("aria-expanded");
|
|
186
|
+
} else {
|
|
187
|
+
tab.setAttribute("aria-expanded", state ? "true" : "false");
|
|
111
188
|
}
|
|
112
189
|
},
|
|
113
190
|
setLabel(text: string): void {
|
|
114
191
|
tab.setAttribute("aria-label", text);
|
|
115
192
|
},
|
|
116
|
-
setEditing(editing: boolean,
|
|
193
|
+
setEditing(editing: boolean, on: boolean): void {
|
|
117
194
|
if (editing) {
|
|
118
195
|
// Drop the tab semantics while a textbox lives inside the chip, and
|
|
119
196
|
// take the chip out of the roving sequence so Tab moves past it to the
|
|
@@ -125,24 +202,26 @@ export function createTablist(panel: HTMLElement): TablistController {
|
|
|
125
202
|
tab.tabIndex = -1;
|
|
126
203
|
return;
|
|
127
204
|
}
|
|
205
|
+
selected = on;
|
|
128
206
|
tab.setAttribute("role", "tab");
|
|
129
|
-
tab.setAttribute("aria-selected",
|
|
130
|
-
tab.tabIndex =
|
|
207
|
+
tab.setAttribute("aria-selected", on ? "true" : "false");
|
|
208
|
+
tab.tabIndex = on ? 0 : -1;
|
|
209
|
+
paintPanel();
|
|
131
210
|
},
|
|
132
211
|
remove(): void {
|
|
133
212
|
tab.removeAttribute("role");
|
|
134
213
|
tab.removeAttribute("aria-controls");
|
|
135
214
|
tab.removeAttribute("aria-selected");
|
|
215
|
+
tab.removeAttribute("aria-expanded");
|
|
136
216
|
tab.removeAttribute("tabindex");
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
217
|
+
labelled?.unlabel(tabId);
|
|
218
|
+
labelled = null;
|
|
140
219
|
},
|
|
141
220
|
};
|
|
142
221
|
}
|
|
143
222
|
|
|
144
223
|
return {
|
|
145
|
-
panelId: () => panelId,
|
|
224
|
+
panelId: () => panelFor(null)?.panelId() ?? "",
|
|
146
225
|
registerTab,
|
|
147
226
|
};
|
|
148
227
|
}
|