-
Notifications
You must be signed in to change notification settings - Fork 367
Add RES-style keyboard navigation #3617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add RES-style keyboard navigation #3617
Conversation
Add foundational utilities for RES-style keyboard navigation: - keyboard-shortcuts.ts: Core utilities for event handling, DOM manipulation, and dropdown toggling. Provides shouldIgnoreEvent() to prevent shortcuts during text input, ensureInView() for minimal scrolling, and toggleDropdownMenu() for action menus. - keyboard-shortcuts-constants.ts: Centralized selectors and DOM patterns. Provides consistent selectors for posts, comments, and navigation elements. These utilities form the foundation for keyboard navigation, handling event filtering, accessibility, and common DOM operations. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Add KeyboardNavigationController for j/k navigation in list components. Provides reusable logic for: - j/k: Navigate to next/previous item - J/K: Jump to first/last item - Automatic scrolling and state updates Generic implementation works with any list component (posts, comments, mixed content). Components provide getters/setters for items and current index, controller handles navigation logic and callbacks. Used by PostListings, Post, and PersonDetails components. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Add handleKeyboardShortcut() - universal dispatcher for all content types. Works with PostView, CommentView, and PersonContentCombinedView, providing unified keyboard shortcuts: - a/z: Upvote/downvote - s: Save/unsave - c/C: Navigate to comments (current/new tab) - l/L: Navigate to link (current/new tab) - u/U: Navigate to user profile (current/new tab) - r/R: Reply (comments) or community (posts), current/new tab - e: Edit own content - x: Expand content (component-specific) - .: Open action dropdown menu Handles type detection (isPost vs isComment), vote toggling, and navigation. Component-specific actions (expand, edit, reply) provided via callbacks. Enables consistent keyboard experience across all views. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Add supporting utilities for keyboard shortcut integration: keyboard-shortcuts-handler.ts: - handleKeyboardAction(): Shared handler for component keyboard events - Calls universal dispatcher with component-specific handlers - Used by PostListings and PersonDetails for consistent behavior keyboard-shortcuts-expansion.ts: - Post expansion state management (Set-based tracking) - toggleExpansion(): Add/remove indices from expanded set - getShowBodyMode(): Determine if post body should be shown - Supports persistent expansion state during navigation These utilities reduce code duplication across components and provide consistent expansion behavior. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Add PostCommentNavigator for p/t navigation in threaded comments: - p key: Navigate to parent comment - t key: Navigate to thread root (top-level comment) - Handles nested comment hierarchies via DOM traversal - Finds parent/root using data-comment-id attributes - Updates state and focuses target comment - Provides visual feedback via highlighting Uses article[data-comment-id] selectors to traverse comment tree structure. Scrolls target into view and updates component state for proper highlighting. Integrated into Post component for comment thread navigation. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Add TypeScript decorator mixin for keyboard navigation in list components. The mixin provides: - Document-level keydown event listener lifecycle - j/k navigation via KeyboardNavigationController - handleCustomKeys() hook for component-specific shortcuts (e.g., 'x' for expand) - Automatic cleanup on unmount Components implement KeyboardShortcutsPostComponent interface: - getPosts(): Return list of items - getCurrentIndex(): Return highlighted index - setCurrentIndex(): Update highlighted index - scrollToIndex(): Scroll and focus item - handleCustomKeys(): Handle component-specific keys Used by PostListings component to enable post list navigation. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Add CSS styles for keyboard navigation: - .keyboard-selected: Subtle background highlight for selected items. Uses rgba(var(--bs-body-color-rgb), 0.1) for automatic theme adaptation. Works in all themes (light, dark, custom) via CSS variables. - Remove focus outlines: .post-listing and .comment-node hide browser outlines. Prevents distracting blue rings during keyboard navigation. Replaced by .keyboard-selected background highlight. - Smooth transitions: 0.15s ease-in-out for professional feel. Applied to .post-listing and .comment-node for fade effect. The highlight provides clear visual feedback without being intrusive, automatically adapting to any theme. Based on styling approach from PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Update post rendering subcomponents to support keyboard-triggered expansion: post-listing-card.tsx & post-listing-list.tsx: - Accept isExpanded and imageExpanded props from parent - Render expanded/collapsed state based on keyboard actions - Support x key expansion via PostListing component post-thumbnail.tsx: - Expose imageExpanded state for keyboard control - Allow parent to control thumbnail expansion These changes enable keyboard shortcuts to control post display state (expand/collapse body and images) across all post listing modes (card, list). Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Enable keyboard shortcuts for individual posts: - a/z: Upvote/downvote post - s: Save/unsave post - c/C: Navigate to comments (current tab / new tab) - l/L: Navigate to link (current tab / new tab) - u/U: Navigate to user profile (current tab / new tab) - r/R: Navigate to community (current tab / new tab) - e: Edit own post - x: Expand post image/body - .: Open action dropdown menu Integrates handleKeyboardShortcut() universal dispatcher with component-specific callbacks for expand and edit actions. Stops event propagation to prevent double-handling by parent PostListings component. Works on both listing pages (via isHighlighted prop) and post detail page (isHighlighted undefined = always active). Adds tabIndex, keyboard-selected class, and ARIA labels for accessibility. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Integrates keyboard shortcuts mixin into PostListings for list-based navigation: - Implements KeyboardShortcutsPostComponent interface - Adds state management for highlighted post and expansion - Handles j/k navigation (via mixin), x key for expand/collapse - Passes keyboard props to PostListing children - Auto-focuses first post on mount Based on implementation by HamzahMansour in PR LemmyNet#1892. Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com> Related: LemmyNet#1892
Enable keyboard shortcuts for comment navigation on post detail pages: - j/k: Navigate between comments (next/previous) - J/K: Jump to first/last comment - p: Navigate to parent comment - t: Navigate to thread root (top-level comment) - Click to select: Click any comment to make it active - Visual highlighting: Selected comment gets keyboard-selected class Integrates PostCommentNavigator for all comment navigation (j/k/J/K/p/t). Comment-level actions (vote, save, reply, edit, etc.) are handled by CommentNode component itself. Post component focuses on navigation orchestration and state management. Provides seamless keyboard experience when reading comment threads. Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Enable keyboard shortcuts for individual comments: CommentNode component: - Enter: Collapse/expand comment and children - a/z: Upvote/downvote comment - s: Save/unsave comment - r: Reply to comment - e: Edit own comment - u/U: Navigate to user profile (current tab / new tab) - R: Navigate to community (new tab) - .: Open action dropdown menu Integrates handleKeyboardShortcut() universal dispatcher with comment-specific callbacks. Stops event propagation to prevent double-handling by parent Post component. Only processes shortcuts when isHighlighted prop is true (set by parent). CommentNodes component: - Pass highlightedCommentId to visually mark selected comment - Support keyboard navigation in both threaded and flat views Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
Enable keyboard navigation for PersonDetails component (user profile pages): - j/k: Navigate through mixed content (posts and comments) - J/K: Jump to first/last item - All post shortcuts work on profile posts (a/z/s/c/l/u/r/x/.) - All comment shortcuts work on profile comments (a/z/s/r/e/u/./Enter) - Click to select: Click any item to make it active - Visual highlighting: Selected item gets keyboard-selected class - Focus management: Focuses inner PostListing or CommentNode for event handling PersonDetails orchestrates navigation across mixed content while delegating individual actions to child components (PostListing, CommentNode). Also exports personLink() utility from person-listing.tsx for use by keyboard-shortcuts-actions.ts (user profile navigation - 'u' key). Based on implementation by HamzahMansour in PR LemmyNet#1892. Related: LemmyNet#1892 Co-authored-by: Hamzah Mansour <HamzahMansour@users.noreply.github.com>
|
In general the shortcuts are working well. Various things I noticed:
|
|
Thanks for testing!
We can try this. In RES this is not a problem because you can explicitly enable or disable keyboard navigation.
Agreed. I'll add a toast. Looks like we'll need new strings for localization after all.
For list mode, it looks like there is no context menu button at all. Since the . key simply clicks the button, we can't show it there in list mode. It should be working OK in card and small card mode. Do you think we should add the ⋮ button but hide it? Or we can add the button at all times, since it seems useful to have either way, but that's a little out of scope for this PR and more of an overall design question. Happy to do either.
I have a draft for the documentation repository, let me upload it as well. |
Another option would be to add a badge to the post / comment itself to visually indicate that the post is saved, but that's also a small design change. |
Show a toast notification to confirm when save/unsave actions succeed.
This applies to all save methods including keyboard shortcuts ('s' key)
and the dropdown menu.
Changes:
- Add new i18n strings: post_saved, post_unsaved, comment_saved, comment_unsaved
- Update handleSavePost/handleSaveComment in Home, Community, Post, Profile
- Show success toast on save/unsave completion
- Follow existing toast pattern (5s duration, bottom-left position)
|
Right it looks like the context menu from post listing was removed on main branch. Imo that should be added back. Inside the post view |
Add keyboard mode activation system that shows navigation highlights only after first keyboard shortcut use. This prevents highlights from appearing for mouse/mobile users while keeping full functionality for keyboard navigation users. - Guard .keyboard-selected CSS with .keyboard-mode-active class - Add activateKeyboardMode() utility function - Call activation in all keyboard handlers (mixin, Post, PostListing, CommentNode) - Class added to documentElement on first keyboard interaction - Does not persist across page loads to prevent accidental activation becoming sticky
Extract getCrossPostParams and getCrossPostBody functions from PostActionBar to a new shared utils/post.ts file. This allows other components to reuse the cross-posting logic without duplication. - Create utils/post.ts with getCrossPostParams helper - Update PostActionBar to import and use the shared helper - Remove local crossPostParams and crossPostBody functions No functional changes, pure refactoring.
Extract PostActionDropdown with all its handler logic into a reusable wrapper component. This allows different post listing layouts to share the same dropdown menu logic without duplication. - Create PostActionDropdownWrapper component - Export PostActionDropdownWrapperProps type - Include all handler functions for post actions This component will be used by both PostActionBar (card mode) and PostListingList (list mode) in subsequent commits.
Refactor PostActionBar to use the new PostActionDropdownWrapper component instead of inline handlers. This removes ~200 lines of duplicated handler logic from post-action-bar.tsx. - Replace PostActionDropdown with PostActionDropdownWrapper - Remove all inline handler functions (now in wrapper) - Remove unused imports (Post type, browser utils, date utils) - Simplify component to focus on layout No functional changes, pure refactoring.
Add the post action menu dropdown to list mode using the new PostActionDropdownWrapper component. This restores the menu button that was accidentally removed during the October refactoring. - Add PostActionDropdownWrapper to PostListingList - Add all required props (admins, markable, action handlers) - Pass props from PostListing to PostListingList - Render menu button inline after comments button The menu button now appears in all post listing modes: - Card mode: via PostActionBar - List mode: via PostActionDropdownWrapper (newly added) Fixes accidental removal from commit cdd85ab (PR LemmyNet#3512). Enables keyboard shortcut "." to work in list mode.
Done.
Toast added. It seemed useful even when doing this with the mouse, so I made it show regardless of how the post is saved/unsaved.
OK, added some patches which do this.
Here it is: LemmyNet/lemmy-docs#395 |
I'm having quite a lot of trouble reproducing this. I managed to reproduce this once, and then no longer. I've tried multiple browsers and variations of the circumstances. I suspect this has something to do with the Dropdown asset not being loaded at the time. Do you have a way to reproduce this problem reliably? |
|
Now the problem also disappeared for me, maybe it only happens on the first load or something like that? |
|
It's possible, but this is stretching the limits of my front-end development knowledge, so I can't answer for sure. If it's transient and the feature is not a commonly used one (I certainly have not used the RES equivalent personally), perhaps some flakiness is acceptable until we have more information? Up to you. |
|
Yes thats fine if it cant be reproduced. Lets see what @dessalines says. |
dessalines
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't review any more right now.
My feeling is this whole thing needs to be restructured in a better way, by:
- Adding a
KeyboardEventsreact component (doesn't even have to be generic, since all the functionality and events are so different), loaded at the screen level, IEhome,community,post.tsx, etc, not at the sub-component level.- Instantianting keyboard navigation for every individual comment is overkill and doesn't make much sense.
- Navigate around, doing actions by passing props like
isFocused, to them. You can use the already existinghandleSaveCommentin places likepost.tsx. If you need the currently selected comment / post, add that to the state of the screen-level component, then you can do all the api actions based on that.
|
|
||
| constructor(props: any, context: any) { | ||
| super(props, context); | ||
| this.handleKeyDown = this.handleKeyDown.bind(this); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't use binds (we're in the process of getting rid of all of them). Look at all the other handler functions at the bottom, and do:
function handleKeyDown(i: CommentNode, event: KeyboardEvent)
If an existing file is already using a ton of binds, then don't worry about it, we'll handle it in a later cleanup.
| onPersonNote(form: NotePerson): void; | ||
| onLockComment(form: LockComment): void; | ||
| // Keyboard navigation props | ||
| highlightedCommentId?: number | null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just make this isHighlighted: boolean; You can do the check a level up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops! Will fix.
| this.props.onCommentClick(id); | ||
| } | ||
| }} | ||
| onKeyDown={this.handleKeyDown} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onKeyDown={e => handleKeyDown(this, e)
| onLockComment(form: LockComment): void; | ||
| // Keyboard navigation props | ||
| highlightedCommentId?: number | null; | ||
| onCommentClick?(commentId: number): void; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is this for? For navigation to the comment? Or what keyboard command uses it?
| * Interface for components that can use keyboard shortcuts for navigation | ||
| * Generic over the item type (PostView, PersonContentCombinedView, etc.) | ||
| */ | ||
| export interface KeyboardShortcutsComponent<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is commentnode not using this also?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In principle, the navigation models are different - comments are arranged in a tree and have an additional movement axis. (Aware that the current implementation flattens everything in a list anyway - left that as-is to avoid premature optimization.)
| { | ||
| state: KeyboardNavigationState = { | ||
| highlightedIndex: 0, | ||
| expandedPostIndices: new Set<number>(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a really weird place to store this. Don't you already have it in PostListing? And is this imageExpanded, bodyExpanded?
| notifications: PostNotificationsMode; | ||
| editLoading: boolean; | ||
| // Keyboard navigation state | ||
| highlightedCommentId: CommentId | null; // null means post is highlighted |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
highlightedCommentId?: CommentId
| notifications: "replies_and_mentions", | ||
| editLoading: false, | ||
| highlightedCommentId: null, // Start with post highlighted | ||
| imageExpanded: false, // Start with image not expanded |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove.
If you need to show the expanded image, use post_listing_mode: card vs small_card
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another one without the mixin
| private commentNavigator = new PostCommentNavigator( | ||
| () => { | ||
| // Get all visible comment elements in DOM order | ||
| // Collapsed comments' children are not rendered, so they won't be in the DOM | ||
| // Query for article elements with IDs exactly matching 'comment-{digits}' | ||
| return Array.from( | ||
| document.querySelectorAll(SELECTOR.COMMENT_ARTICLES), | ||
| ).filter(el => PATTERN.COMMENT_ID.test(el.id)) as HTMLElement[]; | ||
| }, | ||
| () => this.state.highlightedCommentId, | ||
| id => this.setState({ highlightedCommentId: id }), | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DOM queries just to set the highlighted comment? None of this is necessary.
Just set highlighted after the comments fetch.
Not sure how that would look like, since we have to deal with a good variety of objects (just posts on home/community page, post listing + comments on post pages, mixed items on the user page). Maybe have these expose a common interface? What do you have in mind?
For what it's worth, the approach I was going for was to separate actions (upvoting, saving, etc.) from navigation. Actions are handled by the object the action applies to, while navigation is handled by its container. |
On the <KeyboardCommentEvents
focusedComment={this.state.focusedComment}
onCommentSave={form => handleSaveComment(this, form)}
onVote={...}
onFocusNext={...}
onNavigateToComment={...}
...
/>
...
<KeyboardPostEvents
... |
Thanks. Would you mind illustrating how you envision this to work e.g. on the user page, which has both posts and comments? Would we have both |
That does seem like the best way, although the navigation is beyond me. The |

Add RES-style keyboard navigation
Implements keyboard shortcuts for navigating and interacting with posts and comments across Lemmy UI.
Closes #984
Keyboard Shortcuts
Posts (homepage, community pages, user profiles)
Navigation:
jkJKPagination:
npPActions:
azsxe.Quick Navigation:
cClLuUrRComments (post detail pages)
Navigation:
jkJKptActions:
azsreEnter.Quick Navigation:
uUImplementation
I tried to take a least-intrusive approach and isolate the behavior to its own component, as well as to reduce code duplication. However, I may have over-engineered it a little, as this adds a lot more code in total than the predecessor PR.
On the other hand, a good chunk of the new code is comments and type declarations, which I hope contribute positively to overall complexity.
Testing
I've tested this manually on:
Credits
Based on the implementation by @HamzahMansour in PR #1892.
Some code is by Claude (Sonnet 4.5).