Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Notification badges inside a folder no longer get trimmed at the page edge or
bleed into the next page.

- Opening a folder no longer puts a focus ring on the first app, on a phone or
on the desktop.

- A long error in Admin now stays on screen long enough to read, up to fifteen
seconds.

Expand Down
6 changes: 5 additions & 1 deletion ui/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -608,9 +608,13 @@
.dyn-fold-sheen { position:absolute; inset:0; pointer-events:none; z-index:2; border-radius:inherit; background:linear-gradient(135deg,rgba(255,255,255,.12) 0%,transparent 55%); }
.dyn-clip { overflow:hidden; flex-shrink:0; width:var(--gw); height:var(--gh); }
.dyn-strip { display:flex; height:var(--gh); will-change:transform; transition:transform .3s cubic-bezier(.4,0,.2,1); }
.dyn-page-grid { flex-shrink:0; display:grid; gap:var(--gap); justify-content:center; align-content:start; width:var(--gw); height:var(--gh); grid-template-columns:repeat(3,var(--fiw)); grid-template-rows:repeat(3,var(--fiw)); }
.dyn-page-grid { flex-shrink:0; display:grid; gap:var(--gap); justify-content:center; align-content:start; width:var(--gw); height:var(--gh); padding:var(--ovh,0); overflow:hidden; grid-template-columns:repeat(3,var(--fiw)); grid-template-rows:repeat(3,var(--fiw)); }
.dyn-dot { border-radius:50%; cursor:pointer; flex-shrink:0; width:var(--dsz); height:var(--dsz); }
.dyn-dots-row { display:flex; justify-content:center; align-items:center; flex-shrink:0; width:100%; }
/* WebKit paints its own focus ring on a script-focused anchor. Only
:focus-visible is styled below. */
.folder-overlay-mobile:focus, .dyn-fold-anchor:focus,
.folder-overlay:focus, .folder-icon-link:focus { outline:none; }
.dyn-fold-anchor { display:flex; flex-direction:column; align-items:center; gap:4px; text-decoration:none; -webkit-tap-highlight-color:transparent; }
.dyn-fold-inner-label { font-weight:400; color:rgba(255,255,255,.90); text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:0 1px 3px rgba(0,0,0,.6); font-size:var(--lfs); width:var(--fiw); }
.dyn-title-mob { position:absolute; pointer-events:none; font-size:var(--tfs); }
Expand Down
36 changes: 24 additions & 12 deletions ui/js/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export function openFolderDesktop(folder) {
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder');
ov.tabIndex = -1;
const _prevFocus = /** @type {HTMLElement} */ (document.activeElement);
let releaseDeskTrap = null;
const outer = mk('div');
Expand Down Expand Up @@ -234,7 +235,7 @@ export function openFolderDesktop(folder) {
folderOverlay = ov;
/* The dashboard behind stays focusable while covered, so the overlay needs a
Tab trap. Attach it after the overlay is in the document. */
releaseDeskTrap = trapFocus(ov, { closeOnEscape: false, onClose: closeDesk });
releaseDeskTrap = trapFocus(ov, { closeOnEscape: false, onClose: closeDesk, initialFocus: ov });
}

function mFolder(item, cw, rh, isz, ir, im, sc) {
Expand Down Expand Up @@ -332,7 +333,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) {
}

let folderOverlayMob = null;
export function openFolderMobile(folder, isz, _ir, _im, _sc) {
export function openFolderMobile(folder, isz, _ir, _im, sc) {
if (folderOverlayMob) {
folderOverlayMob.remove();
folderOverlayMob = null;
Expand All @@ -349,6 +350,7 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) {
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.setAttribute('aria-label', (folder.label || t('type.folder')) + ' folder');
ov.tabIndex = -1;

let releaseMobTrap = null;
function closeMob() {
Expand All @@ -374,6 +376,11 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) {
folderIm = Math.round(folderIconW * 0.64);
const gridInnerW = folderIconW * 3 + gap * 2,
gridH = folderIconW * 3 + gap * 2;
/* A badge sits 7px outside its icon corner. Without this inset the outer
icons' badges cross into the next page and the viewport shows them. */
const badgeOvh = Math.min(Math.ceil(7 * (sc || 1)), padH, padVT, padVB);
const pageW = gridInnerW + badgeOvh * 2,
pageH = gridH + badgeOvh * 2;
const dotSz = Math.round(7 * ptScale);
const dotsZoneH = pages.length > 1 ? Math.round(26 * ptScale) : 0;
const boxH = padVT + gridH + padVB + dotsZoneH;
Expand Down Expand Up @@ -405,29 +412,35 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) {
'--bh': boxH + 'px',
'--top': boxTop + 'px',
'--br': boxR + 'px',
'--pt': padVT + 'px',
'--ph': padH + 'px',
'--pb': padVB + 'px',
'--pt': padVT - badgeOvh + 'px',
'--ph': padH - badgeOvh + 'px',
'--pb': padVB - badgeOvh + 'px',
});

const clipW = mk('div');
clipW.className = 'dyn-clip';
css(clipW, { '--gw': gridInnerW + 'px', '--gh': gridH + 'px' });
css(clipW, { '--gw': pageW + 'px', '--gh': pageH + 'px' });
const strip = mk('div');
strip.className = 'dyn-strip';
css(strip, { '--gh': gridH + 'px', width: pages.length * gridInnerW + 'px' });
css(strip, { '--gh': pageH + 'px', width: pages.length * pageW + 'px' });

let dotEls = [];
function gotoPage(n) {
curPage = Math.max(0, Math.min(pages.length - 1, n));
strip.style.transform = strip.style.webkitTransform = `translateX(-${curPage * gridInnerW}px)`;
strip.style.transform = strip.style.webkitTransform = `translateX(-${curPage * pageW}px)`;
dotEls.forEach((d, j) => d.classList.toggle('on', j === curPage));
}

function buildPage(apps) {
const p = mk('div');
p.className = 'dyn-page-grid';
css(p, { '--gw': gridInnerW + 'px', '--gh': gridH + 'px', '--gap': gap + 'px', '--fiw': folderIconW + 'px' });
css(p, {
'--gw': pageW + 'px',
'--gh': pageH + 'px',
'--gap': gap + 'px',
'--fiw': folderIconW + 'px',
'--ovh': badgeOvh + 'px',
});
for (let i = 0; i < 9; i++) {
const child = apps[i];
if (child) {
Expand Down Expand Up @@ -459,7 +472,7 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) {
dotsEl = mk('div');
dotsEl.className = 'folder-dots dyn-dots-row';
css(dotsEl, {
padding: `${Math.round(18 * ptScale)}px 0 ${Math.round(4 * ptScale)}px`,
padding: `${Math.max(0, Math.round(18 * ptScale) - badgeOvh)}px 0 ${Math.round(4 * ptScale)}px`,
gap: Math.round(7 * ptScale) + 'px',
});
dotEls = pages.map((_, i) => {
Expand Down Expand Up @@ -531,8 +544,7 @@ export function openFolderMobile(folder, isz, _ir, _im, _sc) {
);
document.body.appendChild(ov);
folderOverlayMob = ov;
/* After the overlay is in the document: the trap focuses its first control. */
releaseMobTrap = trapFocus(ov, { onClose: closeMob });
releaseMobTrap = trapFocus(ov, { onClose: closeMob, initialFocus: ov });
}

export function buildMobile() {
Expand Down
12 changes: 10 additions & 2 deletions ui/test/dialog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,16 @@ test('trapFocus tolerates a missing root', async () => {
test('all four dialogs use the shared behaviour', () => {
const sites = [
['js/spotlight.js', /wrapTab\(e, ov\)/, 'the search overlay'],
['js/ui.js', /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk \}\)/, 'the desktop folder overlay'],
['js/ui.js', /releaseMobTrap = trapFocus\(ov, \{ onClose: closeMob \}\)/, 'the mobile folder overlay'],
[
'js/ui.js',
/trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk, initialFocus: ov \}\)/,
'the desktop folder overlay',
],
[
'js/ui.js',
/releaseMobTrap = trapFocus\(ov, \{ onClose: closeMob, initialFocus: ov \}\)/,
'the mobile folder overlay',
],
['js/dashboard.js', /trapFocus\(ov, \{ closeOnEscape: false, initialFocus: pw \}\)/, 'the setup prompt'],
];
for (const [file, pattern, what] of sites) {
Expand Down
111 changes: 111 additions & 0 deletions ui/test/folder-overlay.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* Regression tests for the mobile folder overlay.

Two defects, both in the overlay that opens when a folder is tapped on a
phone.

A badge sits outside its icon's top-right corner by design. The pages of a
folder were laid out flush against each other in one strip, each page exactly
as wide as three icons and two gaps, and one viewport clipped the strip. A
badge on the right-hand column therefore rendered in the next page's space:
trimmed while its own page was shown, and visible as a sliver at the left
edge after a swipe. The top row lost the same 7px upwards.

The fix insets each page by the overhang and lets the page clip itself, so a
badge stays inside the page it belongs to. The inset comes out of the box
padding, so the icons do not move.

The second defect: the overlay trapped focus without saying what to focus,
the trap fell back to the first focusable element, and that is the first app
link. iOS Safari paints its own focus ring for a script-driven focus on an
anchor, which the stylesheet only styles for :focus-visible. Opening a folder
also should not move focus onto an item nobody asked for.

Source-shape tests: the geometry is written in JavaScript against a real
layout, which is not available here. */

import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');

const ui = read('js/ui.js');
const css = read('css/dashboard.css');

const overlay = ui.slice(ui.indexOf('export function openFolderMobile('), ui.indexOf('export function buildMobile('));
assert.ok(overlay.length > 0, 'openFolderMobile was renamed or moved');

test('the page is inset by the badge overhang', () => {
assert.match(
overlay,
/badgeOvh = Math\.min\(Math\.ceil\(7 \* \(sc \|\| 1\)\)/,
'the inset must follow the badge scale',
);
assert.match(overlay, /pageW = gridInnerW \+ badgeOvh \* 2/);
assert.match(overlay, /pageH = gridH \+ badgeOvh \* 2/);
});

test('the inset comes out of the box padding, so the icons keep their place', () => {
assert.match(overlay, /'--pt': padVT - badgeOvh/);
assert.match(overlay, /'--ph': padH - badgeOvh/);
assert.match(overlay, /'--pb': padVB - badgeOvh/);
});

test('the inset never exceeds the padding it is taken from', () => {
assert.match(overlay, /Math\.min\(Math\.ceil\(7 \* \(sc \|\| 1\)\), padH, padVT, padVB\)/);
});

test('the icon grid itself is unchanged', () => {
assert.match(overlay, /folderIconW = Math\.min\(Math\.floor\(\(innerW - gap \* 2\) \/ 3\), isz\)/);
assert.match(css, /\.dyn-page-grid\s*{[^}]*grid-template-columns:repeat\(3,var\(--fiw\)\)/);
});

test('the strip is measured and moved in padded pages', () => {
assert.match(overlay, /width: pages\.length \* pageW \+ 'px'/);
assert.match(overlay, /translateX\(-\$\{curPage \* pageW\}px\)/);
assert.doesNotMatch(overlay, /curPage \* gridInnerW/, 'a page step shorter than the page leaves the seam visible');
});

test('the viewport is one padded page', () => {
assert.match(overlay, /css\(clipW, \{ '--gw': pageW \+ 'px', '--gh': pageH \+ 'px' \}\)/);
});

test('a page clips its own overflow', () => {
const rule = css.match(/\.dyn-page-grid\s*{[^}]*}/);
assert.ok(rule, '.dyn-page-grid rule not found');
assert.match(rule[0], /overflow:hidden/, 'without this the badge crosses into the next page');
assert.match(rule[0], /padding:var\(--ovh,0\)/);
assert.match(overlay, /'--ovh': badgeOvh \+ 'px'/);
});

test('the overlay takes focus itself, not the first app', () => {
assert.match(overlay, /ov\.tabIndex = -1/);
assert.match(overlay, /trapFocus\(ov, \{ onClose: closeMob, initialFocus: ov \}\)/);
assert.doesNotMatch(overlay, /trapFocus\(ov, \{ onClose: closeMob \}\)/);
});

test('the native focus ring cannot render on a folder app link', () => {
assert.match(css, /\.folder-overlay-mobile:focus, \.dyn-fold-anchor:focus,/);
assert.match(css, /\.folder-overlay:focus, \.folder-icon-link:focus \{ outline:none; \}/);
for (const [reset, visible] of [
['.dyn-fold-anchor:focus,', '.dyn-fold-anchor:focus-visible'],
['.folder-icon-link:focus ', '.folder-icon-link:focus-visible'],
]) {
assert.ok(
css.indexOf(visible) > css.indexOf(reset),
`${visible}: the accent ring must be declared after the reset or it never applies`,
);
}
});

/* Desktop Safari showed the same ring the phone did: same trap, same missing
plain-focus rule. */
test('the desktop overlay takes focus itself too', () => {
const desktop = ui.slice(ui.indexOf('export function openFolderDesktop('), ui.indexOf('function mFolder('));
assert.match(desktop, /ov\.tabIndex = -1/);
assert.match(desktop, /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk, initialFocus: ov \}\)/);
assert.doesNotMatch(desktop, /trapFocus\(ov, \{ closeOnEscape: false, onClose: closeDesk \}\)/);
});
Loading