mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +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:
@@ -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)/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user