From f0c48790a84ebf01d8c16d2cf4b6ce483483ff51 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 5 Jun 2026 10:39:23 +0200 Subject: [PATCH] [Glitch] Make post/feed navigation by hotkey more robust Port 09689103390d10d77de5146483cbbb8abb5c94c9 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/features/ui/index.jsx | 12 ++- .../glitch/features/ui/util/focusUtils.ts | 76 ++++++++++++++++--- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index f34be17736..18d0af36e5 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -89,7 +89,7 @@ import { Quotes, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; -import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem } from './util/focusUtils'; +import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem, getFocusedColumnIndex } from './util/focusUtils'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { CustomHomepage } from 'flavours/glitch/features/custom_homepage'; @@ -552,20 +552,18 @@ class UI extends PureComponent { handleMoveUp = () => { const currentItemIndex = getFocusedItemIndex(); if (currentItemIndex === -1) { - focusColumn(1); + return focusColumn(getFocusedColumnIndex()); } else { - const wasHandled = focusItemSibling(currentItemIndex, -1); - return wasHandled; + return focusItemSibling(currentItemIndex, -1); } }; handleMoveDown = () => { const currentItemIndex = getFocusedItemIndex(); if (currentItemIndex === -1) { - focusColumn(1); + return focusColumn(getFocusedColumnIndex()); } else { - const wasHandled = focusItemSibling(currentItemIndex, 1); - return wasHandled; + return focusItemSibling(currentItemIndex, 1); } }; diff --git a/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts b/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts index 80e9cb9768..6e6b8e24e0 100644 --- a/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts +++ b/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts @@ -62,18 +62,17 @@ export function focusColumn(index = 1) { function fallback() { focusColumnTitle(index + indexOffset, isMultiColumnLayout); + return false; } if (!column) { - fallback(); - return; + return fallback(); } const container = column.querySelector('.scrollable'); if (!container) { - fallback(); - return; + return fallback(); } const focusableItems = Array.from( @@ -86,8 +85,7 @@ export function focusColumn(index = 1) { const itemToFocus = findFirstVisibleWithRect(focusableItems); if (!itemToFocus) { - fallback(); - return; + return fallback(); } const viewportWidth = @@ -110,6 +108,7 @@ export function focusColumn(index = 1) { itemToFocus.item.scrollIntoView(true); } itemToFocus.item.focus(); + return true; } /** @@ -126,6 +125,18 @@ export function getFocusedItemIndex() { return items.indexOf(focusedItem); } +/** + * Get the index of the column that contains the user's focus + */ +export function getFocusedColumnIndex() { + const columnWithFocus = document.activeElement?.closest('.column'); + + if (!columnWithFocus) return 1; + + const allColumns = Array.from(document.querySelectorAll('.column')); + return allColumns.indexOf(columnWithFocus) + 1; +} + /** * Focus the topmost item of the column that currently has focus, * or the first column if none @@ -159,12 +170,7 @@ export function focusItemSibling(index: number, direction: 1 | -1) { ); if (!siblingItem) { - return false; - } - - // If sibling element is empty, we skip it - if (siblingItem.matches(':empty')) { - return focusItemSibling(index + direction, direction); + return focusListSibling(direction); } // Check if the sibling is a post or a 'follow suggestions' widget @@ -177,6 +183,52 @@ export function focusItemSibling(index: number, direction: 1 | -1) { targetElement = siblingItem; } + // If sibling element is empty, we skip it + if (!targetElement || siblingItem.matches(':empty')) { + return focusItemSibling(index + direction, direction); + } + + targetElement.scrollIntoView({ + block: 'start', + }); + + targetElement.focus(); + + return true; +} + +/** + * Finds the next or previous .item-list on the page or in the column, + * and focuses its first or last item. + */ +function focusListSibling(direction: 1 | -1) { + const container = document.activeElement?.closest('.item-list'); + + if (!container) { + return false; + } + + // Get all item lists in the current column or page + const currentColumn = container.closest('.column') ?? document; + + const columnItemLists = Array.from( + currentColumn.querySelectorAll('.item-list'), + ); + const currentListIndex = columnItemLists.indexOf(container); + + // Find the next or previous item-list + const listSibling = columnItemLists[currentListIndex + direction]; + + // Depending on the direction, find the first or last focusable in the list + let targetElement: HTMLElement | null | undefined; + if (direction > 0) { + targetElement = listSibling?.querySelector('.focusable'); + } else { + const allFocusables = + listSibling?.querySelectorAll('.focusable'); + targetElement = allFocusables?.[allFocusables.length - 1]; + } + if (targetElement) { targetElement.scrollIntoView({ block: 'start',