diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5f8044..1b494bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index cee9d598..3aba6a6e 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -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); } diff --git a/ui/js/ui.js b/ui/js/ui.js index 15464acf..4f2b98f5 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -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'); @@ -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) { @@ -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; @@ -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() { @@ -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; @@ -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) { @@ -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) => { @@ -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() { diff --git a/ui/test/dialog.test.mjs b/ui/test/dialog.test.mjs index 4e7557e1..1a9f1820 100644 --- a/ui/test/dialog.test.mjs +++ b/ui/test/dialog.test.mjs @@ -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) { diff --git a/ui/test/folder-overlay.test.mjs b/ui/test/folder-overlay.test.mjs new file mode 100644 index 00000000..43a4f910 --- /dev/null +++ b/ui/test/folder-overlay.test.mjs @@ -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 \}\)/); +});