mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/do-menu-winctl' into feat/do-menu-fixes
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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=""');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user