mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-05 17:31:12 +00:00
Improvements for keyboard navigation in feeds (#35853)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Crowdin / Upload translations / upload-translations (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Crowdin / Upload translations / upload-translations (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
This commit is contained in:
parent
511e10df34
commit
118c30fbc7
|
@ -7,11 +7,7 @@ import { normalizeKey, isKeyboardEvent } from './utils';
|
||||||
* the hotkey with a higher priority is selected. All others
|
* the hotkey with a higher priority is selected. All others
|
||||||
* are ignored.
|
* are ignored.
|
||||||
*/
|
*/
|
||||||
const hotkeyPriority = {
|
const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const;
|
||||||
singleKey: 0,
|
|
||||||
combo: 1,
|
|
||||||
sequence: 2,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This type of function receives a keyboard event and an array of
|
* This type of function receives a keyboard event and an array of
|
||||||
|
@ -105,14 +101,15 @@ const hotkeyMatcherMap = {
|
||||||
new: just('n'),
|
new: just('n'),
|
||||||
forceNew: optionPlus('n'),
|
forceNew: optionPlus('n'),
|
||||||
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||||
|
focusLoadMore: just('l'),
|
||||||
reply: just('r'),
|
reply: just('r'),
|
||||||
favourite: just('f'),
|
favourite: just('f'),
|
||||||
boost: just('b'),
|
boost: just('b'),
|
||||||
mention: just('m'),
|
mention: just('m'),
|
||||||
open: any('enter', 'o'),
|
open: any('enter', 'o'),
|
||||||
openProfile: just('p'),
|
openProfile: just('p'),
|
||||||
moveDown: any('down', 'j'),
|
moveDown: just('j'),
|
||||||
moveUp: any('up', 'k'),
|
moveUp: just('k'),
|
||||||
toggleHidden: just('x'),
|
toggleHidden: just('x'),
|
||||||
toggleSensitive: just('h'),
|
toggleSensitive: just('h'),
|
||||||
toggleComposeSpoilers: optionPlus('x'),
|
toggleComposeSpoilers: optionPlus('x'),
|
||||||
|
|
|
@ -114,8 +114,6 @@ class Status extends ImmutablePureComponent {
|
||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
onMoveUp: PropTypes.func,
|
|
||||||
onMoveDown: PropTypes.func,
|
|
||||||
showThread: PropTypes.bool,
|
showThread: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
getScrollPosition: PropTypes.func,
|
getScrollPosition: PropTypes.func,
|
||||||
|
@ -328,14 +326,6 @@ class Status extends ImmutablePureComponent {
|
||||||
history.push(`/@${status.getIn(['account', 'acct'])}`);
|
history.push(`/@${status.getIn(['account', 'acct'])}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyMoveUp = e => {
|
|
||||||
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHotkeyMoveDown = e => {
|
|
||||||
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHotkeyToggleHidden = () => {
|
handleHotkeyToggleHidden = () => {
|
||||||
const { onToggleHidden } = this.props;
|
const { onToggleHidden } = this.props;
|
||||||
const status = this._properStatus();
|
const status = this._properStatus();
|
||||||
|
@ -399,8 +389,6 @@ class Status extends ImmutablePureComponent {
|
||||||
mention: this.handleHotkeyMention,
|
mention: this.handleHotkeyMention,
|
||||||
open: this.handleHotkeyOpen,
|
open: this.handleHotkeyOpen,
|
||||||
openProfile: this.handleHotkeyOpenProfile,
|
openProfile: this.handleHotkeyOpenProfile,
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
|
||||||
toggleHidden: this.handleHotkeyToggleHidden,
|
toggleHidden: this.handleHotkeyToggleHidden,
|
||||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||||
openMedia: this.handleHotkeyOpenMedia,
|
openMedia: this.handleHotkeyOpenMedia,
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { StatusQuoteManager } from '../components/status_quoted';
|
||||||
import { LoadGap } from './load_gap';
|
import { LoadGap } from './load_gap';
|
||||||
import ScrollableList from './scrollable_list';
|
import ScrollableList from './scrollable_list';
|
||||||
|
|
||||||
|
|
||||||
export default class StatusList extends ImmutablePureComponent {
|
export default class StatusList extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -40,84 +41,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
trackScroll: true,
|
trackScroll: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.columnHeaderHeight = this.node?.node
|
|
||||||
? parseFloat(
|
|
||||||
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
|
||||||
) || 0
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFeaturedStatusCount = () => {
|
|
||||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
getCurrentStatusIndex = (id, featured) => {
|
|
||||||
if (featured) {
|
|
||||||
return this.props.featuredStatusIds.indexOf(id);
|
|
||||||
} else {
|
|
||||||
return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveUp = (id, featured) => {
|
|
||||||
const index = this.getCurrentStatusIndex(id, featured);
|
|
||||||
this._selectChild(id, index, -1);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveDown = (id, featured) => {
|
|
||||||
const index = this.getCurrentStatusIndex(id, featured);
|
|
||||||
this._selectChild(id, index, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
_selectChild = (id, index, direction) => {
|
|
||||||
const listContainer = this.node?.node;
|
|
||||||
let listItem = listContainer?.querySelector(
|
|
||||||
// :nth-child uses 1-based indexing
|
|
||||||
`.item-list > :nth-child(${index + 1 + direction})`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!listItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If selected container element is empty, we skip it
|
|
||||||
if (listItem.matches(':empty')) {
|
|
||||||
this._selectChild(id, index + direction, direction);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the list item is a post
|
|
||||||
let targetElement = listItem.querySelector('.focusable');
|
|
||||||
|
|
||||||
// Otherwise, check if the item contains follow suggestions or
|
|
||||||
// is a 'load more' button.
|
|
||||||
if (
|
|
||||||
!targetElement && (
|
|
||||||
listItem.querySelector('.inline-follow-suggestions') ||
|
|
||||||
listItem.matches('.load-more')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
targetElement = listItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetElement) {
|
|
||||||
const elementRect = targetElement.getBoundingClientRect();
|
|
||||||
|
|
||||||
const isFullyVisible =
|
|
||||||
elementRect.top >= this.columnHeaderHeight &&
|
|
||||||
elementRect.bottom <= window.innerHeight;
|
|
||||||
|
|
||||||
if (!isFullyVisible) {
|
|
||||||
targetElement.scrollIntoView({
|
|
||||||
block: direction === 1 ? 'start' : 'center',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
targetElement.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
handleLoadOlder = debounce(() => {
|
||||||
const { statusIds, lastId, onLoadMore } = this.props;
|
const { statusIds, lastId, onLoadMore } = this.props;
|
||||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||||
|
@ -158,8 +81,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
<StatusQuoteManager
|
<StatusQuoteManager
|
||||||
key={statusId}
|
key={statusId}
|
||||||
id={statusId}
|
id={statusId}
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
scrollKey={this.props.scrollKey}
|
scrollKey={this.props.scrollKey}
|
||||||
showThread
|
showThread
|
||||||
|
@ -176,8 +97,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
key={`f-${statusId}`}
|
key={`f-${statusId}`}
|
||||||
id={statusId}
|
id={statusId}
|
||||||
featured
|
featured
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
showThread
|
showThread
|
||||||
withCounters={this.props.withCounters}
|
withCounters={this.props.withCounters}
|
||||||
|
@ -191,5 +110,4 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,7 @@ const getAccounts = createSelector(
|
||||||
|
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
|
export const Conversation = ({ conversation, scrollKey }) => {
|
||||||
const id = conversation.get('id');
|
const id = conversation.get('id');
|
||||||
const unread = conversation.get('unread');
|
const unread = conversation.get('unread');
|
||||||
const lastStatusId = conversation.get('last_status');
|
const lastStatusId = conversation.get('last_status');
|
||||||
|
@ -110,14 +110,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
dispatch(deleteConversation(id));
|
dispatch(deleteConversation(id));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleHotkeyMoveUp = useCallback(() => {
|
|
||||||
onMoveUp(id);
|
|
||||||
}, [id, onMoveUp]);
|
|
||||||
|
|
||||||
const handleHotkeyMoveDown = useCallback(() => {
|
|
||||||
onMoveDown(id);
|
|
||||||
}, [id, onMoveDown]);
|
|
||||||
|
|
||||||
const handleConversationMute = useCallback(() => {
|
const handleConversationMute = useCallback(() => {
|
||||||
if (lastStatus.get('muted')) {
|
if (lastStatus.get('muted')) {
|
||||||
dispatch(unmuteStatus(lastStatus.get('id')));
|
dispatch(unmuteStatus(lastStatus.get('id')));
|
||||||
|
@ -161,8 +153,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
const handlers = {
|
const handlers = {
|
||||||
reply: handleReply,
|
reply: handleReply,
|
||||||
open: handleClick,
|
open: handleClick,
|
||||||
moveUp: handleHotkeyMoveUp,
|
|
||||||
moveDown: handleHotkeyMoveDown,
|
|
||||||
toggleHidden: handleShowMore,
|
toggleHidden: handleShowMore,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -224,6 +214,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
Conversation.propTypes = {
|
Conversation.propTypes = {
|
||||||
conversation: ImmutablePropTypes.map.isRequired,
|
conversation: ImmutablePropTypes.map.isRequired,
|
||||||
scrollKey: PropTypes.string,
|
scrollKey: PropTypes.string,
|
||||||
onMoveUp: PropTypes.func,
|
|
||||||
onMoveDown: PropTypes.func,
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,20 +10,6 @@ import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
|
|
||||||
import { Conversation } from './conversation';
|
import { Conversation } from './conversation';
|
||||||
|
|
||||||
const focusChild = (node, index, alignTop) => {
|
|
||||||
const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (alignTop && node.scrollTop > element.offsetTop) {
|
|
||||||
element.scrollIntoView(true);
|
|
||||||
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
|
|
||||||
element.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConversationsList = ({ scrollKey, ...other }) => {
|
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
const listRef = useRef();
|
const listRef = useRef();
|
||||||
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
||||||
|
@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const lastStatusId = conversations.last()?.get('last_status');
|
const lastStatusId = conversations.last()?.get('last_status');
|
||||||
|
|
||||||
const handleMoveUp = useCallback(id => {
|
|
||||||
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
|
|
||||||
focusChild(listRef.current.node, elementIndex, true);
|
|
||||||
}, [listRef, conversations]);
|
|
||||||
|
|
||||||
const handleMoveDown = useCallback(id => {
|
|
||||||
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
|
|
||||||
focusChild(listRef.current.node, elementIndex, false);
|
|
||||||
}, [listRef, conversations]);
|
|
||||||
|
|
||||||
const debouncedLoadMore = useMemo(() => debounce(id => {
|
const debouncedLoadMore = useMemo(() => debounce(id => {
|
||||||
dispatch(expandConversations({ maxId: id }));
|
dispatch(expandConversations({ maxId: id }));
|
||||||
}, 300, { leading: true }), [dispatch]);
|
}, 300, { leading: true }), [dispatch]);
|
||||||
|
@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
<Conversation
|
<Conversation
|
||||||
key={item.get('id')}
|
key={item.get('id')}
|
||||||
conversation={item}
|
conversation={item}
|
||||||
onMoveUp={handleMoveUp}
|
|
||||||
onMoveDown={handleMoveDown}
|
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
import { useEffect, useCallback, useRef, useState, useId } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
@ -171,6 +171,7 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
|
||||||
hidden,
|
hidden,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const uniqueId = useId();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||||
|
@ -256,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='inline-follow-suggestions'>
|
<div
|
||||||
|
role='group'
|
||||||
|
aria-labelledby={uniqueId}
|
||||||
|
className='inline-follow-suggestions focusable'
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
<div className='inline-follow-suggestions__header'>
|
<div className='inline-follow-suggestions__header'>
|
||||||
<h3>
|
<h3 id={uniqueId}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='follow_suggestions.who_to_follow'
|
id='follow_suggestions.who_to_follow'
|
||||||
defaultMessage='Who to follow'
|
defaultMessage='Who to follow'
|
||||||
|
|
|
@ -83,13 +83,17 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>up</kbd>, <kbd>k</kbd></td>
|
<td><kbd>k</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>down</kbd>, <kbd>j</kbd></td>
|
<td><kbd>j</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><kbd>l</kbd></td>
|
||||||
|
<td><FormattedMessage id='keyboard_shortcuts.load_more' defaultMessage='Focus "Load more" button' /></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>1</kbd>-<kbd>9</kbd></td>
|
<td><kbd>1</kbd>-<kbd>9</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
|
||||||
|
|
|
@ -57,8 +57,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
notification: ImmutablePropTypes.map.isRequired,
|
notification: ImmutablePropTypes.map.isRequired,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
onMoveUp: PropTypes.func.isRequired,
|
|
||||||
onMoveDown: PropTypes.func.isRequired,
|
|
||||||
onMention: PropTypes.func.isRequired,
|
onMention: PropTypes.func.isRequired,
|
||||||
onFavourite: PropTypes.func.isRequired,
|
onFavourite: PropTypes.func.isRequired,
|
||||||
onReblog: PropTypes.func.isRequired,
|
onReblog: PropTypes.func.isRequired,
|
||||||
|
@ -73,16 +71,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
...WithRouterPropTypes,
|
...WithRouterPropTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = () => {
|
|
||||||
const { notification, onMoveUp } = this.props;
|
|
||||||
onMoveUp(notification.get('id'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveDown = () => {
|
|
||||||
const { notification, onMoveDown } = this.props;
|
|
||||||
onMoveDown(notification.get('id'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOpen = () => {
|
handleOpen = () => {
|
||||||
const { notification } = this.props;
|
const { notification } = this.props;
|
||||||
|
|
||||||
|
@ -128,8 +116,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
mention: this.handleMention,
|
mention: this.handleMention,
|
||||||
open: this.handleOpen,
|
open: this.handleOpen,
|
||||||
openProfile: this.handleOpenProfile,
|
openProfile: this.handleOpenProfile,
|
||||||
moveUp: this.handleMoveUp,
|
|
||||||
moveDown: this.handleMoveDown,
|
|
||||||
toggleHidden: this.handleHotkeyToggleHidden,
|
toggleHidden: this.handleHotkeyToggleHidden,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -180,8 +166,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
id={notification.get('status')}
|
id={notification.get('status')}
|
||||||
withDismiss
|
withDismiss
|
||||||
hidden={this.props.hidden}
|
hidden={this.props.hidden}
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
contextType='notifications'
|
contextType='notifications'
|
||||||
getScrollPosition={this.props.getScrollPosition}
|
getScrollPosition={this.props.getScrollPosition}
|
||||||
updateScrollBottom={this.props.updateScrollBottom}
|
updateScrollBottom={this.props.updateScrollBottom}
|
||||||
|
|
|
@ -31,21 +31,6 @@ const messages = defineMessages({
|
||||||
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
|
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectChild = (ref, index, alignTop) => {
|
|
||||||
const container = ref.current.node;
|
|
||||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (alignTop && container.scrollTop > element.offsetTop) {
|
|
||||||
element.scrollIntoView(true);
|
|
||||||
} else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
|
||||||
element.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
const columnRef = useRef();
|
const columnRef = useRef();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
dispatch(acceptNotificationRequest({ id }));
|
dispatch(acceptNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleMoveUp = useCallback(id => {
|
|
||||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
|
||||||
selectChild(columnRef, elementIndex, true);
|
|
||||||
}, [columnRef, notifications]);
|
|
||||||
|
|
||||||
const handleMoveDown = useCallback(id => {
|
|
||||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
|
||||||
selectChild(columnRef, elementIndex, false);
|
|
||||||
}, [columnRef, notifications]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchNotificationRequest({ id }));
|
dispatch(fetchNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
key={item.get('id')}
|
key={item.get('id')}
|
||||||
notification={item}
|
notification={item}
|
||||||
accountId={item.get('account')}
|
accountId={item.get('account')}
|
||||||
onMoveUp={handleMoveUp}
|
|
||||||
onMoveDown={handleMoveDown}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
|
|
|
@ -24,9 +24,7 @@ import { NotificationUpdate } from './notification_update';
|
||||||
export const NotificationGroup: React.FC<{
|
export const NotificationGroup: React.FC<{
|
||||||
notificationGroupId: NotificationGroupModel['group_key'];
|
notificationGroupId: NotificationGroupModel['group_key'];
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
onMoveUp: (groupId: string) => void;
|
}> = ({ notificationGroupId, unread }) => {
|
||||||
onMoveDown: (groupId: string) => void;
|
|
||||||
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
|
|
||||||
const notificationGroup = useAppSelector((state) =>
|
const notificationGroup = useAppSelector((state) =>
|
||||||
state.notificationGroups.groups.find(
|
state.notificationGroups.groups.find(
|
||||||
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
||||||
|
@ -42,14 +40,6 @@ export const NotificationGroup: React.FC<{
|
||||||
|
|
||||||
const handlers = useMemo(
|
const handlers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
moveUp: () => {
|
|
||||||
onMoveUp(notificationGroupId);
|
|
||||||
},
|
|
||||||
|
|
||||||
moveDown: () => {
|
|
||||||
onMoveDown(notificationGroupId);
|
|
||||||
},
|
|
||||||
|
|
||||||
openProfile: () => {
|
openProfile: () => {
|
||||||
if (accountId) dispatch(navigateToProfile(accountId));
|
if (accountId) dispatch(navigateToProfile(accountId));
|
||||||
},
|
},
|
||||||
|
@ -58,7 +48,7 @@ export const NotificationGroup: React.FC<{
|
||||||
if (accountId) dispatch(mentionComposeById(accountId));
|
if (accountId) dispatch(mentionComposeById(accountId));
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
|
[dispatch, accountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
||||||
|
|
|
@ -99,29 +99,6 @@ export const Notifications: React.FC<{
|
||||||
|
|
||||||
const columnRef = useRef<ColumnRef>(null);
|
const columnRef = useRef<ColumnRef>(null);
|
||||||
|
|
||||||
const selectChild = useCallback((index: number, alignTop: boolean) => {
|
|
||||||
const container = columnRef.current?.node as HTMLElement | undefined;
|
|
||||||
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const element = container.querySelector<HTMLElement>(
|
|
||||||
`article:nth-of-type(${index + 1}) .focusable`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (alignTop && container.scrollTop > element.offsetTop) {
|
|
||||||
element.scrollIntoView(true);
|
|
||||||
} else if (
|
|
||||||
!alignTop &&
|
|
||||||
container.scrollTop + container.clientHeight <
|
|
||||||
element.offsetTop + element.offsetHeight
|
|
||||||
) {
|
|
||||||
element.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keep track of mounted components for unread notification handling
|
// Keep track of mounted components for unread notification handling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void dispatch(mountNotifications());
|
void dispatch(mountNotifications());
|
||||||
|
@ -187,28 +164,6 @@ export const Notifications: React.FC<{
|
||||||
columnRef.current?.scrollTop();
|
columnRef.current?.scrollTop();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMoveUp = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
const elementIndex =
|
|
||||||
notifications.findIndex(
|
|
||||||
(item) => item.type !== 'gap' && item.group_key === id,
|
|
||||||
) - 1;
|
|
||||||
selectChild(elementIndex, true);
|
|
||||||
},
|
|
||||||
[notifications, selectChild],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMoveDown = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
const elementIndex =
|
|
||||||
notifications.findIndex(
|
|
||||||
(item) => item.type !== 'gap' && item.group_key === id,
|
|
||||||
) + 1;
|
|
||||||
selectChild(elementIndex, false);
|
|
||||||
},
|
|
||||||
[notifications, selectChild],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMarkAsRead = useCallback(() => {
|
const handleMarkAsRead = useCallback(() => {
|
||||||
dispatch(markNotificationsAsRead());
|
dispatch(markNotificationsAsRead());
|
||||||
void dispatch(submitMarkers({ immediate: true }));
|
void dispatch(submitMarkers({ immediate: true }));
|
||||||
|
@ -241,8 +196,6 @@ export const Notifications: React.FC<{
|
||||||
<NotificationGroup
|
<NotificationGroup
|
||||||
key={item.group_key}
|
key={item.group_key}
|
||||||
notificationGroupId={item.group_key}
|
notificationGroupId={item.group_key}
|
||||||
onMoveUp={handleMoveUp}
|
|
||||||
onMoveDown={handleMoveDown}
|
|
||||||
unread={
|
unread={
|
||||||
lastReadId !== '0' &&
|
lastReadId !== '0' &&
|
||||||
!!item.page_max_id &&
|
!!item.page_max_id &&
|
||||||
|
@ -251,15 +204,7 @@ export const Notifications: React.FC<{
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [
|
}, [notifications, isLoading, hasMore, lastReadId, handleLoadGap]);
|
||||||
notifications,
|
|
||||||
isLoading,
|
|
||||||
hasMore,
|
|
||||||
lastReadId,
|
|
||||||
handleLoadGap,
|
|
||||||
handleMoveUp,
|
|
||||||
handleMoveDown,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const prepend = (
|
const prepend = (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -396,15 +396,6 @@ class Status extends ImmutablePureComponent {
|
||||||
this.props.dispatch(unblockDomain(domain));
|
this.props.dispatch(unblockDomain(domain));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
handleHotkeyMoveUp = () => {
|
|
||||||
this.handleMoveUp(this.props.status.get('id'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHotkeyMoveDown = () => {
|
|
||||||
this.handleMoveDown(this.props.status.get('id'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleHotkeyReply = e => {
|
handleHotkeyReply = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.handleReplyClick(this.props.status);
|
this.handleReplyClick(this.props.status);
|
||||||
|
@ -439,54 +430,6 @@ class Status extends ImmutablePureComponent {
|
||||||
this.handleTranslate(this.props.status);
|
this.handleTranslate(this.props.status);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = id => {
|
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
|
||||||
|
|
||||||
if (id === status.get('id')) {
|
|
||||||
this._selectChild(ancestorsIds.length - 1, true);
|
|
||||||
} else {
|
|
||||||
let index = ancestorsIds.indexOf(id);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
index = descendantsIds.indexOf(id);
|
|
||||||
this._selectChild(ancestorsIds.length + index, true);
|
|
||||||
} else {
|
|
||||||
this._selectChild(index - 1, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveDown = id => {
|
|
||||||
const { status, ancestorsIds, descendantsIds } = this.props;
|
|
||||||
|
|
||||||
if (id === status.get('id')) {
|
|
||||||
this._selectChild(ancestorsIds.length + 1, false);
|
|
||||||
} else {
|
|
||||||
let index = ancestorsIds.indexOf(id);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
index = descendantsIds.indexOf(id);
|
|
||||||
this._selectChild(ancestorsIds.length + index + 2, false);
|
|
||||||
} else {
|
|
||||||
this._selectChild(index + 1, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_selectChild (index, align_top) {
|
|
||||||
const container = this.node;
|
|
||||||
const element = container.querySelectorAll('.focusable')[index];
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
if (align_top && container.scrollTop > element.offsetTop) {
|
|
||||||
element.scrollIntoView(true);
|
|
||||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
|
||||||
element.scrollIntoView(false);
|
|
||||||
}
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderChildren (list, ancestors) {
|
renderChildren (list, ancestors) {
|
||||||
const { params: { statusId } } = this.props;
|
const { params: { statusId } } = this.props;
|
||||||
|
|
||||||
|
@ -494,8 +437,6 @@ class Status extends ImmutablePureComponent {
|
||||||
<StatusQuoteManager
|
<StatusQuoteManager
|
||||||
key={id}
|
key={id}
|
||||||
id={id}
|
id={id}
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
contextType='thread'
|
contextType='thread'
|
||||||
previousId={i > 0 ? list[i - 1] : undefined}
|
previousId={i > 0 ? list[i - 1] : undefined}
|
||||||
nextId={list[i + 1] || (ancestors && statusId)}
|
nextId={list[i + 1] || (ancestors && statusId)}
|
||||||
|
@ -602,8 +543,6 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
|
||||||
reply: this.handleHotkeyReply,
|
reply: this.handleHotkeyReply,
|
||||||
favourite: this.handleHotkeyFavourite,
|
favourite: this.handleHotkeyFavourite,
|
||||||
boost: this.handleHotkeyBoost,
|
boost: this.handleHotkeyBoost,
|
||||||
|
@ -626,7 +565,7 @@ class Status extends ImmutablePureComponent {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||||
<div className={classNames('scrollable', { fullscreen })} ref={this.setContainerRef}>
|
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<Hotkeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
|
|
|
@ -77,6 +77,7 @@ import {
|
||||||
AccountFeatured,
|
AccountFeatured,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { ColumnsContextProvider } from './util/columns_context';
|
import { ColumnsContextProvider } from './util/columns_context';
|
||||||
|
import { focusColumn, getFocusedItemIndex, focusItemSibling } from './util/focusUtils';
|
||||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||||
|
|
||||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||||
|
@ -446,20 +447,34 @@ class UI extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleHotkeyFocusColumn = e => {
|
handleHotkeyFocusColumn = e => {
|
||||||
const index = (e.key * 1) + 1; // First child is drawer, skip that
|
focusColumn({index: e.key * 1});
|
||||||
const column = this.node.querySelector(`.column:nth-child(${index})`);
|
};
|
||||||
if (!column) return;
|
|
||||||
const container = column.querySelector('.scrollable');
|
|
||||||
|
|
||||||
if (container) {
|
handleHotkeyLoadMore = () => {
|
||||||
const status = container.querySelector('.focusable');
|
document.querySelector('.load-more')?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
if (status) {
|
handleMoveUp = () => {
|
||||||
if (container.scrollTop > status.offsetTop) {
|
const currentItemIndex = getFocusedItemIndex();
|
||||||
status.scrollIntoView(true);
|
if (currentItemIndex === -1) {
|
||||||
}
|
focusColumn({
|
||||||
status.focus();
|
index: 1,
|
||||||
}
|
focusItem: 'first-visible',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
focusItemSibling(currentItemIndex, -1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMoveDown = () => {
|
||||||
|
const currentItemIndex = getFocusedItemIndex();
|
||||||
|
if (currentItemIndex === -1) {
|
||||||
|
focusColumn({
|
||||||
|
index: 1,
|
||||||
|
focusItem: 'first-visible',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
focusItemSibling(currentItemIndex, 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -542,6 +557,9 @@ class UI extends PureComponent {
|
||||||
forceNew: this.handleHotkeyForceNew,
|
forceNew: this.handleHotkeyForceNew,
|
||||||
toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
|
toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
|
||||||
focusColumn: this.handleHotkeyFocusColumn,
|
focusColumn: this.handleHotkeyFocusColumn,
|
||||||
|
focusLoadMore: this.handleHotkeyLoadMore,
|
||||||
|
moveDown: this.handleMoveDown,
|
||||||
|
moveUp: this.handleMoveUp,
|
||||||
back: this.handleHotkeyBack,
|
back: this.handleHotkeyBack,
|
||||||
goToHome: this.handleHotkeyGoToHome,
|
goToHome: this.handleHotkeyGoToHome,
|
||||||
goToNotifications: this.handleHotkeyGoToNotifications,
|
goToNotifications: this.handleHotkeyGoToNotifications,
|
||||||
|
|
132
app/javascript/mastodon/features/ui/util/focusUtils.ts
Normal file
132
app/javascript/mastodon/features/ui/util/focusUtils.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import initialState from '@/mastodon/initial_state';
|
||||||
|
|
||||||
|
interface FocusColumnOptions {
|
||||||
|
index?: number;
|
||||||
|
focusItem?: 'first' | 'first-visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move focus to the column of the passed index (1-based).
|
||||||
|
* Can either focus the topmost item or the first one in the viewport
|
||||||
|
*/
|
||||||
|
export function focusColumn({
|
||||||
|
index = 1,
|
||||||
|
focusItem = 'first',
|
||||||
|
}: FocusColumnOptions = {}) {
|
||||||
|
// Skip the leftmost drawer in multi-column mode
|
||||||
|
const indexOffset = initialState?.meta.advanced_layout ? 1 : 0;
|
||||||
|
|
||||||
|
const column = document.querySelector(
|
||||||
|
`.column:nth-child(${index + indexOffset})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!column) return;
|
||||||
|
|
||||||
|
const container = column.querySelector('.scrollable');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let itemToFocus: HTMLElement | null = null;
|
||||||
|
|
||||||
|
if (focusItem === 'first-visible') {
|
||||||
|
const focusableItems = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>(
|
||||||
|
'.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');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemToFocus) {
|
||||||
|
if (container.scrollTop > itemToFocus.offsetTop) {
|
||||||
|
itemToFocus.scrollIntoView(true);
|
||||||
|
}
|
||||||
|
itemToFocus.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the index of the currently focused item in one of our item lists
|
||||||
|
*/
|
||||||
|
export function getFocusedItemIndex() {
|
||||||
|
const focusedElement = document.activeElement;
|
||||||
|
const itemList = focusedElement?.closest('.item-list');
|
||||||
|
|
||||||
|
if (!focusedElement || !itemList) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusedItem: HTMLElement | null = null;
|
||||||
|
if (focusedElement.parentElement === itemList) {
|
||||||
|
focusedItem = focusedElement as HTMLElement;
|
||||||
|
} else {
|
||||||
|
focusedItem = focusedElement.closest('.item-list > *');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!focusedItem) return -1;
|
||||||
|
|
||||||
|
const items = Array.from(itemList.children);
|
||||||
|
return items.indexOf(focusedItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the item next to the one with the provided index
|
||||||
|
*/
|
||||||
|
export function focusItemSibling(
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
scrollThreshold = 62,
|
||||||
|
) {
|
||||||
|
const focusedElement = document.activeElement;
|
||||||
|
const itemList = focusedElement?.closest('.item-list');
|
||||||
|
|
||||||
|
const siblingItem = itemList?.querySelector<HTMLElement>(
|
||||||
|
// :nth-child uses 1-based indexing
|
||||||
|
`.item-list > :nth-child(${index + 1 + direction})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!siblingItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If sibling element is empty, we skip it
|
||||||
|
if (siblingItem.matches(':empty')) {
|
||||||
|
focusItemSibling(index + direction, direction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the sibling is a post or a 'follow suggestions' widget
|
||||||
|
let targetElement = siblingItem.querySelector<HTMLElement>('.focusable');
|
||||||
|
|
||||||
|
// Otherwise, check if the item is a 'load more' button.
|
||||||
|
if (!targetElement && siblingItem.matches('.load-more')) {
|
||||||
|
targetElement = siblingItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
const elementRect = targetElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const isFullyVisible =
|
||||||
|
elementRect.top >= scrollThreshold &&
|
||||||
|
elementRect.bottom <= window.innerHeight;
|
||||||
|
|
||||||
|
if (!isFullyVisible) {
|
||||||
|
targetElement.scrollIntoView({
|
||||||
|
block: direction === 1 ? 'start' : 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
targetElement.focus();
|
||||||
|
}
|
||||||
|
}
|
|
@ -483,6 +483,7 @@
|
||||||
"keyboard_shortcuts.home": "Open home timeline",
|
"keyboard_shortcuts.home": "Open home timeline",
|
||||||
"keyboard_shortcuts.hotkey": "Hotkey",
|
"keyboard_shortcuts.hotkey": "Hotkey",
|
||||||
"keyboard_shortcuts.legend": "Display this legend",
|
"keyboard_shortcuts.legend": "Display this legend",
|
||||||
|
"keyboard_shortcuts.load_more": "Focus \"Load more\" button",
|
||||||
"keyboard_shortcuts.local": "Open local timeline",
|
"keyboard_shortcuts.local": "Open local timeline",
|
||||||
"keyboard_shortcuts.mention": "Mention author",
|
"keyboard_shortcuts.mention": "Mention author",
|
||||||
"keyboard_shortcuts.muted": "Open muted users list",
|
"keyboard_shortcuts.muted": "Open muted users list",
|
||||||
|
|
|
@ -435,6 +435,10 @@
|
||||||
.inline-follow-suggestions {
|
.inline-follow-suggestions {
|
||||||
background-color: color.change($ui-highlight-color, $alpha: 0.1);
|
background-color: color.change($ui-highlight-color, $alpha: 0.1);
|
||||||
border-bottom-color: color.change($ui-highlight-color, $alpha: 0.3);
|
border-bottom-color: color.change($ui-highlight-color, $alpha: 0.3);
|
||||||
|
|
||||||
|
&.focusable:focus-visible {
|
||||||
|
background: color.change($ui-highlight-color, $alpha: 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-follow-suggestions__body__scrollable__card {
|
.inline-follow-suggestions__body__scrollable__card {
|
||||||
|
|
|
@ -10376,6 +10376,10 @@ noscript {
|
||||||
border-bottom: 1px solid var(--background-border-color);
|
border-bottom: 1px solid var(--background-border-color);
|
||||||
background: color.change($ui-highlight-color, $alpha: 0.05);
|
background: color.change($ui-highlight-color, $alpha: 0.05);
|
||||||
|
|
||||||
|
&.focusable:focus-visible {
|
||||||
|
background: color.change($ui-highlight-color, $alpha: 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user