From 2c17476c07c02a0c2c335f5cbb834d3ab48a53f6 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sat, 8 Aug 2026 01:28:09 -0700 Subject: [PATCH] fix(gui): folder pages, live drag-out, and one merge pulse in My Apps Three fixes to the My Apps folders: A folder big enough to need it now paginates like the grid outside (iOS folders page too) instead of growing a scrollbar: pages of cols x 3 tiles in a scroll-snap scroller with dots below, wheel and swipe to flip, and a drag held at the card's edge flips folder pages so reordering works across them. The card's height stays constant across pages, and a deep-link landing on an app that lives on a later folder page travels there before the launch morph. Dragging an app out of the open folder no longer drops it blind: after a beat held outside the card, the app leaves the folder, the card closes behind it, and the SAME drag carries on over the grid -- placeholder shuffle, edge page flips, even a drop into another folder -- so the app lands where the user watched themselves put it. A quick drop during the beat still lands beside the folder, and swinging back inside within the beat cancels cleanly. The folder well's fill played twice on nearly every merge: the rest countdown anchored at first contact with the target, so the hand decelerating INTO the tile always read as movement when the dwell elapsed, and the deliberate refill-from-empty replayed the fill. The countdown now re-anchors on the move events themselves -- rest is only visible at its edges -- so the fill that completes is the one started by the last movement, played exactly once. --- src/gui/src/UI/Dashboard/TabApps.js | 367 ++++++++++++++++++++++++---- src/gui/src/css/dashboard.css | 74 ++++-- 2 files changed, 377 insertions(+), 64 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 41971498c..4657a27ed 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -88,9 +88,19 @@ const DRAG_MERGE_DROP_MS = 240; // ghost dropping into the folder it joined // A tile dragged this far outside the open folder's card leaves the folder // (iOS: drag an app out of a folder and it lands back on the home screen). const GROUP_EJECT_MARGIN = 24; +// How long the icon must stay carried outside the card before the folder +// closes under it and the SAME drag carries on over the grid, placeholder +// and page flips included, so the user PLACES the app rather than +// discovering where a blind drop put it (see _ejectToGrid). A beat of +// forgiveness first: an overshoot can swing straight back in. +const GROUP_EJECT_CLOSE_MS = 260; // -- Folder panel -- const GROUP_ICON_MAX_TILES = 9; // the 3x3 mini-grid on a folder's icon +// A folder big enough to need it paginates like the grid outside (iOS folders +// page too) rather than growing a scrollbar: this many rows to a page, fewer +// when the viewport can't show that many (see _refreshGroupPanel). +const GROUP_PANEL_ROWS = 3; const GROUP_PANEL_OPEN_MS = 380; // keep in sync with .myapps-group-panel const GROUP_PANEL_CLOSE_MS = 240; // A brand-new folder opens itself so the user sees what their drop made — and @@ -511,6 +521,8 @@ const TabApps = { _apps: null, _groups: [], _openGroupId: null, + _groupPage: 0, + _groupPageCount: 0, _layout: null, _page: 0, _pageCount: 0, @@ -923,7 +935,7 @@ const TabApps = { return; } const cols = $panelGrid.length - ? gridColumnCount($panelGrid[0]) + ? gridColumnCount($panelGrid.find('.myapps-group-page')[0] || $panelGrid[0]) : ((self._layout && self._layout.cols) || 1); let next = idx; if ( e.key === 'ArrowLeft' ) next = idx - 1; @@ -1222,7 +1234,7 @@ const TabApps = { // The folder card's own empty space counts: with the grid's behind a // backdrop, holding there is the only way into reorder mode from // inside a folder. Its backdrop doesn't — a tap there closes. - if ( $(oe.target).closest('.myapps-tile, .myapps-reorder-btn, .myapps-pager-dot, .myapps-pager-arrow, .myapps-search-inner, .myapps-modal-overlay, .myapps-group-backdrop, .myapps-group-name').length ) return; + if ( $(oe.target).closest('.myapps-tile, .myapps-reorder-btn, .myapps-pager-dot, .myapps-pager-arrow, .myapps-search-inner, .myapps-modal-overlay, .myapps-group-backdrop, .myapps-group-name, .myapps-group-dots').length ) return; const p = this._emptyPress = { pointerId: oe.pointerId, @@ -1288,6 +1300,8 @@ const TabApps = { $el_window.find('.myapps-group-overlay').remove(); this._openGroupId = groupId; + this._groupPage = 0; + this._groupPageCount = 0; // Where focus came from, so closing can hand it back rather than // dumping the keyboard user at the top of the document. this._groupReturnFocus = fromTile || null; @@ -1300,6 +1314,7 @@ const TabApps = { aria-label="${i18n('app_group_name_aria')}" autocomplete="off" autocorrect="off" spellcheck="false" data-form-type="other" data-lpignore="true" data-1p-ignore>
+
`); @@ -1346,6 +1361,60 @@ const TabApps = { window.mouseover_window = undefined; }); + // -- The folder's own pager (see _refreshGroupPanel) -- + + $overlay.on('click', '.myapps-group-dot', function () { + self._goToGroupPage($el_window, parseInt(this.dataset.page, 10), true); + }); + + // Mouse wheel / two-finger vertical swipe flips one folder page per + // gesture, mirroring the grid's scroller (which see for the debounce). + $overlay.on('wheel', '.myapps-group-panel-grid', function (e) { + const oe = e.originalEvent; + if ( Math.abs(oe.deltaX) > Math.abs(oe.deltaY) ) return; + if ( Math.abs(oe.deltaY) < 4 ) return; + e.preventDefault(); + clearTimeout(self._groupWheelTimer); + self._groupWheelTimer = setTimeout(() => { + self._groupWheelActive = false; + }, 150); + if ( self._groupWheelActive ) return; + self._groupWheelActive = true; + self._goToGroupPage($el_window, self._groupPage + (oe.deltaY > 0 ? 1 : -1), true); + }); + + // Keep the active dot in sync with native swipes. The scroller node + // outlives every _refreshGroupPanel rebuild (only its contents are + // replaced), so binding once here is enough. + const scroller = $overlay.find('.myapps-group-panel-grid')[0]; + let ticking = false; + scroller.addEventListener('scroll', () => { + if ( ticking ) return; + ticking = true; + requestAnimationFrame(() => { + ticking = false; + const pageW = scroller.clientWidth || 1; + const idx = Math.round(Math.abs(scroller.scrollLeft) / pageW); + const page = Math.max(0, Math.min((self._groupPageCount || 1) - 1, idx)); + if ( page !== self._groupPage ) { + self._groupPage = page; + self._updateGroupPagerUI($el_window); + } + }); + }, { passive: true }); + + // Focus follows pages: Tab (or the arrow keys, whose focus calls + // suppress the browser's own scrolling) may land on a tile of an + // offscreen folder page — flip there, snap-aligned, so focus is never + // on something the user can't see. + $overlay.on('focusin', '.myapps-tile', function () { + const $pages = $overlay.find('.myapps-group-page'); + const page = $pages.index($(this).closest('.myapps-group-page')); + if ( page >= 0 && page !== self._groupPage ) { + self._goToGroupPage($el_window, page, true); + } + }); + // Escape closes from anywhere — a click on the scrim leaves focus on // no element in particular, and the key must still work there. Not // mid-drag, where Escape already means "cancel this drag". @@ -1451,12 +1520,32 @@ const TabApps = { // Only as wide as it needs to be, within the cap the stylesheet sets // for the viewport — a folder of three in a four-wide card would sit // lopsided against a stretch of empty surface. - const maxCols = parseInt(getComputedStyle($grid[0]).getPropertyValue('--myapps-group-cols-max'), 10); - $grid[0].style.setProperty( - '--myapps-group-cols', - String(Math.max(1, Math.min(apps.length, Number.isFinite(maxCols) ? maxCols : 4))), - ); - const html = apps.map(app => buildTileHtml(app)).join(''); + const gridCS = getComputedStyle($grid[0]); + const maxCols = parseInt(gridCS.getPropertyValue('--myapps-group-cols-max'), 10); + const cols = Math.max(1, Math.min(apps.length, Number.isFinite(maxCols) ? maxCols : 4)); + $grid[0].style.setProperty('--myapps-group-cols', String(cols)); + + // A folder that outgrows one page paginates, like the grid outside — + // GROUP_PANEL_ROWS rows to a page, fewer when the viewport is too + // short for them (landscape phones): the card must never outgrow the + // screen now that it doesn't scroll. ~180px is the card's overhead + // around the rows (scrim padding, card padding, name row, dots). + const tileH = parseFloat(gridCS.getPropertyValue('--myapps-tile-h')) || 78; + const rows = Math.max(1, Math.min( + GROUP_PANEL_ROWS, + Math.floor((window.innerHeight - 180) / (tileH + 24)), + )); + const perPage = cols * rows; + const pageCount = Math.ceil(apps.length / perPage); + + let html = ''; + for ( let p = 0; p < pageCount; p++ ) { + html += '
'; + for ( const app of apps.slice(p * perPage, (p + 1) * perPage) ) { + html += buildTileHtml(app); + } + html += '
'; + } // A rebuild replaces every tile node, and a node detached mid-gesture // takes the rest of that gesture with it: the name box commits on // BLUR, which fires on the press — before the click it belongs to — @@ -1477,6 +1566,24 @@ const TabApps = { $grid[0].__myappsPanelHtml = html; $grid.html(html); $overlay.find('.myapps-tile').attr('tabindex', '0'); + + // The rebuild reset the scroller; put it back on the page the + // user was browsing (clamped — a shrink can delete that page), + // and rebuild the dots to the new page count. + this._groupPageCount = pageCount; + this._groupPage = Math.max(0, Math.min(this._groupPage, pageCount - 1)); + let dots = ''; + for ( let p = 0; p < pageCount; p++ ) { + dots += ``; + } + $overlay.find('.myapps-group-dots') + .html(dots) + .toggleClass('myapps-group-dots-hidden', pageCount < 2); + if ( this._groupPage > 0 ) { + $grid[0].scrollLeft = this._groupPage * $grid[0].clientWidth; + } + this._updateGroupPagerUI($el_window); + const ae = document.activeElement; if ( ae === document.body || ae === null || ! ae.isConnected ) { const tiles = $grid[0].querySelectorAll('.myapps-tile'); @@ -1487,11 +1594,37 @@ const TabApps = { // Tiles that survive keep the resting rects an earlier drag left // on them, and the card may have moved since (a resize re-centres // it) — stale rects are what the next drag would hit-test against. - for ( const el of $grid[0].children ) el.__myappsRestRect = null; + for ( const el of $grid[0].querySelectorAll('.myapps-tile') ) el.__myappsRestRect = null; } this.updateRunningDots($el_window); }, + _goToGroupPage ($el_window, index, smooth) { + const scroller = $el_window.find('.myapps-group-panel-grid')[0]; + if ( ! scroller || ! this._groupPageCount ) return; + const page = Math.max(0, Math.min(this._groupPageCount - 1, index)); + scroller.scrollTo({ + left: page * scroller.clientWidth, + behavior: (smooth && ! this._reduceMotion()) ? 'smooth' : 'auto', + }); + // Programmatic scrolls don't reliably emit 'scroll' events; track the + // page eagerly (same as goToPage). + this._groupPage = page; + this._updateGroupPagerUI($el_window); + }, + + _updateGroupPagerUI ($el_window) { + const page = this._groupPage; + $el_window.find('.myapps-group-dots .myapps-group-dot').each(function (i) { + this.classList.toggle('active', i === page); + if ( i === page ) { + this.setAttribute('aria-current', 'true'); + } else { + this.removeAttribute('aria-current'); + } + }); + }, + _animateGroupPanelOpen ($el_window, fromTile) { const $overlay = $el_window.find('.myapps-group-overlay'); const panel = $overlay.find('.myapps-group-panel')[0]; @@ -1717,6 +1850,7 @@ const TabApps = { mergeAnchorX: 0, mergeAnchorY: 0, ejecting: false, + ejectTimer: null, }; // Ignore events from a second pointer (e.g. a stray finger) so it can't @@ -1763,6 +1897,9 @@ const TabApps = { // before the finger lifts. this._updateEjectState(e.clientX, e.clientY); if ( d.ejecting ) return; + if ( d.flipping ) return; + this._maybeGroupEdgeFlip(e.clientX); + if ( d.flipping ) return; this._updatePlaceholder(e.clientX, e.clientY); return; } @@ -1775,6 +1912,10 @@ const TabApps = { // Whether the dragged icon has left the open folder's card (with a margin // of forgiveness, since the card's edge is also where the last row of // tiles sits). Only meaningful for a drag that started inside a folder. + // An icon that stays out past GROUP_EJECT_CLOSE_MS is ejected mid-drag — + // see _ejectToGrid; a drop during the beat before that still ejects, it + // just lands beside the folder (_commitEject) instead of where the user + // carried it. _updateEjectState (px, py) { const d = this._drag; const panel = d.$el_window.find('.myapps-group-panel')[0]; @@ -1789,7 +1930,57 @@ const TabApps = { if ( outside === d.ejecting ) return; d.ejecting = outside; d.$el_window.find('.myapps-group-overlay').toggleClass('myapps-group-ejecting', outside); - if ( outside ) this._vibrate(8); + clearTimeout(d.ejectTimer); + d.ejectTimer = null; + if ( outside ) { + this._vibrate(8); + // A timer, not a pointer event, ends the beat: nothing is + // dispatched while the icon rests outside the card. + d.ejectTimer = setTimeout(() => { + d.ejectTimer = null; + if ( this._drag !== d || ! d.ejecting ) return; + this._ejectToGrid(d); + }, GROUP_EJECT_CLOSE_MS); + } + }, + + // The icon has been carried out of the folder and held there: take the + // app out NOW — the folder closes behind it and the very same drag + // carries on over the grid (placeholder shuffle, edge page flips, even a + // drop into another folder), so the app lands where the user watched + // themselves put it. The membership change is saved here, exactly as a + // drop at this moment would have saved it; the drop that eventually ends + // the drag saves only the final position. + _ejectToGrid (d) { + const appName = d.tileEl.dataset.appName; + if ( ! this._ejectApp(d.$el_window, d.groupId, appName) ) return; + this._closeGroup(d.$el_window); + this.renderApps(d.$el_window, { preservePage: true, instant: true }); + + const tile = d.$el_window.find('.myapps-page .myapps-tile').toArray() + .find(el => el.dataset.appName === appName); + if ( ! tile ) { + // Nothing left to carry (the app vanished in a refresh); the + // eject itself stands, there is just nothing to place. + this._endDrag(false); + return; + } + + // A folder-page flip armed just before the icon left the card must + // not fire into the grid drag this has become. + clearTimeout(d.edgeTimer); + d.edgeTimer = null; + d.edgeDir = 0; + + d.panelGrid = null; + d.groupId = null; + d.ejecting = false; + d.tileEl = tile; + tile.classList.add('myapps-tile-dragging'); + // The tile landed beside its old folder, possibly on another page; + // this pulls the placeholder straight under the icon the user is + // still holding. + this._updatePlaceholder(d.lastClientX, d.lastClientY); }, _beginDrag () { @@ -1881,6 +2072,50 @@ const TabApps = { }, DRAG_EDGE_DWELL_MS); }, + // _maybeEdgeFlip's folder-card counterpart: dwelling at the card's edge + // flips its pages, so a drag can reorder across them. The zone is + // narrower than the grid's — the card's scroller is exactly as wide as + // its tiles, so a wide zone would swallow the whole edge column; this + // one stays inside the edge tiles' hit-test dead band (DRAG_HIT_INSET). + // No merge hold either: tiles inside a folder never merge. + _maybeGroupEdgeFlip (px) { + const d = this._drag; + const scroller = d.panelGrid; + if ( ! scroller || this._groupPageCount < 2 ) return; + + const r = scroller.getBoundingClientRect(); + const zone = Math.min(DRAG_EDGE_ZONE, d.tileW * DRAG_HIT_INSET); + let dir = 0; + if ( px >= r.right - zone ) dir = 1; + else if ( px <= r.left + zone ) dir = -1; + + const atEnd = (dir === 1 && this._groupPage >= this._groupPageCount - 1); + const atStart = (dir === -1 && this._groupPage <= 0); + if ( dir === 0 || atEnd || atStart ) { + clearTimeout(d.edgeTimer); + d.edgeTimer = null; + d.edgeDir = 0; + return; + } + + if ( d.edgeTimer && d.edgeDir === dir ) return; // already dwelling this way + clearTimeout(d.edgeTimer); + d.edgeDir = dir; + d.edgeTimer = setTimeout(() => { + d.edgeTimer = null; + d.edgeDir = 0; + if ( this._drag !== d || d.ejecting ) return; + d.flipping = true; + this._goToGroupPage(d.$el_window, this._groupPage + dir, true); + clearTimeout(d.flipClearTimer); + d.flipClearTimer = setTimeout(() => { + if ( this._drag !== d ) return; + d.flipping = false; + this._updatePlaceholder(d.lastClientX, d.lastClientY); + }, this._reduceMotion() ? 60 : DRAG_FLIP_SETTLE_MS); + }, DRAG_EDGE_DWELL_MS); + }, + // Slot the (invisible) placeholder into the tile the dragged icon is // hovering over, animating the displaced tiles with FLIP. The placeholder // may hop in from another page (cross-page reorder). @@ -1897,7 +2132,13 @@ const TabApps = { _updatePlaceholder (px, py, { force = false } = {}) { const d = this._drag; if ( ! d ) return; - const pageEl = d.panelGrid || d.$el_window.find('.myapps-page').toArray()[this._page]; + // Inside a folder, only the visible page's tiles are live targets — + // the other pages' tiles have real rects just past the scroller's + // clip edge, and a probe near the card's edge must not shuffle a + // tile the user can't see. + const pageEl = d.panelGrid + ? d.panelGrid.querySelectorAll('.myapps-group-page')[this._groupPage] + : d.$el_window.find('.myapps-page').toArray()[this._page]; if ( ! pageEl ) return; // Probe with the dragged icon's centre rather than the fingertip, so the @@ -1960,10 +2201,13 @@ const TabApps = { const refNode = (phIndex === -1 || overIndex < phIndex) ? overTile : overTile.nextElementSibling; + // overTile's own parent, not pageEl: inside a folder the tiles live + // one level down, in the current .myapps-group-page. + const parentEl = overTile.parentElement || pageEl; this._flipMove(tiles.filter(t => t !== d.tileEl), () => { - if ( refNode ) pageEl.insertBefore(d.tileEl, refNode); - else pageEl.appendChild(d.tileEl); + if ( refNode ) parentEl.insertBefore(d.tileEl, refNode); + else parentEl.appendChild(d.tileEl); }); }, @@ -1977,46 +2221,65 @@ const TabApps = { this._clearMergeTarget(); return false; } - if ( d.mergeEl === overTile ) return true; // countdown already running + if ( d.mergeEl === overTile ) { + // The countdown measures REST, and rest is only visible at its + // edges: each move event that carries the icon further across + // the tile re-anchors the countdown HERE, so the one that + // finally completes started with the last movement — the well + // fills exactly once, beginning the moment the hand stops. + // (Anchoring at first contact instead made the dwell's own + // re-check trip on the hand decelerating INTO the tile, and the + // refill played the fill twice on nearly every merge.) + if ( ! d.mergeArmed ) { + const moved = Math.hypot(d.lastClientX - d.mergeAnchorX, d.lastClientY - d.mergeAnchorY); + if ( moved > DRAG_MERGE_TRAVEL ) { + d.mergeAnchorX = d.lastClientX; + d.mergeAnchorY = d.lastClientY; + this._restartMergeCountdown(d, overTile); + } + } + return true; + } d.mergeEl = overTile; d.mergeAnchorX = d.lastClientX; d.mergeAnchorY = d.lastClientY; overTile.classList.add('myapps-tile-merge-pending'); - - // Re-arming rather than one-shot: a pointer that is still moving when - // the dwell elapses restarts it from wherever it now is, so the offer - // lands the moment the icon comes to rest and never a moment before. - // (One-shot the other way round — cancel on movement — could never - // fire at all: the last events of a drag are the ones that carry the - // icon onto the target, and nothing is dispatched while it rests.) - const tick = () => { - if ( this._drag !== d || d.mergeEl !== overTile ) return; - const moved = Math.hypot(d.lastClientX - d.mergeAnchorX, d.lastClientY - d.mergeAnchorY); - if ( moved > DRAG_MERGE_TRAVEL ) { - d.mergeAnchorX = d.lastClientX; - d.mergeAnchorY = d.lastClientY; - // The well's fill IS this countdown to the user (the CSS - // transition runs the same DRAG_MERGE_DWELL_MS) — and the - // hand that decelerates INTO a tile routinely trips this - // restart, because the anchor was pinned back at first - // contact. Refill from empty, or the well sits full while - // the countdown quietly runs again, promising a folder the - // drop wouldn't make. - overTile.classList.remove('myapps-tile-merge-pending'); - void overTile.offsetWidth; // commit the empty state - overTile.classList.add('myapps-tile-merge-pending'); - d.mergeTimer = setTimeout(tick, DRAG_MERGE_DWELL_MS); - return; - } - d.mergeArmed = true; - overTile.classList.add('myapps-tile-merge-armed'); - this._vibrate(12); - }; - d.mergeTimer = setTimeout(tick, DRAG_MERGE_DWELL_MS); + d.mergeTimer = setTimeout(() => this._mergeDwellElapsed(d, overTile), DRAG_MERGE_DWELL_MS); return true; }, + // Restart the rest countdown — and with it the well's fill, from empty: + // the fill IS the countdown to the user (the CSS transition runs the same + // DRAG_MERGE_DWELL_MS), so a well left full while the countdown quietly + // reran would promise a folder the drop wouldn't make. + _restartMergeCountdown (d, overTile) { + overTile.classList.remove('myapps-tile-merge-pending'); + void overTile.offsetWidth; // commit the empty state + overTile.classList.add('myapps-tile-merge-pending'); + clearTimeout(d.mergeTimer); + d.mergeTimer = setTimeout(() => this._mergeDwellElapsed(d, overTile), DRAG_MERGE_DWELL_MS); + }, + + _mergeDwellElapsed (d, overTile) { + if ( this._drag !== d || d.mergeEl !== overTile ) return; + // Movement normally restarts the countdown from the move events + // themselves (see _considerMerge); this re-check only catches travel + // in the final instants before the timer fired. It exists because + // nothing is dispatched while the icon rests — a cancel-on-movement + // dwell could never fire at all. + const moved = Math.hypot(d.lastClientX - d.mergeAnchorX, d.lastClientY - d.mergeAnchorY); + if ( moved > DRAG_MERGE_TRAVEL ) { + d.mergeAnchorX = d.lastClientX; + d.mergeAnchorY = d.lastClientY; + this._restartMergeCountdown(d, overTile); + return; + } + d.mergeArmed = true; + overTile.classList.add('myapps-tile-merge-armed'); + this._vibrate(12); + }, + // Can the dragged tile be dropped INTO `tile`? Folders don't nest (so a // folder is never the thing being dropped, and never gains another // folder), and inside an open folder every tile is already together. @@ -2109,6 +2372,7 @@ const TabApps = { clearTimeout(d.edgeTimer); clearTimeout(d.flipClearTimer); clearTimeout(d.mergeTimer); + clearTimeout(d.ejectTimer); }, _endDrag (commit) { @@ -2800,7 +3064,18 @@ const TabApps = { .find(el => el.dataset.appName === appName); // A folder that refused to open (its apps went missing under // us) leaves the folder's own tile as the morph's anchor. - if ( inFolder ) tile = inFolder; + if ( inFolder ) { + tile = inFolder; + // The app may live on a later folder page — travel there + // visibly, same as the grid's own wayfinding flip, so the + // morph grows out of a tile the user can actually see. + const inPage = $el_window.find('.myapps-group-page') + .index($(inFolder).closest('.myapps-group-page')); + if ( inPage >= 0 && inPage !== this._groupPage ) { + this._goToGroupPage($el_window, inPage, true); + await sleep(DEEP_LINK_INTRO_FLIP_SETTLE_MS); + } + } if ( interrupted || document.visibilityState === 'hidden' ) return tile; } begin_dashboard_tile_launch(tile); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index cd4c9333d..c02caf962 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -977,7 +977,10 @@ input.myapps-search:disabled { visibility: hidden; } -.myapps-pager-dot { +/* .myapps-group-dot is the folder pager's dot (see .myapps-group-dots) — + same look, its own class so the two pagers' click handlers can't cross. */ +.myapps-pager-dot, +.myapps-group-dot { -webkit-appearance: none; appearance: none; flex: none; @@ -994,17 +997,20 @@ input.myapps-search:disabled { transition: opacity 0.2s ease; } -.myapps-pager-dot.active { +.myapps-pager-dot.active, +.myapps-group-dot.active { opacity: 0.85; } @media (hover: hover) { - .myapps-pager-dot:not(.active):hover { + .myapps-pager-dot:not(.active):hover, + .myapps-group-dot:not(.active):hover { opacity: 0.5; } } -.myapps-pager-dot:focus-visible { +.myapps-pager-dot:focus-visible, +.myapps-group-dot:focus-visible { outline: 2px solid var(--select-color); outline-offset: -1px; } @@ -1361,29 +1367,62 @@ input.myapps-group-name:focus { background: var(--dashboard-card-background); } +/* The card's pager: a folder big enough to need it paginates like the grid + outside (see _refreshGroupPanel) rather than growing a scrollbar. */ .myapps-group-panel-grid { /* --myapps-group-cols-max is the widest the card may get; the actual count is set inline by _refreshGroupPanel so a folder of three doesn't sit in the corner of a four-wide card. */ --myapps-group-cols-max: 4; + display: flex; + /* A scroller can't be sized by the very content it clips, so the grid's + width is spelled out for it. */ + width: calc(var(--myapps-group-cols, 4) * var(--myapps-tile-w) + + (var(--myapps-group-cols, 4) - 1) * var(--myapps-gap-x)); + max-width: 100%; + /* Reorder mode's uninstall badge overhangs the top corner of every tile, + and the scroller clips whatever leaves its padding box — the top + row's badges came out sliced flat. Hold the overhang inside the padding, + pulled straight back out again so the resting layout is unchanged. */ + padding-top: 9px; + margin-top: -9px; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + overscroll-behavior-x: contain; + touch-action: pan-x; + scrollbar-width: none; +} + +.myapps-group-panel-grid::-webkit-scrollbar { + display: none; +} + +.myapps-group-page { + flex: 0 0 100%; + min-width: 0; + scroll-snap-align: start; + scroll-snap-stop: always; display: grid; grid-template-columns: repeat(var(--myapps-group-cols, 4), var(--myapps-tile-w)); column-gap: var(--myapps-gap-x); row-gap: 24px; justify-content: center; align-content: start; - /* Reorder mode's uninstall badge overhangs the top corner of every tile, - and the scroller below clips whatever leaves its padding box — the top - row's badges came out sliced flat. Hold the overhang inside the padding, - pulled straight back out again so the resting layout is unchanged. */ - padding-top: 9px; - margin-top: -9px; - /* A folder big enough to need it scrolls rather than growing past the - viewport; the row gap keeps the last row from looking cut off. */ - max-height: min(52vh, 430px); - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-width: thin; +} + +/* The folder's page dots — the grid pager's, one size down in spacing. Gone + entirely (not just invisible) on a one-page folder: the card hugs its + contents, and a reserved empty strip would read as a layout bug. */ +.myapps-group-dots { + display: flex; + align-items: center; + justify-content: center; + margin-top: -6px; +} + +.myapps-group-dots-hidden { + display: none; } /* Uninstall confirmation modal */ @@ -3278,9 +3317,8 @@ body.myapps-reordering .myapps-tile { border-radius: 24px; } - .myapps-group-panel-grid { + .myapps-group-page { row-gap: 18px; - max-height: 46vh; } }