@cplieger/web-terminal-ui 4.1.0 → 4.2.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/css/30-tabs.css +35 -15
- package/package.json +2 -2
- package/src/features/tabs/index.ts +71 -0
- package/src/kernel/a11y.ts +5 -0
package/css/30-tabs.css
CHANGED
|
@@ -356,6 +356,12 @@
|
|
|
356
356
|
from the filled done/working/input states (WCAG 1.4.1). */
|
|
357
357
|
background: transparent;
|
|
358
358
|
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
359
|
+
/* Reset every transient visual owned by a status. In particular, a dot that
|
|
360
|
+
leaves working must stop its pulse, and a dot that enters working must not
|
|
361
|
+
carry the input ring or an exited opacity into the animated paint layer. */
|
|
362
|
+
box-shadow: none;
|
|
363
|
+
opacity: 1;
|
|
364
|
+
animation: none;
|
|
359
365
|
}
|
|
360
366
|
|
|
361
367
|
/* Reveal a dot only once its session has reported activity (the tabs feature
|
|
@@ -370,31 +376,33 @@
|
|
|
370
376
|
border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
|
|
371
377
|
}
|
|
372
378
|
|
|
373
|
-
/* Agent working: solid pulsing
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
379
|
+
/* Agent working: solid pulsing pale lime. Its high lightness keeps it clear on
|
|
380
|
+
every tab state, while its yellow-green hue complements the Kiro purple active
|
|
381
|
+
tab. Motion is the primary non-hue cue. Server-side this is driven by the OSC
|
|
382
|
+
9;4 progress signal, so it tracks agent activity, not the user typing at the
|
|
383
|
+
prompt. */
|
|
378
384
|
:where(.wt-root) .wt-status-dot[data-status="working"] {
|
|
379
|
-
background: #
|
|
380
|
-
border-color: #
|
|
385
|
+
background: #d9f99d;
|
|
386
|
+
border-color: #d9f99d;
|
|
381
387
|
animation: wt-pulse 1.2s ease-in-out infinite;
|
|
382
388
|
}
|
|
383
389
|
|
|
384
|
-
/* Agent done / ready: solid green, matching Kiro IDE's "Ready"
|
|
385
|
-
motion.
|
|
390
|
+
/* Agent done / ready: solid saturated green, matching Kiro IDE's "Ready"
|
|
391
|
+
(#22c55e), no motion. Keeping it darker and more saturated than working's
|
|
392
|
+
pale lime makes the two states distinct by lightness as well as motion. */
|
|
386
393
|
:where(.wt-root) .wt-status-dot[data-status="done"] {
|
|
387
394
|
background: #22c55e;
|
|
388
395
|
border-color: #22c55e;
|
|
389
396
|
}
|
|
390
397
|
|
|
391
|
-
/* User action required (permission / approval): solid
|
|
392
|
-
|
|
393
|
-
|
|
398
|
+
/* User action required (permission / approval): solid orange (#fb923c), shifted
|
|
399
|
+
away from the old amber so it is clearly separated from both pale-lime working
|
|
400
|
+
and green done, plus a matching ring so it reads as "needs you" via shape as
|
|
401
|
+
well as hue. It persists until you act on or switch into the tab. */
|
|
394
402
|
:where(.wt-root) .wt-status-dot[data-status="input"] {
|
|
395
|
-
background: #
|
|
396
|
-
border-color: #
|
|
397
|
-
box-shadow: 0 0 0 2px
|
|
403
|
+
background: #fb923c;
|
|
404
|
+
border-color: #fb923c;
|
|
405
|
+
box-shadow: 0 0 0 2px rgb(251 146 60 / 40%);
|
|
398
406
|
}
|
|
399
407
|
|
|
400
408
|
/* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
|
|
@@ -405,7 +413,19 @@
|
|
|
405
413
|
}
|
|
406
414
|
|
|
407
415
|
@keyframes wt-pulse {
|
|
416
|
+
0%,
|
|
417
|
+
100% {
|
|
418
|
+
/* Pin the animated layer's paint as well as its opacity. When working begins
|
|
419
|
+
in the same frame that input/done ends, an opacity-only animation can
|
|
420
|
+
pulse the previously rasterized orange/green dot. */
|
|
421
|
+
background-color: #d9f99d;
|
|
422
|
+
border-color: #d9f99d;
|
|
423
|
+
opacity: 1;
|
|
424
|
+
}
|
|
425
|
+
|
|
408
426
|
50% {
|
|
427
|
+
background-color: #d9f99d;
|
|
428
|
+
border-color: #d9f99d;
|
|
409
429
|
opacity: 0.4;
|
|
410
430
|
}
|
|
411
431
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
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",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@vitest/coverage-v8": "4.1.10",
|
|
51
51
|
"eslint": "10.7.0",
|
|
52
52
|
"fast-check": "4.9.0",
|
|
53
|
-
"happy-dom": "20.
|
|
53
|
+
"happy-dom": "20.11.0",
|
|
54
54
|
"html-validate": "11.5.6",
|
|
55
55
|
"knip": "6.27.0",
|
|
56
56
|
"prettier": "3.9.5",
|
|
@@ -908,6 +908,48 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
908
908
|
}
|
|
909
909
|
switchTo(tab.id);
|
|
910
910
|
});
|
|
911
|
+
// Keyboard interaction for the ARIA tabs pattern (WCAG 2.1.1): arrows
|
|
912
|
+
// move selection (wrapping), Home/End jump to the boundaries, Delete
|
|
913
|
+
// closes the focused tab. Pairs with the roving tabindex the kernel's
|
|
914
|
+
// registerTab manages (selected tab is tabIndex 0, others -1).
|
|
915
|
+
el.addEventListener("keydown", (e) => {
|
|
916
|
+
const current = tabList.indexOf(tab);
|
|
917
|
+
if (current < 0) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (e.key === "Delete") {
|
|
922
|
+
e.preventDefault();
|
|
923
|
+
void close_(tab.id);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
let targetIndex: number;
|
|
928
|
+
switch (e.key) {
|
|
929
|
+
case "ArrowLeft":
|
|
930
|
+
targetIndex = (current - 1 + tabList.length) % tabList.length;
|
|
931
|
+
break;
|
|
932
|
+
case "ArrowRight":
|
|
933
|
+
targetIndex = (current + 1) % tabList.length;
|
|
934
|
+
break;
|
|
935
|
+
case "Home":
|
|
936
|
+
targetIndex = 0;
|
|
937
|
+
break;
|
|
938
|
+
case "End":
|
|
939
|
+
targetIndex = tabList.length - 1;
|
|
940
|
+
break;
|
|
941
|
+
default:
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
e.preventDefault();
|
|
946
|
+
const target = tabList[targetIndex];
|
|
947
|
+
if (!target) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
switchTo(target.id);
|
|
951
|
+
target.el.focus();
|
|
952
|
+
});
|
|
911
953
|
close.addEventListener("click", (e) => {
|
|
912
954
|
e.stopPropagation();
|
|
913
955
|
void close_(tab.id);
|
|
@@ -1169,6 +1211,29 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1169
1211
|
}
|
|
1170
1212
|
}
|
|
1171
1213
|
|
|
1214
|
+
// moveTab shifts a tab one slot left or right in tabList and re-inserts
|
|
1215
|
+
// every tab element in list order (before the "+") so the DOM matches.
|
|
1216
|
+
// It is the single-pointer / non-drag alternative to the drag-and-drop
|
|
1217
|
+
// reorder (WCAG 2.5.7), surfaced as Move left / Move right in the tab
|
|
1218
|
+
// context menu.
|
|
1219
|
+
function moveTab(id: string, delta: -1 | 1): void {
|
|
1220
|
+
const from = tabList.findIndex((t) => t.id === id);
|
|
1221
|
+
const to = from + delta;
|
|
1222
|
+
if (from < 0 || to < 0 || to >= tabList.length) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const [tab] = tabList.splice(from, 1);
|
|
1226
|
+
if (!tab) {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
tabList.splice(to, 0, tab);
|
|
1230
|
+
for (const item of tabList) {
|
|
1231
|
+
scroller.insertBefore(item.el, newBtn);
|
|
1232
|
+
}
|
|
1233
|
+
syncChrome();
|
|
1234
|
+
ctx.announce(`Moved ${tab.display} to position ${String(to + 1)}`);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1172
1237
|
// adoptSession adds a tab for a session that exists server-side but has no
|
|
1173
1238
|
// local tab yet, e.g. one created in another browser (the server pushes it
|
|
1174
1239
|
// over the status SSE, and the poll fallback lists it). This keeps every
|
|
@@ -1410,6 +1475,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
1410
1475
|
return;
|
|
1411
1476
|
}
|
|
1412
1477
|
const n = tabList.length;
|
|
1478
|
+
tabMenuItem("Move left", idx <= 0, () => {
|
|
1479
|
+
moveTab(id, -1);
|
|
1480
|
+
});
|
|
1481
|
+
tabMenuItem("Move right", idx >= n - 1, () => {
|
|
1482
|
+
moveTab(id, 1);
|
|
1483
|
+
});
|
|
1413
1484
|
tabMenuItem("Close", false, () => {
|
|
1414
1485
|
void close_(id);
|
|
1415
1486
|
});
|
package/src/kernel/a11y.ts
CHANGED
|
@@ -97,10 +97,14 @@ export function createTablist(panel: HTMLElement): TablistController {
|
|
|
97
97
|
tab.setAttribute("role", "tab");
|
|
98
98
|
tab.setAttribute("aria-controls", panelId);
|
|
99
99
|
tab.setAttribute("aria-selected", "false");
|
|
100
|
+
// Roving tabindex (WAI-ARIA APG Tabs pattern): only the selected tab is in
|
|
101
|
+
// the Tab sequence; the others are focusable programmatically (arrow keys).
|
|
102
|
+
tab.tabIndex = -1;
|
|
100
103
|
|
|
101
104
|
return {
|
|
102
105
|
setSelected(selected: boolean): void {
|
|
103
106
|
tab.setAttribute("aria-selected", selected ? "true" : "false");
|
|
107
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
104
108
|
if (selected) {
|
|
105
109
|
// The panel is labelled by whichever tab is currently selected.
|
|
106
110
|
panel.setAttribute("aria-labelledby", tabId);
|
|
@@ -113,6 +117,7 @@ export function createTablist(panel: HTMLElement): TablistController {
|
|
|
113
117
|
tab.removeAttribute("role");
|
|
114
118
|
tab.removeAttribute("aria-controls");
|
|
115
119
|
tab.removeAttribute("aria-selected");
|
|
120
|
+
tab.removeAttribute("tabindex");
|
|
116
121
|
if (panel.getAttribute("aria-labelledby") === tabId) {
|
|
117
122
|
panel.removeAttribute("aria-labelledby");
|
|
118
123
|
}
|