From 9334bd9ede642ecce8383f01c25a20b024614b6e Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 28 Nov 2025 17:19:23 +0100 Subject: [PATCH] Don't reset scroll when using hotkeys to focus columns, add hotkey `0` to scroll to top (#37052) --- .../mastodon/components/hotkeys/index.tsx | 1 + .../features/keyboard_shortcuts/index.jsx | 8 +- app/javascript/mastodon/features/ui/index.jsx | 19 ++-- .../mastodon/features/ui/util/focusUtils.ts | 91 ++++++++++++------- app/javascript/mastodon/locales/en.json | 1 + 5 files changed, 77 insertions(+), 43 deletions(-) diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx index 81ca28eb87..c62fc0c20a 100644 --- a/app/javascript/mastodon/components/hotkeys/index.tsx +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -111,6 +111,7 @@ const hotkeyMatcherMap = { openProfile: just('p'), moveDown: just('j'), moveUp: just('k'), + moveToTop: just('0'), toggleHidden: just('x'), toggleSensitive: just('h'), toggleComposeSpoilers: optionPlus('x'), diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx index 01a4f0e1fd..8a6ebe6def 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.jsx @@ -95,13 +95,17 @@ class KeyboardShortcuts extends ImmutablePureComponent { - l - + 0 + 1-9 + + l + + n diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 476c25e32d..0d73106808 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -78,7 +78,7 @@ import { Quotes, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; -import { focusColumn, getFocusedItemIndex, focusItemSibling } from './util/focusUtils'; +import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem } from './util/focusUtils'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; // Dummy import, to make sure that ends up in the application bundle. @@ -449,20 +449,21 @@ class UI extends PureComponent { }; handleHotkeyFocusColumn = e => { - focusColumn({index: e.key * 1}); + focusColumn(e.key * 1); }; handleHotkeyLoadMore = () => { document.querySelector('.load-more')?.focus(); }; + handleMoveToTop = () => { + focusFirstItem(); + }; + handleMoveUp = () => { const currentItemIndex = getFocusedItemIndex(); if (currentItemIndex === -1) { - focusColumn({ - index: 1, - focusItem: 'first-visible', - }); + focusColumn(1); } else { focusItemSibling(currentItemIndex, -1); } @@ -471,10 +472,7 @@ class UI extends PureComponent { handleMoveDown = () => { const currentItemIndex = getFocusedItemIndex(); if (currentItemIndex === -1) { - focusColumn({ - index: 1, - focusItem: 'first-visible', - }); + focusColumn(1); } else { focusItemSibling(currentItemIndex, 1); } @@ -562,6 +560,7 @@ class UI extends PureComponent { focusLoadMore: this.handleHotkeyLoadMore, moveDown: this.handleMoveDown, moveUp: this.handleMoveUp, + moveToTop: this.handleMoveToTop, back: this.handleHotkeyBack, goToHome: this.handleHotkeyGoToHome, goToNotifications: this.handleHotkeyGoToNotifications, diff --git a/app/javascript/mastodon/features/ui/util/focusUtils.ts b/app/javascript/mastodon/features/ui/util/focusUtils.ts index a728a3c5eb..f140cb0b35 100644 --- a/app/javascript/mastodon/features/ui/util/focusUtils.ts +++ b/app/javascript/mastodon/features/ui/util/focusUtils.ts @@ -1,16 +1,30 @@ -interface FocusColumnOptions { - index?: number; - focusItem?: 'first' | 'first-visible'; +/** + * Out of a list of elements, return the first one whose top edge + * is inside of the viewport, and return the element and its BoundingClientRect. + */ +function findFirstVisibleWithRect( + items: HTMLElement[], +): { item: HTMLElement; rect: DOMRect } | null { + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight; + + for (const item of items) { + const rect = item.getBoundingClientRect(); + const isVisible = rect.top >= 0 && rect.top < viewportHeight; + + if (isVisible) { + return { item, rect }; + } + } + + return null; } /** * Move focus to the column of the passed index (1-based). - * Can either focus the topmost item or the first one in the viewport + * Focus is placed on the topmost visible item */ -export function focusColumn({ - index = 1, - focusItem = 'first', -}: FocusColumnOptions = {}) { +export function focusColumn(index = 1) { // Skip the leftmost drawer in multi-column mode const isMultiColumnLayout = !!document.querySelector( 'body.layout-multiple-columns', @@ -27,33 +41,28 @@ export function focusColumn({ if (!container) return; - let itemToFocus: HTMLElement | null = null; + const focusableItems = Array.from( + container.querySelectorAll( + '.focusable:not(.status__quote .focusable)', + ), + ); - if (focusItem === 'first-visible') { - const focusableItems = Array.from( - container.querySelectorAll( - '.focusable:not(.status__quote .focusable)', - ), - ); - - const viewportHeight = - window.innerHeight || document.documentElement.clientHeight; - - // Find first item visible in the viewport - itemToFocus = - focusableItems.find((item) => { - const { top } = item.getBoundingClientRect(); - return top >= 0 && top < viewportHeight; - }) ?? null; - } else { - itemToFocus = container.querySelector('.focusable'); - } + // Find first item visible in the viewport + const itemToFocus = findFirstVisibleWithRect(focusableItems); if (itemToFocus) { - if (container.scrollTop > itemToFocus.offsetTop) { - itemToFocus.scrollIntoView(true); + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth; + const { item, rect } = itemToFocus; + + if ( + container.scrollTop > item.offsetTop || + rect.right > viewportWidth || + rect.left < 0 + ) { + itemToFocus.item.scrollIntoView(true); } - itemToFocus.focus(); + itemToFocus.item.focus(); } } @@ -71,6 +80,26 @@ export function getFocusedItemIndex() { return items.indexOf(focusedItem); } +/** + * Focus the topmost item of the column that currently has focus, + * or the first column if none + */ +export function focusFirstItem() { + const focusedElement = document.activeElement; + const container = + focusedElement?.closest('.scrollable') ?? + document.querySelector('.scrollable'); + + if (!container) return; + + const itemToFocus = container.querySelector('.focusable'); + + if (itemToFocus) { + container.scrollTo(0, 0); + itemToFocus.focus(); + } +} + /** * Focus the item next to the one with the provided index */ diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 1108f8194e..bc8dd40d93 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -516,6 +516,7 @@ "keyboard_shortcuts.toggle_hidden": "Show/hide text behind CW", "keyboard_shortcuts.toggle_sensitivity": "Show/hide media", "keyboard_shortcuts.toot": "Start a new post", + "keyboard_shortcuts.top": "Move to top of list", "keyboard_shortcuts.translate": "to translate a post", "keyboard_shortcuts.unfocus": "Unfocus compose textarea/search", "keyboard_shortcuts.up": "Move up in the list",