diff --git a/app/javascript/flavours/glitch/components/hotkeys/index.tsx b/app/javascript/flavours/glitch/components/hotkeys/index.tsx index db24038ff8..29d43266e1 100644 --- a/app/javascript/flavours/glitch/components/hotkeys/index.tsx +++ b/app/javascript/flavours/glitch/components/hotkeys/index.tsx @@ -112,6 +112,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/flavours/glitch/features/keyboard_shortcuts/index.jsx b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx index e424568586..5a4589aa17 100644 --- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx +++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.jsx @@ -99,13 +99,17 @@ class KeyboardShortcuts extends ImmutablePureComponent { - l - + 0 + 1-9 + + l + + n diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index f18c44c4c8..9cf4b2372e 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -81,7 +81,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. @@ -495,20 +495,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); } @@ -517,10 +518,7 @@ class UI extends PureComponent { handleMoveDown = () => { const currentItemIndex = getFocusedItemIndex(); if (currentItemIndex === -1) { - focusColumn({ - index: 1, - focusItem: 'first-visible', - }); + focusColumn(1); } else { focusItemSibling(currentItemIndex, 1); } @@ -615,6 +613,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/flavours/glitch/features/ui/util/focusUtils.ts b/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts index a728a3c5eb..f140cb0b35 100644 --- a/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts +++ b/app/javascript/flavours/glitch/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 */