launcher-menu grid: scroll indicators follow the element that scrolls

With maxRows capping the scroller, the inner grid panel scrolls (as in
Steam's own design, where the outer element holds the fixed edge shadows).
The fade/shadow state is now read from whichever of the two overflows and
updated on the panel's scroll events too, so the shadows show only where
more content is.
This commit is contained in:
Pierre Kisters committed 2026-09-27 21:06:24 +02:00
1 parent 9ddb450057
commit cfa9ca7e15
1 file changed
+18 -5
+18 -5
View File
@@ -39,7 +39,10 @@
// shadows (box-shadow in the background colour at the top and // shadows (box-shadow in the background colour at the top and
// bottom edge), shown by Steam's can-scroll-up/-down classes, which // bottom edge), shown by Steam's can-scroll-up/-down classes, which
// go stale the same way. The same state goes in data-sfui-shadow // go stale the same way. The same state goes in data-sfui-shadow
// on the scroller and sets the pseudo-elements' opacity. // on the scroller and sets the pseudo-elements' opacity. Once
// maxRows caps the scroller, the inner panel is what scrolls (as in
// Steam's own design; the outer element keeps the shadows fixed),
// so the state is read from whichever of the two overflows.
// Steam's gamepad navigation derives a panel's layout from its computed // Steam's gamepad navigation derives a panel's layout from its computed
// style (display: grid), so up/down/left/right move between tiles. // style (display: grid), so up/down/left/right move between tiles.
// With the pinned-desktop patch, its pinned row (.sfui-pinned-desktop, // With the pinned-desktop patch, its pinned row (.sfui-pinned-desktop,
@@ -51,7 +54,7 @@
// which removes markers, stylesheets, observers and the timer. // which removes markers, stylesheets, observers and the timer.
((find, sigs, opts) => { ((find, sigs, opts) => {
const NAME = 'launcher-menu-grid'; const NAME = 'launcher-menu-grid';
const VERSION = 5; const VERSION = 6;
const COLS = opts.columns; const COLS = opts.columns;
const ROWS = opts.maxRows ?? null; const ROWS = opts.maxRows ?? null;
if (!(Number.isInteger(COLS) && COLS >= 1) || !(ROWS === null || (Number.isInteger(ROWS) && ROWS >= 1))) if (!(Number.isInteger(COLS) && COLS >= 1) || !(ROWS === null || (Number.isInteger(ROWS) && ROWS >= 1)))
@@ -191,11 +194,16 @@ ${PINNED} > [role=button] * { flex-grow: 0 !important; text-align: center !impor
// Scroll indicator state from the actual scroll position (Steam's // Scroll indicator state from the actual scroll position (Steam's
// thresholds), for both the mask fade and the edge shadows. // thresholds), for both the mask fade and the edge shadows.
// The element that actually scrolls: the inner panel once maxRows caps
// the scroller, otherwise the scroller.
const scrolling = (sc, panel) =>
panel && (panel.scrollHeight > panel.clientHeight + 1 || panel.scrollTop > 0) ? panel : sc;
const fade = (sc) => { const fade = (sc) => {
const r = regions.get(sc); const r = regions.get(sc);
if (!r) return; if (!r) return;
const top = sc.scrollTop > 1; const el = scrolling(sc, r.panel);
const bottom = sc.scrollHeight - sc.scrollTop > sc.clientHeight + 1; const top = el.scrollTop > 1;
const bottom = el.scrollHeight - el.scrollTop > el.clientHeight + 1;
const v = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none'; const v = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none';
if (sc.getAttribute(SHADOW) !== v) sc.setAttribute(SHADOW, v); if (sc.getAttribute(SHADOW) !== v) sc.setAttribute(SHADOW, v);
if (r.fade && r.fade.getAttribute(FADE) !== v) r.fade.setAttribute(FADE, v); if (r.fade && r.fade.getAttribute(FADE) !== v) r.fade.setAttribute(FADE, v);
@@ -234,12 +242,16 @@ ${PINNED} > [role=button] * { flex-grow: 0 !important; text-align: center !impor
const ro = docs.get(d)?.ro; const ro = docs.get(d)?.ro;
const r = regions.get(sc); const r = regions.get(sc);
if (r) { if (r) {
if (r.panel !== panel) { ro?.unobserve(r.panel); r.panel = panel; ro?.observe(panel); } if (r.panel !== panel) {
ro?.unobserve(r.panel); r.panel.removeEventListener('scroll', r.onScroll);
r.panel = panel; ro?.observe(panel); panel.addEventListener('scroll', r.onScroll, { passive: true });
}
return; return;
} }
const onScroll = guard(() => fade(sc)); const onScroll = guard(() => fade(sc));
regions.set(sc, { d, list, panel, fade: fadeOf(sc, list), onScroll }); regions.set(sc, { d, list, panel, fade: fadeOf(sc, list), onScroll });
sc.addEventListener('scroll', onScroll, { passive: true }); sc.addEventListener('scroll', onScroll, { passive: true });
panel.addEventListener('scroll', onScroll, { passive: true });
ro?.observe(sc); ro?.observe(sc);
ro?.observe(panel); ro?.observe(panel);
}; };
@@ -249,6 +261,7 @@ ${PINNED} > [role=button] * { flex-grow: 0 !important; text-align: center !impor
if (!r) return; if (!r) return;
regions.delete(sc); regions.delete(sc);
sc.removeEventListener('scroll', r.onScroll); sc.removeEventListener('scroll', r.onScroll);
r.panel.removeEventListener('scroll', r.onScroll);
const ro = docs.get(r.d)?.ro; const ro = docs.get(r.d)?.ro;
ro?.unobserve(sc); ro?.unobserve(sc);
ro?.unobserve(r.panel); ro?.unobserve(r.panel);