Merge branch 'feat/do-menu-winctl' into feat/do-menu-fixes

This commit is contained in:
Linus Rath
2026-11-04 07:50:16 +01:00
51 changed files with 494 additions and 107 deletions
@@ -0,0 +1,47 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { CLOSE_MOVES_ATTRIBUTE, CONTROLS_ATTRIBUTE } from '@/lib/window-controls-overlay';
const root = document.documentElement;
afterEach(() => {
cleanup();
root.removeAttribute(CONTROLS_ATTRIBUTE);
});
function buttonsOfHeader(): HTMLElement[] {
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
return Array.from(header.querySelectorAll('button'));
}
describe('a pane\'s close beside the window\'s', () => {
const apps = [{ id: 'notes', name: 'Notes', url: 'https://notes.example.org' }] as never;
it('is at the end of its row without window controls', () => {
render(<InlineAppView apps={apps} activeAppId="notes" onClose={() => {}} />);
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
expect(header.lastElementChild?.tagName).toBe('BUTTON');
});
it('moves to the start, in the page\'s order too, when the row ends at the window\'s controls', async () => {
root.setAttribute(CONTROLS_ATTRIBUTE, '');
render(<InlineAppView apps={apps} activeAppId="notes" onClose={() => {}} />);
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
act(() => header.setAttribute(CLOSE_MOVES_ATTRIBUTE, ''));
await waitFor(() => expect(header.firstElementChild?.tagName).toBe('BUTTON'));
// Tab reaches it first, where it's seen.
expect(buttonsOfHeader()[0]).toBe(screen.getByRole('button', { name: 'close' }));
// The window narrows and the row no longer ends there: back to the end.
act(() => header.removeAttribute(CLOSE_MOVES_ATTRIBUTE));
await waitFor(() => expect(header.lastElementChild?.tagName).toBe('BUTTON'));
});
it('stays at the end when the window has no controls over the page, whatever the row says', async () => {
render(<InlineAppView apps={apps} activeAppId="notes" onClose={() => {}} />);
const header = document.querySelector<HTMLElement>('[data-window-top-row]')!;
act(() => header.setAttribute(CLOSE_MOVES_ATTRIBUTE, ''));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(header.lastElementChild?.tagName).toBe('BUTTON');
});
});
+48 -8
View File
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ROW_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, markWindowRows } from '@/lib/window-controls-overlay';
import { CLOSE_MOVES_ATTRIBUTE, PANE_HEAD_ATTRIBUTE, ROW_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, markWindowRows } from '@/lib/window-controls-overlay';
/** The controls take the right 138px of the window, 56px high. */
const AREA = { x: 0, y: 0, width: 1024 - 138, height: 56 };
@@ -67,14 +67,42 @@ describe('rows under the window\'s controls', () => {
const read = (file: string) => readFileSync(join(process.cwd(), file), 'utf8');
describe('a pane\'s close next to the window\'s', () => {
it('moves to the other end of the row, on the side away from the controls', () => {
const css = read('app/globals.css');
expect(css).toMatch(/html\[data-window-controls\]:not\(\[dir="rtl"\]\) \[data-window-row-right\] > \[data-pane-close\]/);
expect(css).toMatch(/html\[data-window-controls\]\[dir="rtl"\] \[data-window-row-left\] > \[data-pane-close\]/);
describe("a close at the end of a row beside the window's", () => {
it('is marked on a top row whose end has the controls', () => {
const toolbar = row();
box(toolbar, 0, 56);
markWindowRows(AREA);
expect(toolbar.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(true);
});
it('is marked on every pane that can end up at the window\'s top right', () => {
it("isn't, right to left, where the row ends at the left and the controls are at the right", () => {
document.documentElement.dir = 'rtl';
try {
const toolbar = row();
box(toolbar, 0, 56);
markWindowRows(AREA);
expect(toolbar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
expect(toolbar.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(false);
} finally {
document.documentElement.dir = '';
}
});
it("is marked on a side panel's head right under the controls, and not further down", () => {
const head = document.createElement('div');
head.setAttribute(PANE_HEAD_ATTRIBUTE, '');
document.body.appendChild(head);
box(head, 60, 40, 700, 1000);
markWindowRows(AREA);
expect(head.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(true);
// No room is made in it: it's below the controls.
expect(head.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
box(head, 200, 40, 700, 1000);
markWindowRows(AREA);
expect(head.hasAttribute(CLOSE_MOVES_ATTRIBUTE)).toBe(false);
});
it("is asked by every pane that can end up at the window's top right", () => {
for (const file of [
'components/email/email-composer.tsx',
'components/layout/inline-app-view.tsx',
@@ -82,12 +110,24 @@ describe('a pane\'s close next to the window\'s', () => {
'components/files/preview-shell.tsx',
'components/calendar/task-modal.tsx',
'components/contacts/contact-form.tsx',
'components/offline/offline-banner.tsx',
'components/org/org-announcement.tsx',
'components/plugins/plugin-side-panels.tsx',
'components/layout/mv-side-panel.tsx',
]) {
expect(read(file), file).toContain('data-pane-close');
expect(read(file), file).toContain('useCloseBesideControls(');
}
});
});
describe('notices at the top', () => {
it('toasts that step out of the way stay below the controls', () => {
expect(read('app/globals.css')).toMatch(/html\[data-window-controls\] \[data-toast-container\]\[data-dodging\]\s*\{[^}]*--titlebar-row-height/);
});
it("the offline bar and the organisation's notice are rows that make room for the controls", () => {
for (const file of ['components/offline/offline-banner.tsx', 'components/org/org-announcement.tsx']) {
expect(read(file), file).toContain('data-window-top-row=""');
}
});
});
+21 -2
View File
@@ -44,6 +44,14 @@ export const ROW_ATTRIBUTE = 'data-window-row';
/** The window's controls are over this end of it. */
export const ROW_RIGHT_ATTRIBUTE = 'data-window-row-right';
export const ROW_LEFT_ATTRIBUTE = 'data-window-row-left';
/**
* The end of this row or pane head, where its own close usually goes, is at the
* window's controls: a close there would read as a second one for the window, so
* it goes to the other end (hooks/use-close-beside-controls.ts).
*/
export const CLOSE_MOVES_ATTRIBUTE = 'data-window-close-moves';
/** The head of a pane at the window's end, a row below the controls: its close can sit right under the window's. */
export const PANE_HEAD_ATTRIBUTE = 'data-window-pane-head';
function overlay(): OverlayLike | null {
if (typeof navigator === 'undefined') return null;
@@ -110,6 +118,8 @@ const MAX_ROW_HEIGHT = 96;
export function markWindowRows(area: TitlebarArea, doc: Document = document): HTMLElement[] {
const viewportWidth = doc.defaultView?.innerWidth ?? 0;
const { left, right } = controlsInsets(area, viewportWidth);
// A row's own close is at its end: the right, or the left in a right-to-left page.
const rtl = doc.documentElement.dir === 'rtl';
const seen = new Set<HTMLElement>();
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(ROW_CANDIDATES))) {
const rect = el.getBoundingClientRect();
@@ -127,12 +137,21 @@ export function markWindowRows(area: TitlebarArea, doc: Document = document): HT
el.removeAttribute(ROW_ATTRIBUTE);
el.removeAttribute(ROW_RIGHT_ATTRIBUTE);
el.removeAttribute(ROW_LEFT_ATTRIBUTE);
el.removeAttribute(CLOSE_MOVES_ATTRIBUTE);
continue;
}
seen.add(el);
const onRight = right > 0 && rect.right > viewportWidth - right;
const onLeft = left > 0 && rect.left < left;
el.toggleAttribute(ROW_ATTRIBUTE, atTop);
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, right > 0 && rect.right > viewportWidth - right);
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, left > 0 && rect.left < left);
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, onRight);
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, onLeft);
el.toggleAttribute(CLOSE_MOVES_ATTRIBUTE, rtl ? onLeft : onRight);
}
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(`[${PANE_HEAD_ATTRIBUTE}]`))) {
const rect = el.getBoundingClientRect();
const near = rect.height > 0 && rect.top < area.height * 2;
el.toggleAttribute(CLOSE_MOVES_ATTRIBUTE, near && (rtl ? left > 0 && rect.left < left : right > 0 && rect.right > viewportWidth - right));
}
return [...seen];
}