@cplieger/web-terminal-ui 2.1.4 → 3.0.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 +57 -32
- package/css/03-v3.css +590 -0
- package/css/MANIFEST +1 -0
- package/package.json +9 -5
- package/scaffold/index.html +8 -6
- package/src/composition.ts +15 -0
- package/src/features/activity-monitor.ts +64 -0
- package/src/features/animations.ts +29 -0
- package/src/features/clipboard.ts +98 -0
- package/src/features/connection-banner.ts +53 -0
- package/src/features/context-menu.ts +207 -0
- package/src/features/dom.ts +15 -0
- package/src/features/mobile-toolbar.ts +82 -0
- package/src/features/predictive-echo.ts +63 -0
- package/src/features/scroll-to-bottom.ts +49 -0
- package/src/features/tabs.ts +687 -0
- package/src/index.ts +30 -9
- package/src/kernel/a11y.ts +102 -0
- package/src/kernel/bus.ts +61 -0
- package/src/kernel/conn-state.ts +133 -0
- package/src/kernel/kernel.ts +688 -0
- package/src/kernel/regions.ts +119 -0
- package/src/kernel/types.ts +286 -0
- package/src/presets.ts +46 -0
- package/src/mount.ts +0 -903
- package/src/status.ts +0 -228
package/src/index.ts
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
|
-
// @cplieger/web-terminal-ui — the reference touch-first browser UI built on
|
|
2
|
-
//
|
|
3
|
-
// mount(root) call that builds the whole terminal subtree inside a
|
|
4
|
-
// host-provided element; scaffold/index.html + css/ are a reference page to copy.
|
|
1
|
+
// @cplieger/web-terminal-ui — the reference touch-first browser UI built on the
|
|
2
|
+
// @cplieger/web-terminal-engine engine.
|
|
5
3
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
9
13
|
|
|
10
|
-
export {
|
|
11
|
-
export type {
|
|
14
|
+
export { createTerminal } from "./kernel/kernel.js";
|
|
15
|
+
export type {
|
|
16
|
+
CreateTerminalOptions,
|
|
17
|
+
TerminalHandle,
|
|
18
|
+
TerminalFeature,
|
|
19
|
+
FeatureInstance,
|
|
20
|
+
TerminalContext,
|
|
21
|
+
TerminalEvents,
|
|
22
|
+
RegionName,
|
|
23
|
+
RegionSlot,
|
|
24
|
+
RenderHandle,
|
|
25
|
+
ScrollHandle,
|
|
26
|
+
SessionRef,
|
|
27
|
+
SessionView,
|
|
28
|
+
ConnState,
|
|
29
|
+
TablistController,
|
|
30
|
+
TabHandle,
|
|
31
|
+
Unsubscribe,
|
|
32
|
+
} from "./kernel/types.js";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Kernel-owned accessibility primitives (design sections 12, 22.4): one polite
|
|
2
|
+
// and one assertive live region so features never spawn competing aria-live
|
|
3
|
+
// regions, and one tablist/tabpanel controller wired on the kernel's output
|
|
4
|
+
// surface so `tabs` gets the ARIA seam without crossing into kernel-owned DOM.
|
|
5
|
+
|
|
6
|
+
import type { TablistController, TabHandle } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Visually-hidden style applied inline so it holds without external CSS
|
|
9
|
+
* (tests run without the stylesheet). Standard sr-only clip technique. */
|
|
10
|
+
function hideVisually(el: HTMLElement): void {
|
|
11
|
+
const s = el.style;
|
|
12
|
+
s.position = "absolute";
|
|
13
|
+
s.width = "1px";
|
|
14
|
+
s.height = "1px";
|
|
15
|
+
s.margin = "-1px";
|
|
16
|
+
s.padding = "0";
|
|
17
|
+
s.overflow = "hidden";
|
|
18
|
+
s.clipPath = "inset(50%)";
|
|
19
|
+
s.whiteSpace = "nowrap";
|
|
20
|
+
s.border = "0";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Announcer {
|
|
24
|
+
announce(message: string, politeness?: "polite" | "assertive"): void;
|
|
25
|
+
destroy(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build the single pair of live regions inside root. */
|
|
29
|
+
export function createAnnouncer(root: HTMLElement): Announcer {
|
|
30
|
+
const polite = document.createElement("div");
|
|
31
|
+
polite.setAttribute("aria-live", "polite");
|
|
32
|
+
polite.setAttribute("aria-atomic", "true");
|
|
33
|
+
hideVisually(polite);
|
|
34
|
+
const assertive = document.createElement("div");
|
|
35
|
+
assertive.setAttribute("aria-live", "assertive");
|
|
36
|
+
assertive.setAttribute("aria-atomic", "true");
|
|
37
|
+
hideVisually(assertive);
|
|
38
|
+
root.append(polite, assertive);
|
|
39
|
+
|
|
40
|
+
function announce(message: string, politeness: "polite" | "assertive" = "polite"): void {
|
|
41
|
+
const el = politeness === "assertive" ? assertive : polite;
|
|
42
|
+
// Clear then set on the next frame so a repeat of the same message is
|
|
43
|
+
// re-announced (screen readers ignore an unchanged live-region value).
|
|
44
|
+
el.textContent = "";
|
|
45
|
+
requestAnimationFrame(() => {
|
|
46
|
+
el.textContent = message;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function destroy(): void {
|
|
51
|
+
polite.remove();
|
|
52
|
+
assertive.remove();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { announce, destroy };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let panelSeq = 0;
|
|
59
|
+
let tabSeq = 0;
|
|
60
|
+
|
|
61
|
+
/** Wire the ARIA tablist/tabpanel seam on the kernel's output surface. */
|
|
62
|
+
export function createTablist(panel: HTMLElement): TablistController {
|
|
63
|
+
panelSeq += 1;
|
|
64
|
+
const panelId = panel.id || `wt-panel-${String(panelSeq)}`;
|
|
65
|
+
panel.id = panelId;
|
|
66
|
+
panel.setAttribute("role", "tabpanel");
|
|
67
|
+
|
|
68
|
+
function registerTab(tab: HTMLElement): TabHandle {
|
|
69
|
+
tabSeq += 1;
|
|
70
|
+
const tabId = tab.id || `wt-tab-${String(tabSeq)}`;
|
|
71
|
+
tab.id = tabId;
|
|
72
|
+
tab.setAttribute("role", "tab");
|
|
73
|
+
tab.setAttribute("aria-controls", panelId);
|
|
74
|
+
tab.setAttribute("aria-selected", "false");
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
setSelected(selected: boolean): void {
|
|
78
|
+
tab.setAttribute("aria-selected", selected ? "true" : "false");
|
|
79
|
+
if (selected) {
|
|
80
|
+
// The panel is labelled by whichever tab is currently selected.
|
|
81
|
+
panel.setAttribute("aria-labelledby", tabId);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
setLabel(text: string): void {
|
|
85
|
+
tab.setAttribute("aria-label", text);
|
|
86
|
+
},
|
|
87
|
+
remove(): void {
|
|
88
|
+
tab.removeAttribute("role");
|
|
89
|
+
tab.removeAttribute("aria-controls");
|
|
90
|
+
tab.removeAttribute("aria-selected");
|
|
91
|
+
if (panel.getAttribute("aria-labelledby") === tabId) {
|
|
92
|
+
panel.removeAttribute("aria-labelledby");
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
panelId: () => panelId,
|
|
100
|
+
registerTab,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Typed event bus for the kernel/feature contract. A small pub/sub keyed by the
|
|
2
|
+
// TerminalEvents map, so a subscriber gets the exact payload type for its event
|
|
3
|
+
// and the kernel emits with the same guarantee (no `unknown`, section 22.4).
|
|
4
|
+
//
|
|
5
|
+
// The bus itself does not attribute errors to features; the kernel wraps each
|
|
6
|
+
// feature's ctx.on handler with a try/catch that routes to ctx.onError with the
|
|
7
|
+
// feature name, so a throwing handler is isolated and named there.
|
|
8
|
+
|
|
9
|
+
import type { TerminalEvents, Unsubscribe } from "./types.js";
|
|
10
|
+
|
|
11
|
+
/** A typed event bus over TerminalEvents. */
|
|
12
|
+
export interface TypedBus {
|
|
13
|
+
on<K extends keyof TerminalEvents>(e: K, fn: (p: TerminalEvents[K]) => void): Unsubscribe;
|
|
14
|
+
emit<K extends keyof TerminalEvents>(e: K, p: TerminalEvents[K]): void;
|
|
15
|
+
/** Drop every listener (used on kernel destroy). */
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A handler for any event. `(p: never) => void` is the bottom of the
|
|
20
|
+
// contravariant handler lattice: every concrete `(p: TerminalEvents[K]) => void`
|
|
21
|
+
// is assignable to it, so one Set can hold handlers for a single key uniformly.
|
|
22
|
+
// The exact payload type is recovered with a single controlled cast at emit.
|
|
23
|
+
type AnyHandler = (p: never) => void;
|
|
24
|
+
|
|
25
|
+
export function createBus(): TypedBus {
|
|
26
|
+
const listeners = new Map<keyof TerminalEvents, Set<AnyHandler>>();
|
|
27
|
+
|
|
28
|
+
function on<K extends keyof TerminalEvents>(
|
|
29
|
+
e: K,
|
|
30
|
+
fn: (p: TerminalEvents[K]) => void,
|
|
31
|
+
): Unsubscribe {
|
|
32
|
+
let set = listeners.get(e);
|
|
33
|
+
if (!set) {
|
|
34
|
+
set = new Set<AnyHandler>();
|
|
35
|
+
listeners.set(e, set);
|
|
36
|
+
}
|
|
37
|
+
set.add(fn);
|
|
38
|
+
return () => {
|
|
39
|
+
listeners.get(e)?.delete(fn);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emit<K extends keyof TerminalEvents>(e: K, p: TerminalEvents[K]): void {
|
|
44
|
+
const set = listeners.get(e);
|
|
45
|
+
if (!set) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Snapshot so a handler that subscribes/unsubscribes during dispatch does
|
|
49
|
+
// not mutate the set we are iterating. The cast recovers the payload type
|
|
50
|
+
// this key's handlers were registered with.
|
|
51
|
+
for (const fn of [...set]) {
|
|
52
|
+
(fn as (p: TerminalEvents[K]) => void)(p);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clear(): void {
|
|
57
|
+
listeners.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { on, emit, clear };
|
|
61
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Connection-state machine (kernel-owned, design section 22.3). The state, the
|
|
2
|
+
// never-connects give-up, and the loading lifecycle are kernel; the visible
|
|
3
|
+
// banner is the connectionBanner feature, which renders whatever state this
|
|
4
|
+
// emits. This is the DOM-free half of the old status.ts (its banner/toast
|
|
5
|
+
// rendering became the connectionBanner feature and the kernel toast surface).
|
|
6
|
+
//
|
|
7
|
+
// State machine:
|
|
8
|
+
// open -> no banner.
|
|
9
|
+
// connecting -> "Reconnecting..." (initial connect, before first open).
|
|
10
|
+
// reconnecting -> "Reconnecting..." (after a close, during retry);
|
|
11
|
+
// escalates to offline after >3 consecutive failures.
|
|
12
|
+
// offline -> "Offline".
|
|
13
|
+
// restarted -> "Server restarted - recent input may have been lost".
|
|
14
|
+
|
|
15
|
+
import type { ConnState } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const CONNECTING_GRACE_MS = 600;
|
|
18
|
+
|
|
19
|
+
// Consecutive failed initial connects (before the first screen frame) after
|
|
20
|
+
// which "Offline" is allowed through even while the loading overlay is up, so a
|
|
21
|
+
// never-connecting page does not sit on a silent "Loading..." forever. Aligned
|
|
22
|
+
// with the post-load escalation threshold (>3).
|
|
23
|
+
const INITIAL_FAILURE_LIMIT = 4;
|
|
24
|
+
|
|
25
|
+
const RESTARTED_CLEAR_MS = 4000;
|
|
26
|
+
|
|
27
|
+
export interface ConnStateMachine {
|
|
28
|
+
/** Connection opened. */
|
|
29
|
+
open(): void;
|
|
30
|
+
/** Reconnect attempt in progress. */
|
|
31
|
+
reconnecting(): void;
|
|
32
|
+
/** Connection closed / outbox full. */
|
|
33
|
+
closed(): void;
|
|
34
|
+
/** Server restarted (epoch mismatch); recent input may be lost. */
|
|
35
|
+
restarted(): void;
|
|
36
|
+
/** First screen frame rendered: the loading overlay is done, so the banner
|
|
37
|
+
* may show reconnect state from here on. */
|
|
38
|
+
setLoaded(): void;
|
|
39
|
+
/** Current state. */
|
|
40
|
+
current(): ConnState;
|
|
41
|
+
/** Clear timers (kernel destroy). */
|
|
42
|
+
destroy(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createConnState(opts: {
|
|
46
|
+
onState: (s: ConnState) => void;
|
|
47
|
+
onGiveUp?: () => void;
|
|
48
|
+
}): ConnStateMachine {
|
|
49
|
+
let state: ConnState = "connecting";
|
|
50
|
+
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
+
let restartedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
52
|
+
let consecutiveFailures = 0;
|
|
53
|
+
let loaded = false;
|
|
54
|
+
|
|
55
|
+
function emit(): void {
|
|
56
|
+
// Suppress transient states until the initial load is over (the loading
|
|
57
|
+
// overlay owns the screen), except "offline" once the initial-failure limit
|
|
58
|
+
// is reached. connectionBanner renders "open" as hidden.
|
|
59
|
+
if (!loaded && !(state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT)) {
|
|
60
|
+
opts.onState("open"); // nothing to show yet
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
opts.onState(state);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function setState(next: ConnState, delay: number): void {
|
|
67
|
+
if (stableTimer !== null) {
|
|
68
|
+
clearTimeout(stableTimer);
|
|
69
|
+
stableTimer = null;
|
|
70
|
+
}
|
|
71
|
+
if (delay === 0) {
|
|
72
|
+
state = next;
|
|
73
|
+
emit();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
stableTimer = setTimeout(() => {
|
|
77
|
+
stableTimer = null;
|
|
78
|
+
state = next;
|
|
79
|
+
emit();
|
|
80
|
+
}, delay);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
open(): void {
|
|
85
|
+
consecutiveFailures = 0;
|
|
86
|
+
setState("open", 0);
|
|
87
|
+
},
|
|
88
|
+
reconnecting(): void {
|
|
89
|
+
if (!loaded) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setState("reconnecting", CONNECTING_GRACE_MS);
|
|
93
|
+
},
|
|
94
|
+
closed(): void {
|
|
95
|
+
consecutiveFailures++;
|
|
96
|
+
if (!loaded && consecutiveFailures < INITIAL_FAILURE_LIMIT) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!loaded) {
|
|
100
|
+
opts.onGiveUp?.();
|
|
101
|
+
}
|
|
102
|
+
setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
|
|
103
|
+
},
|
|
104
|
+
restarted(): void {
|
|
105
|
+
setState("restarted", 0);
|
|
106
|
+
if (restartedTimer !== null) {
|
|
107
|
+
clearTimeout(restartedTimer);
|
|
108
|
+
}
|
|
109
|
+
restartedTimer = setTimeout(() => {
|
|
110
|
+
restartedTimer = null;
|
|
111
|
+
if (state === "restarted") {
|
|
112
|
+
setState("open", 0);
|
|
113
|
+
}
|
|
114
|
+
}, RESTARTED_CLEAR_MS);
|
|
115
|
+
},
|
|
116
|
+
setLoaded(): void {
|
|
117
|
+
loaded = true;
|
|
118
|
+
},
|
|
119
|
+
current(): ConnState {
|
|
120
|
+
return state;
|
|
121
|
+
},
|
|
122
|
+
destroy(): void {
|
|
123
|
+
if (stableTimer !== null) {
|
|
124
|
+
clearTimeout(stableTimer);
|
|
125
|
+
stableTimer = null;
|
|
126
|
+
}
|
|
127
|
+
if (restartedTimer !== null) {
|
|
128
|
+
clearTimeout(restartedTimer);
|
|
129
|
+
restartedTimer = null;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|