From 5a784ff8b150454b536e84bf35497b567868d006 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:26:16 -0400 Subject: [PATCH] Turn dashboard pages with a sideways trackpad swipe or mouse wheel --- e2e/wheel-paging.spec.js | 103 ++++++++++++++++++++++++++++++ ui/admin/index.html | 2 +- ui/index.html | 2 +- ui/js/admin-app-form.js | 8 +-- ui/js/admin-auth.js | 4 +- ui/js/admin-color-control.js | 4 +- ui/js/admin-drag.js | 2 +- ui/js/admin-list.js | 8 +-- ui/js/admin-settings.js | 8 +-- ui/js/admin-shared.js | 2 +- ui/js/admin-widget-form.js | 8 +-- ui/js/admin.js | 18 +++--- ui/js/background.js | 2 +- ui/js/dashboard-logic.js | 24 +++++++ ui/js/dashboard.js | 32 ++++++++-- ui/js/listbox.js | 2 +- ui/js/spotlight.js | 2 +- ui/js/ui.js | 2 +- ui/js/utils.js | 47 ++++++++++++++ ui/js/widget-config-form.js | 8 +-- ui/test/dashboard-logic.test.mjs | 35 +++++++++- ui/test/scrolls-sideways.test.mjs | 40 ++++++++++++ 22 files changed, 316 insertions(+), 47 deletions(-) create mode 100644 e2e/wheel-paging.spec.js create mode 100644 ui/test/scrolls-sideways.test.mjs diff --git a/e2e/wheel-paging.spec.js b/e2e/wheel-paging.spec.js new file mode 100644 index 00000000..68dd9d73 --- /dev/null +++ b/e2e/wheel-paging.spec.js @@ -0,0 +1,103 @@ +// @ts-check +/* A trackpad swipe or a mouse's sideways wheel turns the page, once per + gesture, over the dashboard and over a widget alike. A widget is a frame, and + a wheel inside it never reaches the dashboard unless it is handed on. */ + +const { test, expect } = require('@playwright/test'); +const { seedConfig, dismissSetupPrompt, app } = require('./helpers'); + +const MANY = Array.from({ length: 48 }, (_, i) => app(`a${i}`, `App ${i}`)); +const clock = { + id: 'clock1', + type: 'widget', + label: 'Clock', + widgetType: 'clock', + widgetSize: 'small', + widgetConfig: {}, +}; + +async function open(page, request) { + await seedConfig(request, { items: [clock, ...MANY], settings: { showLabels: { desktop: false } } }); + await dismissSetupPrompt(request); + await page.route('**/api/badges', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })); + await page.route('**/api/health', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })); + await page.goto('/'); + await page.locator('#dots .dot').nth(1).waitFor({ state: 'visible' }); + await page.locator('.widget iframe').first().waitFor({ state: 'attached' }); + await page.waitForFunction(() => { + const f = /** @type {HTMLIFrameElement|null} */ (document.querySelector('.widget iframe')); + return !!f && f.contentDocument?.readyState === 'complete'; + }); +} + +const current = page => page.locator('#dots .dot').evaluateAll(ds => ds.findIndex(d => d.classList.contains('on'))); + +/* One step past the threshold. Headless WebKit can hold a step back longer than + the gap that ends a gesture, so a run of small steps can count as two. */ +async function swipe(page, dx) { + await page.mouse.wheel(dx * 3, 0); + await page.waitForTimeout(400); +} + +test('a sideways swipe turns one page, a vertical one none', async ({ page, request }) => { + await open(page, request); + const box = await page.locator('#dots').boundingBox(); + await page.mouse.move((box?.x ?? 400) + 10, (box?.y ?? 400) - 40); + await page.mouse.wheel(0, 200); + await page.waitForTimeout(400); + await expect.poll(() => current(page)).toBe(0); + await swipe(page, 20); + await expect.poll(() => current(page)).toBe(1); + await swipe(page, -20); + await expect.poll(() => current(page)).toBe(0); +}); + +test('a sideways swipe over a widget turns the page too', async ({ page, request }) => { + await open(page, request); + const widget = await page.locator('.widget').first().boundingBox(); + if (!widget) throw new Error('no widget on the first page'); + await page.mouse.move(widget.x + widget.width / 2, widget.y + widget.height / 2); + await swipe(page, 20); + await expect.poll(() => current(page)).toBe(1); +}); + +test('a sideways swipe over an open folder leaves the page alone in the phone layout', async ({ page, request }) => { + const kids = Array.from({ length: 4 }, (_, i) => app(`f${i}`, `Folder app ${i}`)); + const folder = { id: 'box', type: 'folder', label: 'Box', children: kids.map(c => c.id), color: 'dark' }; + await seedConfig(request, { items: [folder, ...kids, ...MANY] }); + await dismissSetupPrompt(request); + await page.setViewportSize({ width: 700, height: 844 }); + await page.goto('/'); + await page.locator('body.is-mob').waitFor({ state: 'attached' }); + await page.locator('#dots .dot').nth(1).waitFor({ state: 'attached' }); + await page.getByRole('button', { name: 'Box folder' }).click(); + const overlay = page.locator('.folder-overlay-mobile'); + await overlay.waitFor({ state: 'visible' }); + const box = await overlay.boundingBox(); + await page.mouse.move((box?.x ?? 0) + (box?.width ?? 0) / 2, (box?.y ?? 0) + (box?.height ?? 0) / 2); + await swipe(page, 20); + expect(await current(page)).toBe(0); +}); + +test('a swipe held up by a busy page still turns one page', async ({ page, request, browserName }) => { + test.skip(browserName === 'webkit', 'WebKit stamps a wheel event when it is handled, not when it was sent'); + await open(page, request); + const box = await page.locator('#dots').boundingBox(); + await page.mouse.move((box?.x ?? 400) + 10, (box?.y ?? 400) - 40); + await page.evaluate(() => { + let seen = 0; + addEventListener('wheel', () => { + if (++seen !== 2) return; + const t = performance.now(); + while (performance.now() - t < 400); + }); + }); + const steps = []; + for (let i = 0; i < 8; i++) { + steps.push(page.mouse.wheel(20, 0)); + await new Promise(r => setTimeout(r, 16)); + } + await Promise.all(steps); + await page.waitForTimeout(400); + expect(await current(page)).toBe(1); +}); diff --git a/ui/admin/index.html b/ui/admin/index.html index 687e1e03..176ee9b6 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -301,6 +301,6 @@

