mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(tiles): a hover card on the Tiles button says what a right-click does
Owner feedback 1: "give me the hover info to right click over the tile
button to adjust it". The only hint was the native title, which the
browser shows late, small and unstyled.
The Tiles button now has its own hover card, under it in the count
menu's panel style:
- "Tiles · N" with the remembered count (live: a pick in the menu
changes it while the card is up), "Click: open the grid" ("close the
grid" while it is open), "Right-click: choose 2, 4 or 6 tiles", and
when the count does not fit the window what opens instead ("This
window fits 4 tiles: a click opens 4"; open, what fits). Shown from
the keyboard it adds "Shift+F10: the same menu from the keyboard".
- Shows 300 ms after a pointer that hovers rests on the button, or after
a keyboard focus (:focus-visible); never for a touch pointer or a
device that cannot hover (plus a CSS @media (hover: none) backstop),
never on a hidden button, never while the count menu is open. A short
fade on opacity and transform, none under reduced motion; it takes no
pointer.
- Hides on pointer leave, blur, Escape, a scroll and a resize, and on a
press, a click or a right-click on the button (capture phase, so it is
gone before the menu or the grid opens, and it stays gone while the
pointer rests there). openTileCountMenu hides it too, and the focus
the menu's Escape puts back on the button brings no card back.
- It replaces the button's native title (two tooltips never stack); the
aria-label stays and aria-describedby points at the card, which always
exists and is kept current, its keyboard line included while hidden, so
screen readers hear the same text without a hover. Text is diffed
against the last English, as the rest of the grid chrome, so the zh-CN
translator is not fought on every refresh.
- zh-CN for every line (平铺 · N, 单击, 右键单击, Shift+F10, the fits
note); no other header button changes (its styles are .tile-hint only).
Tests: tile-grid-hint.test.ts (install and aria wiring, the delay, each
show and hide path, content per state and count, the menu rule, the
translator, the CSS); the count menu and i18n tests read the accessible
name instead of the removed title, and the i18n harvest covers the card
(F10 joins the key names that stay Latin). The vm harness gains
removeAttribute.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -107,6 +107,11 @@
|
|||||||
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
|
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
|
||||||
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
|
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
|
||||||
'How many tiles': '窗格数量',
|
'How many tiles': '窗格数量',
|
||||||
|
// The Tiles button's hover card (the count and the fits note are patterns).
|
||||||
|
'Click: open the grid': '单击:打开平铺网格',
|
||||||
|
'Click: close the grid': '单击:关闭平铺网格',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格',
|
||||||
|
'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单',
|
||||||
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
|
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
|
||||||
'Toggle Tile Grid': '切换平铺网格',
|
'Toggle Tile Grid': '切换平铺网格',
|
||||||
'Focus Tile Left': '聚焦左侧窗格',
|
'Focus Tile Left': '聚焦左侧窗格',
|
||||||
@@ -1057,6 +1062,11 @@
|
|||||||
],
|
],
|
||||||
// Tile grid: counts, exit codes and durations pass through.
|
// Tile grid: counts, exit codes and durations pass through.
|
||||||
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
|
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
|
||||||
|
[/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`],
|
||||||
|
[
|
||||||
|
/^This window fits (\d+) tiles?: a click opens (\d+)$/,
|
||||||
|
(_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`,
|
||||||
|
],
|
||||||
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
|
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
|
||||||
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
|
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -192,7 +192,7 @@
|
|||||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||||
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" title="Tiles: show several sessions side by side (right-click for how many)" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
|
||||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
|
||||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||||
|
|||||||
@@ -19952,6 +19952,89 @@ body.tile-grid-resizing--row * {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Tiles button's hover card (tile-grid.js _installTileGridHint): the
|
||||||
|
count, what a click and a right-click do, Shift+F10 from the keyboard.
|
||||||
|
Under the button in the count menu's panel style; it never takes a pointer,
|
||||||
|
and only pointers that hover get it. */
|
||||||
|
.tile-hint {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--floating-bg, var(--bg-secondary, #1a1a1a));
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--control-border, var(--border-color, #333));
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
color: var(--text, #e6e6e6);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
pointer-events: none;
|
||||||
|
transform-origin: top right;
|
||||||
|
animation: tile-hint-in 120ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-hint[hidden],
|
||||||
|
.tile-hint [hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-hint-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-hint-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-hint-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-muted, #999);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The fits and keyboard lines have no mouse: the same indent as the text. */
|
||||||
|
.tile-hint-note,
|
||||||
|
.tile-hint-keys {
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-hint-glyph {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 10px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-hint-glyph .tile-hint-press {
|
||||||
|
fill: var(--accent, #4a9eff);
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.tile-hint {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tile-hint {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tile grid motion (the grid's own default, not an entrance theme) ──────
|
/* ── Tile grid motion (the grid's own default, not an entrance theme) ──────
|
||||||
Opening the grid, each tile fades and settles in, staggered in reading
|
Opening the grid, each tile fades and settles in, staggered in reading
|
||||||
order; its terminal stays transparent until its first capture lands, then
|
order; its terminal stays transparent until its first capture lands, then
|
||||||
|
|||||||
+197
-6
@@ -42,6 +42,9 @@ const TILE_GHOST_HOLD_MAX_MS = 700;
|
|||||||
// A tile's terminal shows once its first capture has landed (the load queue
|
// A tile's terminal shows once its first capture has landed (the load queue
|
||||||
// says so); this is the backstop should that never be reported.
|
// says so); this is the backstop should that never be reported.
|
||||||
const TILE_REVEAL_FALLBACK_MS = 15000;
|
const TILE_REVEAL_FALLBACK_MS = 15000;
|
||||||
|
// How long the pointer (or a keyboard focus) rests on the Tiles button before
|
||||||
|
// its hover card shows (owner feedback 1).
|
||||||
|
const TILE_HINT_DELAY_MS = 300;
|
||||||
// Trailing debounce for refitting tiles after the grid area changes size, so a
|
// Trailing debounce for refitting tiles after the grid area changes size, so a
|
||||||
// window drag sends each tile's PTY one resize, not one per frame.
|
// window drag sends each tile's PTY one resize, not one per frame.
|
||||||
const TILE_GRID_REFIT_MS = 150;
|
const TILE_GRID_REFIT_MS = 150;
|
||||||
@@ -111,6 +114,31 @@ function tileCountGlyph(n) {
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hover card's mouse: a rounded body with its left or right button filled.
|
||||||
|
*/
|
||||||
|
function tileHintMouseGlyph(button) {
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(NS, 'svg');
|
||||||
|
svg.setAttribute('class', `tile-hint-glyph tile-hint-glyph--${button}`);
|
||||||
|
svg.setAttribute('viewBox', '0 0 10 14');
|
||||||
|
svg.setAttribute('aria-hidden', 'true');
|
||||||
|
const body = document.createElementNS(NS, 'rect');
|
||||||
|
for (const [k, v] of Object.entries({ x: '0.75', y: '0.75', width: '8.5', height: '12.5', rx: '4.25' })) {
|
||||||
|
body.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
const press = document.createElementNS(NS, 'path');
|
||||||
|
press.setAttribute('class', 'tile-hint-press');
|
||||||
|
press.setAttribute(
|
||||||
|
'd',
|
||||||
|
button === 'left'
|
||||||
|
? 'M4.6 1.3 V6 H1.3 V5.2 A3.6 3.6 0 0 1 4.6 1.3 Z'
|
||||||
|
: 'M5.4 1.3 V6 H8.7 V5.2 A3.6 3.6 0 0 0 5.4 1.3 Z'
|
||||||
|
);
|
||||||
|
svg.append(body, press);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
/** The grid's state. `has(id)` answers only while it is open. */
|
/** The grid's state. `has(id)` answers only while it is open. */
|
||||||
class TileGridModel {
|
class TileGridModel {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -487,6 +515,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* backstop that hides it even if this never runs).
|
* backstop that hides it even if this never runs).
|
||||||
*/
|
*/
|
||||||
_applyTileGridButtonVisibility(enabled) {
|
_applyTileGridButtonVisibility(enabled) {
|
||||||
|
this._installTileGridHint();
|
||||||
this._tileGridButtonSettingEnabled = !!enabled;
|
this._tileGridButtonSettingEnabled = !!enabled;
|
||||||
const btn = document.querySelector('.btn-tile-grid');
|
const btn = document.querySelector('.btn-tile-grid');
|
||||||
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
const wide = window.innerWidth >= SPLIT_PANE_MIN_WIDTH;
|
||||||
@@ -505,11 +534,163 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const open = this._tilesOwnTerminal();
|
const open = this._tilesOwnTerminal();
|
||||||
btn.classList.toggle('tiles-open', open);
|
btn.classList.toggle('tiles-open', open);
|
||||||
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
||||||
const title = open
|
// No native title: the hover card says it (two tooltips never stack), and
|
||||||
? 'Tiles: back to a single session (right-click for how many tiles)'
|
// aria-describedby gives screen readers the same text.
|
||||||
: 'Tiles: show several sessions side by side (right-click for how many)';
|
btn.setAttribute(
|
||||||
btn.title = title;
|
'aria-label',
|
||||||
btn.setAttribute('aria-label', title);
|
open
|
||||||
|
? 'Tiles: back to a single session (right-click for how many tiles)'
|
||||||
|
: 'Tiles: show several sessions side by side (right-click for how many)'
|
||||||
|
);
|
||||||
|
this._renderTileHint();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Tiles button's hover card (owner feedback 1: "give me the hover info
|
||||||
|
* to right click over the tile button to adjust it"): the title with the
|
||||||
|
* remembered count, what a click does (open or close the grid), that a
|
||||||
|
* right-click chooses 2, 4 or 6, what opens when the count does not fit the
|
||||||
|
* window, and, shown from the keyboard, Shift+F10. It replaces the button's
|
||||||
|
* native title. The card always exists (hidden) and is kept current, since
|
||||||
|
* the button's aria-describedby reads it for screen readers without a
|
||||||
|
* hover. Installed once, from the button's visibility setter; never in a
|
||||||
|
* solo window.
|
||||||
|
*/
|
||||||
|
_installTileGridHint() {
|
||||||
|
if (this._tileHint || this.isSoloWindow) return;
|
||||||
|
const btn = document.querySelector('.btn-tile-grid');
|
||||||
|
if (!btn) return;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.id = 'tileGridHint';
|
||||||
|
card.className = 'tile-hint';
|
||||||
|
card.setAttribute('role', 'tooltip');
|
||||||
|
card.hidden = true;
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'tile-hint-title';
|
||||||
|
const line = (glyph) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'tile-hint-line';
|
||||||
|
const text = document.createElement('span');
|
||||||
|
if (glyph) row.appendChild(glyph);
|
||||||
|
row.appendChild(text);
|
||||||
|
return { row, text };
|
||||||
|
};
|
||||||
|
const click = line(tileHintMouseGlyph('left'));
|
||||||
|
const right = line(tileHintMouseGlyph('right'));
|
||||||
|
right.text.textContent = 'Right-click: choose 2, 4 or 6 tiles';
|
||||||
|
const fits = line(null);
|
||||||
|
fits.row.classList.add('tile-hint-note');
|
||||||
|
const keys = line(null);
|
||||||
|
keys.row.classList.add('tile-hint-keys');
|
||||||
|
keys.text.textContent = 'Shift+F10: the same menu from the keyboard';
|
||||||
|
card.append(title, click.row, right.row, fits.row, keys.row);
|
||||||
|
document.body.appendChild(card);
|
||||||
|
btn.removeAttribute('title');
|
||||||
|
btn.setAttribute('aria-describedby', card.id);
|
||||||
|
const hint = (this._tileHint = { btn, card, title, click: click.text, fits, keys, timer: null, suppressed: false });
|
||||||
|
const canHover = () => window.matchMedia?.('(hover: hover)')?.matches !== false;
|
||||||
|
btn.addEventListener('pointerenter', (e) => {
|
||||||
|
if (e?.pointerType === 'touch' || !canHover()) return;
|
||||||
|
this._scheduleTileHint({ keyboard: false });
|
||||||
|
});
|
||||||
|
btn.addEventListener('pointerleave', () => {
|
||||||
|
hint.suppressed = false;
|
||||||
|
this._hideTileHint();
|
||||||
|
});
|
||||||
|
btn.addEventListener('focus', () => {
|
||||||
|
// Focus put back by the count menu's Escape: the user was just there.
|
||||||
|
if (hint.skipFocus) {
|
||||||
|
hint.skipFocus = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn.matches?.(':focus-visible')) this._scheduleTileHint({ keyboard: true });
|
||||||
|
});
|
||||||
|
btn.addEventListener('blur', () => this._hideTileHint());
|
||||||
|
// Capture: these run before the button's own handlers, so the card is gone
|
||||||
|
// before a right-click opens the count menu or a click opens the grid; and
|
||||||
|
// it stays gone while the pointer rests there.
|
||||||
|
const dismiss = () => {
|
||||||
|
hint.suppressed = true;
|
||||||
|
this._hideTileHint();
|
||||||
|
};
|
||||||
|
for (const type of ['pointerdown', 'click', 'contextmenu']) btn.addEventListener(type, dismiss, true);
|
||||||
|
// While shown: Escape, a scroll anywhere, a resize.
|
||||||
|
hint.onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') this._hideTileHint();
|
||||||
|
};
|
||||||
|
hint.onAway = () => this._hideTileHint();
|
||||||
|
this._renderTileHint();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's text, from the remembered count, the grid's state and what the
|
||||||
|
* window fits. Compared with the last English text set, never the DOM (in
|
||||||
|
* zh-CN the DOM holds the translation; see _renderTileOverlay).
|
||||||
|
*/
|
||||||
|
_renderTileHint() {
|
||||||
|
const hint = this._tileHint;
|
||||||
|
if (!hint) return;
|
||||||
|
const count = this._tileGridCount();
|
||||||
|
const open = this._tilesOwnTerminal();
|
||||||
|
const capacity = this._tileGridLimit().capacity;
|
||||||
|
const set = (el, key, text) => {
|
||||||
|
if (hint[key] === text) return;
|
||||||
|
hint[key] = text;
|
||||||
|
el.textContent = text;
|
||||||
|
};
|
||||||
|
set(hint.title, 'titleText', `Tiles \u00B7 ${count}`);
|
||||||
|
set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid');
|
||||||
|
const plural = capacity === 1 ? '' : 's';
|
||||||
|
const fitsText =
|
||||||
|
count <= capacity
|
||||||
|
? ''
|
||||||
|
: open
|
||||||
|
? `This window fits ${capacity} tile${plural}`
|
||||||
|
: `This window fits ${capacity} tile${plural}: a click opens ${capacity}`;
|
||||||
|
set(hint.fits.text, 'fitsText', fitsText);
|
||||||
|
hint.fits.row.hidden = !fitsText;
|
||||||
|
},
|
||||||
|
|
||||||
|
_scheduleTileHint({ keyboard }) {
|
||||||
|
const hint = this._tileHint;
|
||||||
|
if (!hint || hint.suppressed) return;
|
||||||
|
clearTimeout(hint.timer);
|
||||||
|
hint.timer = setTimeout(() => this._showTileHint({ keyboard }), TILE_HINT_DELAY_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Under the button, right-aligned as the count menu; never with the menu open or the button hidden. */
|
||||||
|
_showTileHint({ keyboard }) {
|
||||||
|
const hint = this._tileHint;
|
||||||
|
hint.timer = null;
|
||||||
|
if (hint.suppressed || this._tileCountMenu || hint.btn.classList.contains('btn-tile-grid--hidden')) return;
|
||||||
|
this._renderTileHint();
|
||||||
|
hint.keys.row.hidden = !keyboard;
|
||||||
|
const rect = hint.btn.getBoundingClientRect?.();
|
||||||
|
if (rect) {
|
||||||
|
hint.card.style.top = `${rect.bottom + 6}px`;
|
||||||
|
hint.card.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
|
||||||
|
}
|
||||||
|
if (!hint.card.hidden) return;
|
||||||
|
hint.card.hidden = false;
|
||||||
|
document.addEventListener('keydown', hint.onKey, true);
|
||||||
|
window.addEventListener('scroll', hint.onAway, true);
|
||||||
|
window.addEventListener('resize', hint.onAway);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Idempotent: cancels a pending show and hides the card. */
|
||||||
|
_hideTileHint() {
|
||||||
|
const hint = this._tileHint;
|
||||||
|
if (!hint) return;
|
||||||
|
clearTimeout(hint.timer);
|
||||||
|
hint.timer = null;
|
||||||
|
if (hint.card.hidden) return;
|
||||||
|
hint.card.hidden = true;
|
||||||
|
// Hidden, the card is the button's description in full: a hidden line
|
||||||
|
// inside it would be left out of what a screen reader hears.
|
||||||
|
hint.keys.row.hidden = false;
|
||||||
|
document.removeEventListener('keydown', hint.onKey, true);
|
||||||
|
window.removeEventListener('scroll', hint.onAway, true);
|
||||||
|
window.removeEventListener('resize', hint.onAway);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -554,6 +735,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
} catch {
|
} catch {
|
||||||
/* Per-device convenience only. */
|
/* Per-device convenience only. */
|
||||||
}
|
}
|
||||||
|
// The hover card names the count.
|
||||||
|
this._renderTileHint();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -570,6 +753,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
openTileCountMenu(event) {
|
openTileCountMenu(event) {
|
||||||
// The right-click: the browser's own context menu stays away.
|
// The right-click: the browser's own context menu stays away.
|
||||||
event?.preventDefault?.();
|
event?.preventDefault?.();
|
||||||
|
// Never beside a hover card still showing (its own capture listener has
|
||||||
|
// usually hidden it already).
|
||||||
|
this._hideTileHint();
|
||||||
if (this._tileCountMenu) {
|
if (this._tileCountMenu) {
|
||||||
this.closeTileCountMenu();
|
this.closeTileCountMenu();
|
||||||
return;
|
return;
|
||||||
@@ -709,7 +895,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.removeEventListener('click', menu.onOutside);
|
document.removeEventListener('click', menu.onOutside);
|
||||||
document.removeEventListener('keydown', menu.onKey, true);
|
document.removeEventListener('keydown', menu.onKey, true);
|
||||||
menu.menu.remove();
|
menu.menu.remove();
|
||||||
if (refocus) document.querySelector('.btn-tile-grid')?.focus?.();
|
if (refocus) {
|
||||||
|
// The user was just in the menu: no hover card for this focus.
|
||||||
|
if (this._tileHint) this._tileHint.skipFocus = true;
|
||||||
|
document.querySelector('.btn-tile-grid')?.focus?.();
|
||||||
|
if (this._tileHint) this._tileHint.skipFocus = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ export class FakeEl {
|
|||||||
getAttribute(k: string) {
|
getAttribute(k: string) {
|
||||||
return this.attrs[k] ?? null;
|
return this.attrs[k] ?? null;
|
||||||
}
|
}
|
||||||
|
removeAttribute(k: string) {
|
||||||
|
delete this.attrs[k];
|
||||||
|
if (k === 'title') this.title = '';
|
||||||
|
}
|
||||||
/** Per event type, whether each listener was registered for the capture phase. */
|
/** Per event type, whether each listener was registered for the capture phase. */
|
||||||
captureFlags: Record<string, boolean[]> = {};
|
captureFlags: Record<string, boolean[]> = {};
|
||||||
addEventListener(type: string, fn: (ev: unknown) => void, opts?: boolean | { capture?: boolean }) {
|
addEventListener(type: string, fn: (ev: unknown) => void, opts?: boolean | { capture?: boolean }) {
|
||||||
|
|||||||
@@ -123,17 +123,20 @@ describe('the Tiles button', () => {
|
|||||||
app.openTileGrid(IDS.slice(0, 3));
|
app.openTileGrid(IDS.slice(0, 3));
|
||||||
expect(btn.getAttribute('aria-pressed')).toBe('true');
|
expect(btn.getAttribute('aria-pressed')).toBe('true');
|
||||||
expect(btn.classList.contains('tiles-open')).toBe(true);
|
expect(btn.classList.contains('tiles-open')).toBe(true);
|
||||||
expect(btn.title).toBe('Tiles: back to a single session (right-click for how many tiles)');
|
expect(btn.getAttribute('aria-label')).toBe('Tiles: back to a single session (right-click for how many tiles)');
|
||||||
app.toggleTileGrid();
|
app.toggleTileGrid();
|
||||||
expect(app._tilesOwnTerminal()).toBe(false);
|
expect(app._tilesOwnTerminal()).toBe(false);
|
||||||
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
||||||
expect(btn.title).toBe('Tiles: show several sessions side by side (right-click for how many)');
|
expect(btn.getAttribute('aria-label')).toBe('Tiles: show several sessions side by side (right-click for how many)');
|
||||||
|
// No native title (the hover card says it, tile-grid-hint.test.ts).
|
||||||
|
expect(btn.title).toBe('');
|
||||||
expect(menu()).toBeNull();
|
expect(menu()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('right-click opens the count menu, the click still toggles (one function with Ctrl+Shift+G)', () => {
|
it('right-click opens the count menu, the click still toggles (one function with Ctrl+Shift+G)', () => {
|
||||||
expect(html).toContain('onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)"');
|
expect(html).toContain('onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)"');
|
||||||
expect(html).toContain('title="Tiles: show several sessions side by side (right-click for how many)"');
|
expect(html).toContain('aria-label="Tiles: show several sessions side by side (right-click for how many)"');
|
||||||
|
expect(html).toContain('aria-describedby="tileGridHint"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The Tiles button's hover card (owner feedback 1: "give me the
|
||||||
|
* hover info to right click over the tile button to adjust it").
|
||||||
|
*
|
||||||
|
* - Installed once with the button's visibility, never in a solo window: a
|
||||||
|
* hidden `role=tooltip` card in body, the button's `aria-describedby`, and
|
||||||
|
* the button's native title removed (two tooltips never stack); the
|
||||||
|
* aria-label stays.
|
||||||
|
* - Shows 300 ms after a pointer that hovers rests on the button, or after a
|
||||||
|
* keyboard focus (`:focus-visible`), under the button. Never for a touch
|
||||||
|
* pointer, a device that cannot hover, a hidden button, or while the count
|
||||||
|
* menu is open.
|
||||||
|
* - Content: "Tiles · N" (the remembered count, live), what a click does
|
||||||
|
* (open or close the grid), "Right-click: choose 2, 4 or 6 tiles", what
|
||||||
|
* opens when the count does not fit the window, and Shift+F10 when shown
|
||||||
|
* from the keyboard; hidden, it holds all of it (the button's description).
|
||||||
|
* - Hides on pointer leave, blur, a click, a right-click or a press on the
|
||||||
|
* button (and stays hidden while the pointer rests there), Escape, a scroll
|
||||||
|
* and a resize; the count menu never opens beside it, and the Escape out of
|
||||||
|
* the menu brings no card back.
|
||||||
|
* - Only `.tile-hint` styles: the panel of the count menu, a fade on opacity
|
||||||
|
* and transform only, nothing under reduced motion or without hover.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
body,
|
||||||
|
bySelector,
|
||||||
|
localStore,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||||
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||||
|
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
let btn: FakeEl;
|
||||||
|
let wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const card = () => body.children.find((c) => c.id === 'tileGridHint') ?? null;
|
||||||
|
const lines = () => card()!.children.filter((c) => c.className.includes('tile-hint-line'));
|
||||||
|
const textOf = (row: FakeEl) => row.children.at(-1)!.textContent;
|
||||||
|
const visibleText = () => [
|
||||||
|
card()!.children[0].textContent,
|
||||||
|
...lines()
|
||||||
|
.filter((l) => !l.hidden)
|
||||||
|
.map(textOf),
|
||||||
|
];
|
||||||
|
const pointer = (type: string, pointerType = 'mouse') => btn.dispatch(type, { pointerType });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
const wrap = new FakeEl();
|
||||||
|
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
|
||||||
|
bySelector.set('.terminal-wrap', wrap);
|
||||||
|
btn = new FakeEl();
|
||||||
|
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
|
||||||
|
btn.title = 'Tiles: show several sessions side by side (right-click for how many)';
|
||||||
|
btn.getBoundingClientRect = () => ({ width: 26, height: 26, top: 8, left: 2000, right: 2026, bottom: 34 });
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function hintApp(): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('installed with the button', () => {
|
||||||
|
it('a hidden tooltip card in body, the button described by it, its native title gone, its label kept', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
expect(card()).not.toBeNull();
|
||||||
|
expect(card()!.attrs.role).toBe('tooltip');
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
expect(btn.getAttribute('aria-describedby')).toBe('tileGridHint');
|
||||||
|
expect(btn.title).toBe('');
|
||||||
|
expect(btn.getAttribute('title')).toBeNull();
|
||||||
|
app._updateTileGridButtonState();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Tiles: show several sessions side by side (right-click for how many)');
|
||||||
|
expect(btn.title).toBe('');
|
||||||
|
// Once: a second call adds no second card.
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
expect(body.children.filter((c) => c.id === 'tileGridHint')).toHaveLength(1);
|
||||||
|
// The markup: no title on the button, described by the card.
|
||||||
|
const tag = html.match(/<button class="btn-icon-header btn-tile-grid[^>]*>/)![0];
|
||||||
|
expect(tag).not.toMatch(/\stitle=/);
|
||||||
|
expect(tag).toContain('aria-describedby="tileGridHint"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never in a solo window', () => {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.isSoloWindow = true;
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
expect(card()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden, it holds everything a screen reader should hear, the keyboard line included', () => {
|
||||||
|
hintApp();
|
||||||
|
expect(lines().map((l) => l.hidden)).toEqual([false, false, true, false]);
|
||||||
|
expect(card()!.children[0].textContent).toBe('Tiles · 6');
|
||||||
|
expect(textOf(lines()[3])).toBe('Shift+F10: the same menu from the keyboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showing', () => {
|
||||||
|
it('300 ms after a mouse rests on the button, under it and right-aligned', () => {
|
||||||
|
hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(299);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(card()!.hidden).toBe(false);
|
||||||
|
expect(card()!.style.top).toBe('40px');
|
||||||
|
expect(card()!.style.right).toBe(`${2400 - 2026}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('from a pointer: no keyboard line; from a keyboard focus: with it', () => {
|
||||||
|
hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(visibleText()).toEqual(['Tiles · 6', 'Click: open the grid', 'Right-click: choose 2, 4 or 6 tiles']);
|
||||||
|
pointer('pointerleave');
|
||||||
|
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
|
||||||
|
btn.dispatch('focus', {});
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(visibleText()).toContain('Shift+F10: the same menu from the keyboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a mouse focus (not :focus-visible) shows nothing', () => {
|
||||||
|
hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
(btn as unknown as { matches: (s: string) => boolean }).matches = () => false;
|
||||||
|
btn.dispatch('focus', {});
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never for a touch pointer, a device that cannot hover, or a hidden button', () => {
|
||||||
|
hintApp();
|
||||||
|
// The setting off, or a narrow window: the button carries its hidden marker.
|
||||||
|
btn.classList.add('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true); // still --hidden
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter', 'touch');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
windowStub.matchMedia = (q: string) => ({ matches: !q.includes('hover'), addEventListener: vi.fn() });
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never while the count menu is open', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what it says', () => {
|
||||||
|
const show = () => {
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('the remembered count, live, and open or close by the grid', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
localStore.set('codeman:tile-count', '2');
|
||||||
|
app._renderTileHint();
|
||||||
|
show();
|
||||||
|
expect(visibleText()[0]).toBe('Tiles · 2');
|
||||||
|
// A pick in the menu changes it at once, the card still up.
|
||||||
|
app._rememberTileGridCount(4);
|
||||||
|
expect(visibleText()[0]).toBe('Tiles · 4');
|
||||||
|
app.openTileGrid(IDS.slice(0, 4));
|
||||||
|
expect(visibleText()).toContain('Click: close the grid');
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
expect(visibleText()).toContain('Click: open the grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a refresh with nothing changed leaves the translated text alone (it compares with the last English)', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
// What the zh-CN translator leaves in the DOM.
|
||||||
|
card()!.children[0].textContent = '平铺 · 6';
|
||||||
|
expect(textOf(lines()[0])).toBe('Click: open the grid');
|
||||||
|
lines()[0].children.at(-1)!.textContent = '单击:打开平铺网格';
|
||||||
|
app._renderTileHint();
|
||||||
|
app._updateTileGridButtonState();
|
||||||
|
expect(card()!.children[0].textContent).toBe('平铺 · 6');
|
||||||
|
expect(textOf(lines()[0])).toBe('单击:打开平铺网格');
|
||||||
|
// A real change writes the new English for the translator.
|
||||||
|
app._rememberTileGridCount(2);
|
||||||
|
expect(card()!.children[0].textContent).toBe('Tiles \u00B7 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a count the window cannot fit says what opens instead (closed) and what fits (open)', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
wrapRect = { width: 1200, height: 900 }; // fits 4
|
||||||
|
app._renderTileHint();
|
||||||
|
show();
|
||||||
|
expect(visibleText()).toEqual([
|
||||||
|
'Tiles · 6',
|
||||||
|
'Click: open the grid',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles',
|
||||||
|
'This window fits 4 tiles: a click opens 4',
|
||||||
|
]);
|
||||||
|
localStore.set('codeman:tile-count', '4');
|
||||||
|
app._renderTileHint();
|
||||||
|
expect(visibleText()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hiding', () => {
|
||||||
|
const shown = () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(card()!.hidden).toBe(false);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('on pointer leave, and a pending show is cancelled', () => {
|
||||||
|
shown();
|
||||||
|
pointer('pointerleave');
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
pointer('pointerleave');
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on blur', () => {
|
||||||
|
shown();
|
||||||
|
btn.dispatch('blur', {});
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on a press, a click or a right-click on the button, in the capture phase, and stays hidden there', () => {
|
||||||
|
for (const type of ['pointerdown', 'click', 'contextmenu']) {
|
||||||
|
resetGridHarness();
|
||||||
|
bySelector.set('.btn-tile-grid', btn);
|
||||||
|
btn.listeners = {};
|
||||||
|
btn.captureFlags = {};
|
||||||
|
shown();
|
||||||
|
btn.dispatch(type, {});
|
||||||
|
expect(card()!.hidden, type).toBe(true);
|
||||||
|
expect(btn.captureFlags[type].some(Boolean), type).toBe(true);
|
||||||
|
// Still resting on it: no card back.
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden, type).toBe(true);
|
||||||
|
// Once the pointer has left, a new rest shows it again.
|
||||||
|
pointer('pointerleave');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
expect(card()!.hidden, type).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the count menu never opens beside it: openTileCountMenu hides it first', () => {
|
||||||
|
const app = shown();
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
expect(body.children.some((c) => c.id === 'tileCountMenu')).toBe(true);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the Escape out of the menu brings no card back with the keyboard focus', () => {
|
||||||
|
const app = hintApp();
|
||||||
|
btn.classList.remove('btn-tile-grid--hidden');
|
||||||
|
(btn as unknown as { matches: (s: string) => boolean }).matches = (s) => s === ':focus-visible';
|
||||||
|
btn.focus = vi.fn(() => btn.dispatch('focus', {}));
|
||||||
|
app.openTileCountMenu({ preventDefault: vi.fn() });
|
||||||
|
app.closeTileCountMenu({ refocus: true });
|
||||||
|
expect(btn.focus).toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on Escape, a scroll and a resize, listened to only while shown', () => {
|
||||||
|
const docAdd = (windowStub.addEventListener as ReturnType<typeof vi.fn>).mock;
|
||||||
|
const before = docAdd.calls.length;
|
||||||
|
const app = shown();
|
||||||
|
const added = docAdd.calls.slice(before) as Array<[string, () => void, unknown]>;
|
||||||
|
const scroll = added.find(([t]) => t === 'scroll')!;
|
||||||
|
const resize = added.find(([t]) => t === 'resize')!;
|
||||||
|
expect(scroll[2]).toBe(true);
|
||||||
|
scroll[1]();
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
expect((windowStub.removeEventListener as ReturnType<typeof vi.fn>).mock.calls.some(([t]) => t === 'scroll')).toBe(true);
|
||||||
|
pointer('pointerleave');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
resize[1]();
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
pointer('pointerleave');
|
||||||
|
pointer('pointerenter');
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
app._tileHint.onKey({ key: 'Enter' });
|
||||||
|
expect(card()!.hidden).toBe(false);
|
||||||
|
app._tileHint.onKey({ key: 'Escape' });
|
||||||
|
expect(card()!.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden after a keyboard show, the keyboard line is back in the description', () => {
|
||||||
|
shown(); // a pointer show: the keyboard line hidden
|
||||||
|
expect(lines()[3].hidden).toBe(true);
|
||||||
|
pointer('pointerleave');
|
||||||
|
expect(lines()[3].hidden).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the CSS', () => {
|
||||||
|
it('the count menu panel, no pointer, a fade on opacity and transform only, none without hover or with reduced motion', () => {
|
||||||
|
const block = css.slice(css.indexOf('.tile-hint {'), css.indexOf('.tile-hint {') + 900);
|
||||||
|
expect(block).toContain('background: var(--floating-bg');
|
||||||
|
expect(block).toContain('border: 1px solid var(--control-border');
|
||||||
|
expect(block).toContain('pointer-events: none;');
|
||||||
|
const keyframes = css.match(/@keyframes tile-hint-in \{([\s\S]*?)\n\}/)![1];
|
||||||
|
expect([...keyframes.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]).sort()).toEqual(['opacity', 'transform']);
|
||||||
|
expect(css).toMatch(/@media \(hover: none\) \{\s*\.tile-hint \{\s*display: none !important;/);
|
||||||
|
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.tile-hint \{\s*animation: none;/);
|
||||||
|
expect(css).toMatch(/\.tile-hint\[hidden\],\s*\.tile-hint \[hidden\] \{\s*display: none;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no other header button changes: its rules are .tile-hint only', () => {
|
||||||
|
const start = css.indexOf('*/', css.indexOf("/* The Tiles button's hover card")) + 2;
|
||||||
|
const end = css.indexOf('/* ── Tile grid motion');
|
||||||
|
const rules = css.slice(start, end).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
const selectors = [...rules.matchAll(/^([^\s@}][^{]*)\{/gm)].map((m) => m[1].trim());
|
||||||
|
expect(selectors.length).toBeGreaterThan(5);
|
||||||
|
for (const sel of selectors) expect(sel, sel).toMatch(/^(\.tile-hint|from|to)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,7 +65,7 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// What may stay Latin in a translation: key names, compact durations, glyphs.
|
// What may stay Latin in a translation: key names, compact durations, glyphs.
|
||||||
const ALLOWED = /\b(Ctrl|Cmd|Shift|Alt|Option|Enter|G)\b|<1m|\b\d+[dhm]\b/g;
|
const ALLOWED = /\b(Ctrl|Cmd|Shift|Alt|Option|Enter|G|F10)\b|<1m|\b\d+[dhm]\b/g;
|
||||||
const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) ?? [];
|
const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) ?? [];
|
||||||
|
|
||||||
/** Every string a translation must handle, with where it was seen. */
|
/** Every string a translation must handle, with where it was seen. */
|
||||||
@@ -166,6 +166,16 @@ async function exercise() {
|
|||||||
// A session named like a UI string (a count menu label): user text, never translated.
|
// A session named like a UI string (a count menu label): user text, never translated.
|
||||||
app.sessions.get('s-5').name = '6 tiles';
|
app.sessions.get('s-5').name = '6 tiles';
|
||||||
|
|
||||||
|
// The Tiles button's hover card, closed: the default count, then a count
|
||||||
|
// the window cannot fit (the card is in body, harvested with it).
|
||||||
|
app._applyTileGridButtonVisibility(true);
|
||||||
|
harvestAll(app, 'hover card, closed');
|
||||||
|
wrapRect = { width: 1200, height: 900 };
|
||||||
|
app._rememberTileGridCount(6);
|
||||||
|
harvestAll(app, 'hover card, does not fit');
|
||||||
|
wrapRect = { width: 2400, height: 1200 };
|
||||||
|
app._renderTileHint();
|
||||||
|
|
||||||
// The count menu: the cap (nothing greyed), then the window wording.
|
// The count menu: the cap (nothing greyed), then the window wording.
|
||||||
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
||||||
harvestAll(app, 'count menu, cap');
|
harvestAll(app, 'count menu, cap');
|
||||||
@@ -184,6 +194,12 @@ async function exercise() {
|
|||||||
// The grid: five tiles first (an empty slot), then the sixth.
|
// The grid: five tiles first (an empty slot), then the sixth.
|
||||||
app.openTileGrid(EIGHT.slice(0, 5));
|
app.openTileGrid(EIGHT.slice(0, 5));
|
||||||
harvestAll(app, 'grid of five');
|
harvestAll(app, 'grid of five');
|
||||||
|
// The hover card with the grid open, and a count it cannot fit there.
|
||||||
|
section.getBoundingClientRect = () => ({ width: 1200, height: 900, top: 0, left: 0, right: 1200, bottom: 900 });
|
||||||
|
app._renderTileHint();
|
||||||
|
harvestAll(app, 'hover card, open, does not fit');
|
||||||
|
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||||
|
app._renderTileHint();
|
||||||
app.addTile('s-6');
|
app.addTile('s-6');
|
||||||
app._renderTileChrome();
|
app._renderTileChrome();
|
||||||
harvestAll(app, 'grid of six');
|
harvestAll(app, 'grid of six');
|
||||||
@@ -260,6 +276,12 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
|||||||
const expected = [
|
const expected = [
|
||||||
'How many tiles',
|
'How many tiles',
|
||||||
'Tiles',
|
'Tiles',
|
||||||
|
'Tiles \u00B7 6',
|
||||||
|
'Click: open the grid',
|
||||||
|
'Click: close the grid',
|
||||||
|
'Right-click: choose 2, 4 or 6 tiles',
|
||||||
|
'Shift+F10: the same menu from the keyboard',
|
||||||
|
'This window fits 4 tiles: a click opens 4',
|
||||||
'2 tiles',
|
'2 tiles',
|
||||||
'4 tiles',
|
'4 tiles',
|
||||||
'6 tiles',
|
'6 tiles',
|
||||||
@@ -384,12 +406,13 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
|
|||||||
api.start();
|
api.start();
|
||||||
api.configure({ language: 'zh-CN' });
|
api.configure({ language: 'zh-CN' });
|
||||||
|
|
||||||
it('the Tiles header button: title and accessible name', () => {
|
it('the Tiles header button: its accessible name, no native title, its description is the hover card', () => {
|
||||||
const btn = doc.querySelector('.btn-tile-grid')!;
|
const btn = doc.querySelector('.btn-tile-grid')!;
|
||||||
for (const text of [btn.getAttribute('title')!, btn.getAttribute('aria-label')!]) {
|
const label = btn.getAttribute('aria-label')!;
|
||||||
expect(text).toContain('平铺');
|
expect(label).toContain('平铺');
|
||||||
expect(leftover(text)).toEqual([]);
|
expect(leftover(label)).toEqual([]);
|
||||||
}
|
expect(btn.hasAttribute('title')).toBe(false);
|
||||||
|
expect(btn.getAttribute('aria-describedby')).toBe('tileGridHint');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the App Settings chips (Tiles, and Split beside it) and the grid region', () => {
|
it('the App Settings chips (Tiles, and Split beside it) and the grid region', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user