General

- + diff --git a/ui/index.html b/ui/index.html index d437f9b9..99c0749c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index eff4e6ab..542d0a72 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -1,4 +1,4 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=a282f4a9'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=0d64dfd3'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111'; import { state } from '/js/admin-state.js?v=af772a1b'; @@ -24,10 +24,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=352fe8b5'; -import { createListbox } from '/js/listbox.js?v=c0187efc'; +} from '/js/admin-shared.js?v=2b48ff3f'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=63cc6464'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=3b14e88c'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 32b0cc2b..2833df8c 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,8 +1,8 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=352fe8b5'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=2b48ff3f'; import { initI18n, t } from '/js/i18n.js?v=5579776a'; import { loginErrorKey } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=a282f4a9'; +import { el, inp as inpById, qa } from '/js/utils.js?v=0d64dfd3'; import { blockingScreenFor } from '/js/config-recovery.js?v=dbe542e1'; export async function checkAuth(onLogin) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 3bcb04e1..452a45e9 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=352fe8b5'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=2b48ff3f'; import { t } from '/js/i18n.js?v=5579776a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=a282f4a9'; +import { qa, q } from '/js/utils.js?v=0d64dfd3'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index ca92fe54..5f29ba12 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -12,7 +12,7 @@ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; import { state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; -import { qa } from '/js/utils.js?v=a282f4a9'; +import { qa } from '/js/utils.js?v=0d64dfd3'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 4c514df3..79fedc28 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -14,12 +14,12 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e'; import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=c1e6f114'; -import { paintIcon } from '/js/admin-shared.js?v=352fe8b5'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=a282f4a9'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=14a608cd'; +import { paintIcon } from '/js/admin-shared.js?v=2b48ff3f'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=0d64dfd3'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=5579776a'; -import { sizeLabel } from '/js/admin-widget-form.js?v=79cb212b'; +import { sizeLabel } from '/js/admin-widget-form.js?v=8bb8feb4'; import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index 39a8f514..2e93c5f1 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=352fe8b5'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=2b48ff3f'; import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=5579776a'; @@ -11,11 +11,11 @@ import { BLOCK, } from '/js/admin-logic.js?v=fc7f0836'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=a282f4a9'; +import { el, inp, setUserText } from '/js/utils.js?v=0d64dfd3'; import { formatNumber } from '/js/format-number.js?v=349a741d'; import { serialWrites } from '/js/admin-save-logic.js?v=5811620e'; -import { renderColorControl } from '/js/admin-color-control.js?v=63cc6464'; -import { BACKDROP } from '/js/background.js?v=9345e336'; +import { renderColorControl } from '/js/admin-color-control.js?v=3b14e88c'; +import { BACKDROP } from '/js/background.js?v=fe705356'; import { ALLOWED_HOSTS_MAX, firstBadHost, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index 4002d20a..3d39d324 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,6 +1,6 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; -import { el, isolate, q } from '/js/utils.js?v=a282f4a9'; +import { el, isolate, q } from '/js/utils.js?v=0d64dfd3'; import { t } from '/js/i18n.js?v=5579776a'; import { errorAdvice } from '/js/admin-error.js?v=b0881957'; import { formatNumber } from '/js/format-number.js?v=349a741d'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 8d1d4b08..13cff436 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,11 +1,11 @@ import { state } from '/js/admin-state.js?v=af772a1b'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=352fe8b5'; -import { createListbox } from '/js/listbox.js?v=c0187efc'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=92a828ab'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=2b48ff3f'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=02ffdaba'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; import { t } from '/js/i18n.js?v=5579776a'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=a282f4a9'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=0d64dfd3'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 65b65c36..c976ed14 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,7 +1,7 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=a457a197'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=3c46af12'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=7e288a05'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=db741421'; import { recoveryShown } from '/js/config-recovery.js?v=dbe542e1'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=35fcb4b0'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=e7e8ec6e'; import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { afterImport, @@ -21,7 +21,7 @@ import { settingsDirty, showBgFields, showWallpaperFile, -} from '/js/admin-settings.js?v=6bde63e6'; +} from '/js/admin-settings.js?v=a5dc252e'; import { apiGet, apiPost, @@ -35,13 +35,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=352fe8b5'; +} from '/js/admin-shared.js?v=2b48ff3f'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=79cb212b'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=8bb8feb4'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { formatNumber } from '/js/format-number.js?v=349a741d'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=c0187efc'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=5579776a'; import { loadLocalIcons } from '/js/icons.js?v=9c7b5111'; @@ -80,8 +80,8 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=a282f4a9'; -import { applyBackground, resolveBackground } from '/js/background.js?v=9345e336'; +} from '/js/utils.js?v=0d64dfd3'; +import { applyBackground, resolveBackground } from '/js/background.js?v=fe705356'; import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); diff --git a/ui/js/background.js b/ui/js/background.js index 85193713..9214749c 100644 --- a/ui/js/background.js +++ b/ui/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=a282f4a9'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=0d64dfd3'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/ui/js/dashboard-logic.js b/ui/js/dashboard-logic.js index f6b7474c..25708de5 100644 --- a/ui/js/dashboard-logic.js +++ b/ui/js/dashboard-logic.js @@ -102,3 +102,27 @@ export function restorePage(stored, totalPages) { export function landingAfterSetup(items) { return Array.isArray(items) && items.length === 0 ? '/admin' : null; } + +/** One page per sideways wheel gesture. A trackpad swipe or a thumb wheel sends + a stream of events, momentum included. The page turns once the stream has + travelled far enough sideways, and not again until the stream pauses. + + @param {{ threshold?: number, quietMs?: number }} [opts] + @returns {(dx: number, dy: number, t: number) => -1 | 0 | 1} */ +export function wheelPager({ threshold = 40, quietMs = 220 } = {}) { + let sum = 0; + let last = Number.NEGATIVE_INFINITY; + let spent = false; + return (dx, dy, t) => { + if (t - last > quietMs) { + sum = 0; + spent = false; + } + last = t; + if (spent || Math.abs(dx) <= Math.abs(dy)) return 0; + sum += dx; + if (Math.abs(sum) < threshold) return 0; + spent = true; + return sum > 0 ? 1 : -1; + }; +} diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index e0ab7d3b..b15a94c1 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -22,14 +22,15 @@ import { q, qa, qi, + scrollsSideways, setUserText, storeGet, storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=a282f4a9'; +} from '/js/utils.js?v=0d64dfd3'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=2346ae96'; +import { initSpotlight } from '/js/spotlight.js?v=e89d7f89'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=5579776a'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=dbe542e1'; @@ -45,7 +46,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=f46cc64f'; +} from '/js/ui.js?v=3c85d5d7'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b'; import { formatNumber } from '/js/format-number.js?v=349a741d'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=1e0ab5d1'; @@ -56,8 +57,9 @@ import { desktopPages, landingAfterSetup, restorePage, -} from '/js/dashboard-logic.js?v=0d519f8b'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=9345e336'; + wheelPager, +} from '/js/dashboard-logic.js?v=6a29cedc'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=fe705356'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; @@ -916,6 +918,26 @@ async function boot() { if (e.key === 'ArrowRight') goTo(pg + pageDir()); if (e.key === 'ArrowLeft') goTo(pg - pageDir()); }); + /* A trackpad swipe or a thumb wheel turns one page per gesture. Widget frames + hand their wheel to the card, so this also runs over widgets. */ + const wheelStep = wheelPager(); + document.addEventListener( + 'wheel', + e => { + if (e.ctrlKey) return; + if (el('spot').classList.contains('on')) return; + if (document.querySelector('.folder-overlay, .folder-overlay-mobile')) return; + const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerWidth : 1; + const dx = e.deltaX * unit, + dy = e.deltaY * unit; + if (Math.abs(dx) <= Math.abs(dy) || scrollsSideways(e.target, dx)) return; + /* Unhandled, a sideways swipe is the browser's back and forward gesture. */ + e.preventDefault(); + const step = wheelStep(dx, dy, e.timeStamp); + if (step) goTo(pg + step * pageDir()); + }, + { passive: false }, + ); let _dMx = 0, _dDragging = false; document.addEventListener('mousedown', e => { diff --git a/ui/js/listbox.js b/ui/js/listbox.js index fa5db9cf..63f1d8d8 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -9,7 +9,7 @@ import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=a282f4a9'; +import { qa } from '/js/utils.js?v=0d64dfd3'; import { iconSvg } from '/js/icon-set.js?v=628341d4'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 82e98ec3..9e43568e 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,5 +1,5 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=a282f4a9'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=0d64dfd3'; import { t } from '/js/i18n.js?v=5579776a'; import { formatNumber } from '/js/format-number.js?v=349a741d'; diff --git a/ui/js/ui.js b/ui/js/ui.js index bfc906e1..f6983414 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -16,7 +16,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=a282f4a9'; +} from '/js/utils.js?v=0d64dfd3'; import { t, currentLang } from '/js/i18n.js?v=5579776a'; import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; diff --git a/ui/js/utils.js b/ui/js/utils.js index 6d79f49c..d81e2bda 100644 --- a/ui/js/utils.js +++ b/ui/js/utils.js @@ -291,6 +291,21 @@ const isCrossOrigin = src => { } }; +/** Whether a wheel moving dx sideways belongs to something under the pointer + that can still scroll that way. + + @param {EventTarget | null} target @param {number} dx */ +export function scrollsSideways(target, dx) { + for (let n = /** @type {any} */ (target); n && n.nodeType === 1; n = n.parentElement) { + const view = n.ownerDocument && n.ownerDocument.defaultView; + if (!view || n.scrollWidth <= n.clientWidth) continue; + if (!/(auto|scroll)/.test(view.getComputedStyle(n).overflowX)) continue; + const left = Math.abs(n.scrollLeft); + if (dx > 0 ? left + n.clientWidth < n.scrollWidth - 1 : left > 0) return true; + } + return false; +} + /** @param {HTMLElement} card @param {{ src?: string, title?: string, design?: [number, number], @@ -348,11 +363,43 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla if (doc && doc.documentElement) doc.documentElement.style.zoom = ifr.getAttribute('data-zoom') || ''; } catch {} }; + /* A wheel over a frame never reaches the dashboard. Hand a bundled page's + wheel to the card, so sideways scrolling turns pages over widgets too. */ + let wheelWin = null; + const forwardWheel = e => { + if (e.ctrlKey || scrollsSideways(e.target, e.deltaX)) return; + const copy = new WheelEvent('wheel', { + deltaX: e.deltaX, + deltaY: e.deltaY, + deltaMode: e.deltaMode, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(copy, 'timeStamp', { + value: e.timeStamp + (wheelWin?.performance.timeOrigin ?? performance.timeOrigin) - performance.timeOrigin, + }); + card.dispatchEvent(copy); + if (copy.defaultPrevented) e.preventDefault(); + }; + const attachWheel = () => { + try { + if (wheelWin) wheelWin.removeEventListener('wheel', forwardWheel); + wheelWin = ifr.contentWindow; + if (wheelWin) wheelWin.addEventListener('wheel', forwardWheel, { passive: false }); + } catch { + wheelWin = null; + } + }; ifr.addEventListener('load', applyDir); ifr.addEventListener('load', applyZoom); + if (zoomable) ifr.addEventListener('load', attachWheel); cleanups.push(() => { ifr.removeEventListener('load', applyDir); ifr.removeEventListener('load', applyZoom); + ifr.removeEventListener('load', attachWheel); + try { + if (wheelWin) wheelWin.removeEventListener('wheel', forwardWheel); + } catch {} }); try { if (ifr.contentDocument && ifr.contentDocument.readyState === 'complete') applyDir(); diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index dfa49314..d85d2ef2 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=5579776a'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=352fe8b5'; -import { createListbox } from '/js/listbox.js?v=c0187efc'; -import { renderColorControl } from '/js/admin-color-control.js?v=63cc6464'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=2b48ff3f'; +import { createListbox } from '/js/listbox.js?v=35d8318a'; +import { renderColorControl } from '/js/admin-color-control.js?v=3b14e88c'; import { seedCarried, applyOptionSet, @@ -15,7 +15,7 @@ import { visibleFieldFlags, } from '/js/admin-logic.js?v=fc7f0836'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957'; -import { qi } from '/js/utils.js?v=a282f4a9'; +import { qi } from '/js/utils.js?v=0d64dfd3'; import { formatNumber } from '/js/format-number.js?v=349a741d'; const PE = diff --git a/ui/test/dashboard-logic.test.mjs b/ui/test/dashboard-logic.test.mjs index 22710095..b32f4ca0 100644 --- a/ui/test/dashboard-logic.test.mjs +++ b/ui/test/dashboard-logic.test.mjs @@ -5,7 +5,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { configChanged, desktopCols, desktopPages, landingAfterSetup, restorePage } from '../js/dashboard-logic.js'; +import { + configChanged, + desktopCols, + desktopPages, + landingAfterSetup, + restorePage, + wheelPager, +} from '../js/dashboard-logic.js'; const SPANS = { app: [1, 1], small: [1, 0], medium: [2, 0], large: [2, 2], xlarge: [2, 3] }; const pagesOf = (kinds, cols, rows) => @@ -188,3 +195,29 @@ test('anything that is not a list leaves the browser alone', () => { assert.equal(landingAfterSetup(v), null, `should not redirect on ${JSON.stringify(v)}`); } }); + +/* A trackpad swipe: about 30 events, 16 ms apart, with momentum fading out. */ +const swipe = (step, t0, dx = 1) => + Array.from({ length: 30 }, (_, i) => step(dx * Math.max(1, 30 - i), 0, t0 + i * 16)).filter(Boolean); + +test('one sideways swipe turns exactly one page', () => { + const step = wheelPager(); + assert.deepEqual(swipe(step, 0), [1]); +}); + +test('the swipe direction picks the page', () => { + assert.deepEqual(swipe(wheelPager(), 0, -1), [-1]); +}); + +test('a second swipe after a pause turns the next page', () => { + const step = wheelPager(); + assert.deepEqual([...swipe(step, 0), ...swipe(step, 30 * 16 + 300)], [1, 1]); +}); + +test('vertical scrolling and a small sideways nudge turn nothing', () => { + const step = wheelPager(); + assert.equal(step(30, 80, 0), 0, 'mostly vertical'); + assert.equal(step(10, 0, 16), 0); + assert.equal(step(10, 0, 32), 0); + assert.equal(step(5, 0, 600), 0, 'the nudge was forgotten after the pause'); +}); diff --git a/ui/test/scrolls-sideways.test.mjs b/ui/test/scrolls-sideways.test.mjs new file mode 100644 index 00000000..4580328a --- /dev/null +++ b/ui/test/scrolls-sideways.test.mjs @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); +const { scrollsSideways } = await import('../js/utils.js'); + +const view = { getComputedStyle: n => ({ overflowX: n.ox }) }; +const node = (props, parent = null) => ({ + nodeType: 1, + ownerDocument: { defaultView: view }, + parentElement: parent, + ox: 'visible', + scrollWidth: 100, + clientWidth: 100, + scrollLeft: 0, + ...props, +}); + +test('a wheel over a plain page belongs to the pager', () => { + assert.equal(scrollsSideways(node({}, node({})), 50), false); +}); + +test('a strip that can still scroll that way keeps the wheel', () => { + const strip = node({ ox: 'auto', scrollWidth: 400, clientWidth: 100, scrollLeft: 100 }); + const inner = node({}, strip); + assert.equal(scrollsSideways(inner, 30), true, 'room to the right'); + assert.equal(scrollsSideways(inner, -30), true, 'room to the left'); +}); + +test('a strip already at its end hands the wheel to the pager', () => { + const atEnd = node({ ox: 'auto', scrollWidth: 400, clientWidth: 100, scrollLeft: 300 }); + assert.equal(scrollsSideways(atEnd, 30), false); + const atStart = node({ ox: 'scroll', scrollWidth: 400, clientWidth: 100, scrollLeft: 0 }); + assert.equal(scrollsSideways(atStart, -30), false); +}); + +test('overflow that is clipped, not scrollable, does not count', () => { + assert.equal(scrollsSideways(node({ ox: 'hidden', scrollWidth: 400, clientWidth: 100 }), 30), false); +});