Merge commit '74b3b6c798d1f137947e80df8eefb7412e70febd' into glitch-soc/merge-upstream
This commit is contained in:
commit
1b92ac0729
41 files changed
+593
-156
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
|
||||
import type { ApiHashtagJSON } from '@/mastodon/api_types/tags';
|
||||
import { Combobox } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
addFeaturedTag,
|
||||
@@ -15,10 +15,50 @@ import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
type SearchResult = Omit<ApiHashtagJSON, 'url' | 'history'> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: {
|
||||
id: 'account_edit_tags.search_placeholder',
|
||||
defaultMessage: 'Enter a hashtag…',
|
||||
},
|
||||
addTag: {
|
||||
id: 'account_edit_tags.add_tag',
|
||||
defaultMessage: 'Add #{tagName}',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountEditTagSearch: FC = () => {
|
||||
const { query, isLoading, results } = useAppSelector(
|
||||
(state) => state.profileEdit.search,
|
||||
);
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
query,
|
||||
isLoading,
|
||||
results: rawResults,
|
||||
} = useAppSelector((state) => state.profileEdit.search);
|
||||
const results = useMemo(() => {
|
||||
if (!rawResults) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: SearchResult[] = [...rawResults]; // Make array mutable
|
||||
const trimmedQuery = query.trim();
|
||||
if (
|
||||
trimmedQuery.length > 0 &&
|
||||
results.every(
|
||||
(result) => result.name.toLowerCase() !== trimmedQuery.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
results.push({
|
||||
id: 'new',
|
||||
name: trimmedQuery,
|
||||
label: intl.formatMessage(messages.addTag, { tagName: trimmedQuery }),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, [intl, query, rawResults]);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
@@ -28,10 +68,8 @@ export const AccountEditTagSearch: FC = () => {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: ApiFeaturedTagJSON) => {
|
||||
(item: SearchResult) => {
|
||||
void dispatch(clearSearch());
|
||||
void dispatch(addFeaturedTag({ name: item.name }));
|
||||
},
|
||||
@@ -42,11 +80,8 @@ export const AccountEditTagSearch: FC = () => {
|
||||
<Combobox
|
||||
value={query}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'account_edit_tags.search_placeholder',
|
||||
defaultMessage: 'Enter a hashtag…',
|
||||
})}
|
||||
items={results ?? []}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
items={results}
|
||||
isLoading={isLoading}
|
||||
renderItem={renderItem}
|
||||
onSelectItem={handleSelect}
|
||||
@@ -57,4 +92,4 @@ export const AccountEditTagSearch: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = (item: ApiFeaturedTagJSON) => <p>#{item.name}</p>;
|
||||
const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`;
|
||||
@@ -3,15 +3,15 @@ import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import type { ApiFeaturedTagJSON } from '@/mastodon/api_types/tags';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { Tag } from '@/mastodon/components/tags/tag';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
addFeaturedTag,
|
||||
deleteFeaturedTag,
|
||||
fetchFeaturedTags,
|
||||
fetchProfile,
|
||||
fetchSuggestedTags,
|
||||
} from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
@@ -35,9 +35,9 @@ const messages = defineMessages({
|
||||
const selectTags = createAppSelector(
|
||||
[(state) => state.profileEdit],
|
||||
(profileEdit) => ({
|
||||
tags: profileEdit.tags ?? [],
|
||||
tags: profileEdit.profile?.featuredTags ?? [],
|
||||
tagSuggestions: profileEdit.tagSuggestions ?? [],
|
||||
isLoading: !profileEdit.tags || !profileEdit.tagSuggestions,
|
||||
isLoading: !profileEdit.profile || !profileEdit.tagSuggestions,
|
||||
isPending: profileEdit.isPending,
|
||||
}),
|
||||
);
|
||||
@@ -52,7 +52,7 @@ export const AccountEditFeaturedTags: FC = () => {
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
void dispatch(fetchFeaturedTags());
|
||||
void dispatch(fetchProfile());
|
||||
void dispatch(fetchSuggestedTags());
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -78,7 +78,9 @@ export const AccountEditFeaturedTags: FC = () => {
|
||||
defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.'
|
||||
tagName='p'
|
||||
/>
|
||||
|
||||
<AccountEditTagSearch />
|
||||
|
||||
{tagSuggestions.length > 0 && (
|
||||
<div className={classes.tagSuggestions}>
|
||||
<FormattedMessage
|
||||
@@ -90,7 +92,9 @@ export const AccountEditFeaturedTags: FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <LoadingIndicator />}
|
||||
|
||||
<AccountEditItemList
|
||||
items={tags}
|
||||
disabled={isPending}
|
||||
@@ -102,15 +106,15 @@ export const AccountEditFeaturedTags: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
function renderTag(tag: ApiFeaturedTagJSON) {
|
||||
function renderTag(tag: TagData) {
|
||||
return (
|
||||
<div className={classes.tagItem}>
|
||||
<h4>#{tag.name}</h4>
|
||||
{tag.statuses_count > 0 && (
|
||||
{tag.statusesCount > 0 && (
|
||||
<FormattedMessage
|
||||
id='account_edit_tags.tag_status_count'
|
||||
defaultMessage='{count, plural, one {# post} other {# posts}}'
|
||||
values={{ count: tag.statuses_count }}
|
||||
values={{ count: tag.statusesCount }}
|
||||
tagName='p'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -15,10 +15,7 @@ import { useElementHandledLink } from '@/mastodon/components/status/handled_link
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import {
|
||||
fetchFeaturedTags,
|
||||
fetchProfile,
|
||||
} from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { fetchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
|
||||
@@ -87,9 +84,8 @@ export const AccountEdit: FC = () => {
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { profile, tags = [] } = useAppSelector((state) => state.profileEdit);
|
||||
const { profile } = useAppSelector((state) => state.profileEdit);
|
||||
useEffect(() => {
|
||||
void dispatch(fetchFeaturedTags());
|
||||
void dispatch(fetchProfile());
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -127,7 +123,7 @@ export const AccountEdit: FC = () => {
|
||||
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
|
||||
const hasName = !!profile.displayName;
|
||||
const hasBio = !!profile.bio;
|
||||
const hasTags = tags.length > 0;
|
||||
const hasTags = profile.featuredTags.length > 0;
|
||||
|
||||
return (
|
||||
<AccountEditColumn
|
||||
@@ -190,7 +186,7 @@ export const AccountEdit: FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{tags.map((tag) => `#${tag.name}`).join(', ')}
|
||||
{profile.featuredTags.map((tag) => `#${tag.name}`).join(', ')}
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
|
||||
@@ -4,7 +4,11 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { ModalShell } from '@/mastodon/components/modal_shell';
|
||||
import {
|
||||
ModalShell,
|
||||
ModalShellActions,
|
||||
ModalShellBody,
|
||||
} from '@/mastodon/components/modal_shell';
|
||||
|
||||
import type { AccountField } from '../common';
|
||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||
@@ -19,7 +23,7 @@ export const AccountFieldModal: FC<{
|
||||
const handleValueElement = useFieldHtml(field.valueHasEmojis);
|
||||
return (
|
||||
<ModalShell>
|
||||
<ModalShell.Body>
|
||||
<ModalShellBody>
|
||||
<EmojiHTML
|
||||
as='h2'
|
||||
htmlString={field.name_emojified}
|
||||
@@ -31,12 +35,12 @@ export const AccountFieldModal: FC<{
|
||||
onElement={handleValueElement}
|
||||
className={classes.fieldValue}
|
||||
/>
|
||||
</ModalShell.Body>
|
||||
<ModalShell.Actions>
|
||||
</ModalShellBody>
|
||||
<ModalShellActions>
|
||||
<Button onClick={onClose} plain>
|
||||
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
|
||||
</Button>
|
||||
</ModalShell.Actions>
|
||||
</ModalShellActions>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,11 @@ import { AvatarGroup } from 'mastodon/components/avatar_group';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { CopyLinkField } from 'mastodon/components/form_fields';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { ModalShell } from 'mastodon/components/modal_shell';
|
||||
import {
|
||||
ModalShell,
|
||||
ModalShellActions,
|
||||
ModalShellBody,
|
||||
} from 'mastodon/components/modal_shell';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { AuthorNote } from '.';
|
||||
@@ -64,7 +68,7 @@ export const CollectionShareModal: React.FC<{
|
||||
|
||||
return (
|
||||
<ModalShell>
|
||||
<ModalShell.Body>
|
||||
<ModalShellBody>
|
||||
<h1 className={classes.heading}>
|
||||
{isNew ? (
|
||||
<FormattedMessage
|
||||
@@ -112,9 +116,9 @@ export const CollectionShareModal: React.FC<{
|
||||
})}
|
||||
value={collectionLink}
|
||||
/>
|
||||
</ModalShell.Body>
|
||||
</ModalShellBody>
|
||||
|
||||
<ModalShell.Actions className={classes.actions}>
|
||||
<ModalShellActions className={classes.actions}>
|
||||
<div className={classes.shareButtonWrapper}>
|
||||
<Button secondary onClick={handleShareViaPost}>
|
||||
<FormattedMessage
|
||||
@@ -135,7 +139,7 @@ export const CollectionShareModal: React.FC<{
|
||||
<Button plain onClick={onClose} className={classes.closeButtonMobile}>
|
||||
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
|
||||
</Button>
|
||||
</ModalShell.Actions>
|
||||
</ModalShellActions>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -134,6 +134,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||
<td><kbd>g</kbd>+<kbd>h</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>g</kbd>+<kbd>e</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.explore' defaultMessage='to open trending timeline' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>g</kbd>+<kbd>n</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></td>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||
import { WordmarkLogo } from 'mastodon/components/logo';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
import { getNavigationSkipLinkId } from 'mastodon/features/ui/components/skip_links';
|
||||
import { useBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import {
|
||||
@@ -224,7 +225,11 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link to='/' className='column-link column-link--logo'>
|
||||
<Link
|
||||
to='/'
|
||||
className='column-link column-link--logo'
|
||||
id={getNavigationSkipLinkId()}
|
||||
>
|
||||
<WordmarkLogo />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Children, cloneElement, useCallback } from 'react';
|
||||
import { Children, cloneElement, createContext, useContext, useCallback } from 'react';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
@@ -53,6 +53,13 @@ const TabsBarPortal = () => {
|
||||
return <div id='tabs-bar__portal' ref={setRef} />;
|
||||
};
|
||||
|
||||
// Simple context to allow column children to know which column they're in
|
||||
export const ColumnIndexContext = createContext(1);
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
export const useColumnIndexContext = () => useContext(ColumnIndexContext);
|
||||
|
||||
export default class ColumnsArea extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
columns: ImmutablePropTypes.list.isRequired,
|
||||
@@ -140,18 +147,22 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||
|
||||
return (
|
||||
<div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}>
|
||||
{columns.map(column => {
|
||||
{columns.map((column, index) => {
|
||||
const params = column.get('params', null) === null ? null : column.get('params').toJS();
|
||||
const other = params && params.other ? params.other : {};
|
||||
|
||||
return (
|
||||
<Bundle key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
|
||||
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
|
||||
</Bundle>
|
||||
<ColumnIndexContext.Provider value={index} key={column.get('uuid')}>
|
||||
<Bundle fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
|
||||
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
|
||||
</Bundle>
|
||||
</ColumnIndexContext.Provider>
|
||||
);
|
||||
})}
|
||||
|
||||
{Children.map(children, child => cloneElement(child, { multiColumn: true }))}
|
||||
<ColumnIndexContext.Provider value={columns.size}>
|
||||
{Children.map(children, child => cloneElement(child, { multiColumn: true }))}
|
||||
</ColumnIndexContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-5
@@ -3,7 +3,11 @@ import { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { ModalShell } from 'mastodon/components/modal_shell';
|
||||
import {
|
||||
ModalShell,
|
||||
ModalShellActions,
|
||||
ModalShellBody,
|
||||
} from 'mastodon/components/modal_shell';
|
||||
|
||||
export interface BaseConfirmationModalProps {
|
||||
onClose: () => void;
|
||||
@@ -58,14 +62,14 @@ export const ConfirmationModal: React.FC<
|
||||
|
||||
return (
|
||||
<ModalShell>
|
||||
<ModalShell.Body>
|
||||
<ModalShellBody>
|
||||
<h1 id={titleId}>{title}</h1>
|
||||
{message && <p>{message}</p>}
|
||||
|
||||
{extraContent ?? children}
|
||||
</ModalShell.Body>
|
||||
</ModalShellBody>
|
||||
|
||||
<ModalShell.Actions>
|
||||
<ModalShellActions>
|
||||
<button onClick={onClose} className='link-button' type='button'>
|
||||
{cancel ?? (
|
||||
<FormattedMessage
|
||||
@@ -99,7 +103,7 @@ export const ConfirmationModal: React.FC<
|
||||
{confirm}
|
||||
</Button>
|
||||
{/* eslint-enable */}
|
||||
</ModalShell.Actions>
|
||||
</ModalShellActions>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DomainBlockModal,
|
||||
ReportModal,
|
||||
ReportCollectionModal,
|
||||
ShareCollectionModal,
|
||||
EmbedModal,
|
||||
ListAdder,
|
||||
CompareHistoryModal,
|
||||
@@ -80,7 +79,7 @@ export const MODAL_COMPONENTS = {
|
||||
'DOMAIN_BLOCK': DomainBlockModal,
|
||||
'REPORT': ReportModal,
|
||||
'REPORT_COLLECTION': ReportCollectionModal,
|
||||
'SHARE_COLLECTION': ShareCollectionModal,
|
||||
'SHARE_COLLECTION': () => import('@/mastodon/features/collections/detail/share_modal').then(module => ({ default: module.CollectionShareModal })),
|
||||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: AltTextModal }),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useId } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import classes from './skip_links.module.scss';
|
||||
|
||||
export const getNavigationSkipLinkId = () => 'skip-link-target-nav';
|
||||
export const getColumnSkipLinkId = (index: number) =>
|
||||
`skip-link-target-content-${index}`;
|
||||
|
||||
export const SkipLinks: React.FC<{
|
||||
multiColumn: boolean;
|
||||
onFocusGettingStartedColumn: () => void;
|
||||
}> = ({ multiColumn, onFocusGettingStartedColumn }) => {
|
||||
const intl = useIntl();
|
||||
const columnCount = useAppSelector((state) => {
|
||||
const settings = state.settings as Immutable.Collection<string, unknown>;
|
||||
return (settings.get('columns') as Immutable.Map<number, unknown>).size;
|
||||
});
|
||||
|
||||
const focusMultiColumnNavbar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onFocusGettingStartedColumn();
|
||||
},
|
||||
[onFocusGettingStartedColumn],
|
||||
);
|
||||
|
||||
return (
|
||||
<ul className={classes.list}>
|
||||
<li className={classes.listItem}>
|
||||
<SkipLink target={getColumnSkipLinkId(1)} hotkey='1'>
|
||||
{intl.formatMessage({
|
||||
id: 'skip_links.skip_to_content',
|
||||
defaultMessage: 'Skip to main content',
|
||||
})}
|
||||
</SkipLink>
|
||||
</li>
|
||||
<li className={classes.listItem}>
|
||||
<SkipLink
|
||||
target={multiColumn ? `/getting-started` : getNavigationSkipLinkId()}
|
||||
onRouterLinkClick={multiColumn ? focusMultiColumnNavbar : undefined}
|
||||
hotkey={multiColumn ? `${columnCount}` : '2'}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'skip_links.skip_to_navigation',
|
||||
defaultMessage: 'Skip to main navigation',
|
||||
})}
|
||||
</SkipLink>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const SkipLink: React.FC<{
|
||||
children: string;
|
||||
target: string;
|
||||
onRouterLinkClick?: React.MouseEventHandler;
|
||||
hotkey: string;
|
||||
}> = ({ children, hotkey, target, onRouterLinkClick }) => {
|
||||
const intl = useIntl();
|
||||
const id = useId();
|
||||
return (
|
||||
<>
|
||||
<a href={`#${target}`} aria-describedby={id} onClick={onRouterLinkClick}>
|
||||
{children}
|
||||
</a>
|
||||
<span id={id} className={classes.hotkeyHint}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: 'skip_links.hotkey',
|
||||
defaultMessage: '<span>Hotkey</span> {hotkey}',
|
||||
},
|
||||
{
|
||||
hotkey,
|
||||
span: (text) => <span className='sr-only'>{text}</span>,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
.list {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
margin: 10px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
|
||||
/* Hide visually when not focused */
|
||||
&:not(:focus-within) {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
clip-path: inset(50%);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-inline-end: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: var(--outline-focus-default);
|
||||
background: var(--color-bg-brand-softer);
|
||||
}
|
||||
|
||||
:any-link {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
color: inherit;
|
||||
text-decoration-color: var(--color-text-secondary);
|
||||
text-underline-offset: 0.2em;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hotkeyHint {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
min-width: 2.5ch;
|
||||
margin-inline-start: auto;
|
||||
padding: 3px 5px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
text-align: center;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -92,6 +92,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
||||
// Without this it ends up in ~8 very commonly used bundles.
|
||||
import '../../components/status';
|
||||
import { areCollectionsEnabled } from '../collections/utils';
|
||||
import { getNavigationSkipLinkId, SkipLinks } from './components/skip_links';
|
||||
|
||||
const messages = defineMessages({
|
||||
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
|
||||
@@ -253,9 +254,9 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||
{areCollectionsEnabled() &&
|
||||
[
|
||||
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />,
|
||||
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} />,
|
||||
<WrappedRoute path='/collections' component={Collections} content={children} />
|
||||
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} key='collections-editor' />,
|
||||
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} key='collections-detail' />,
|
||||
<WrappedRoute path='/collections' component={Collections} content={children} key='collections-list' />
|
||||
]
|
||||
}
|
||||
<Route component={BundleColumnError} />
|
||||
@@ -534,6 +535,10 @@ class UI extends PureComponent {
|
||||
this.props.history.push('/home');
|
||||
};
|
||||
|
||||
handleHotkeyGoToExplore = () => {
|
||||
this.props.history.push('/explore');
|
||||
};
|
||||
|
||||
handleHotkeyGoToNotifications = () => {
|
||||
this.props.history.push('/notifications');
|
||||
};
|
||||
@@ -552,6 +557,14 @@ class UI extends PureComponent {
|
||||
|
||||
handleHotkeyGoToStart = () => {
|
||||
this.props.history.push('/getting-started');
|
||||
// Set focus to the navigation after a timeout
|
||||
// to allow for it to be displayed first
|
||||
setTimeout(() => {
|
||||
const navbarSkipTarget = document.querySelector(
|
||||
`#${getNavigationSkipLinkId()}`,
|
||||
);
|
||||
navbarSkipTarget?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
handleHotkeyGoToFavourites = () => {
|
||||
@@ -595,6 +608,7 @@ class UI extends PureComponent {
|
||||
moveToTop: this.handleMoveToTop,
|
||||
back: this.handleHotkeyBack,
|
||||
goToHome: this.handleHotkeyGoToHome,
|
||||
goToExplore: this.handleHotkeyGoToExplore,
|
||||
goToNotifications: this.handleHotkeyGoToNotifications,
|
||||
goToLocal: this.handleHotkeyGoToLocal,
|
||||
goToFederated: this.handleHotkeyGoToFederated,
|
||||
@@ -612,6 +626,10 @@ class UI extends PureComponent {
|
||||
return (
|
||||
<Hotkeys global handlers={handlers}>
|
||||
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
|
||||
<SkipLinks
|
||||
multiColumn={layout === 'multi-column'}
|
||||
onFocusGettingStartedColumn={this.handleHotkeyGoToStart}
|
||||
/>
|
||||
<SwitchingColumnsArea
|
||||
identity={this.props.identity}
|
||||
location={location}
|
||||
|
||||
@@ -62,12 +62,6 @@ export function CollectionsEditor() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareCollectionModal() {
|
||||
return import('../../collections/detail/share_modal').then(
|
||||
module => ({default: module.CollectionShareModal})
|
||||
);
|
||||
}
|
||||
|
||||
export function Status () {
|
||||
return import('../../status');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
getColumnSkipLinkId,
|
||||
getNavigationSkipLinkId,
|
||||
} from '../components/skip_links';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -20,9 +25,29 @@ function findFirstVisibleWithRect(
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusColumnTitle(index: number, multiColumn: boolean) {
|
||||
if (multiColumn) {
|
||||
const column = document.querySelector(`.column:nth-child(${index})`);
|
||||
if (column) {
|
||||
column
|
||||
.querySelector<HTMLAnchorElement>(
|
||||
`#${getColumnSkipLinkId(index - 1)}, #${getNavigationSkipLinkId()}`,
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
} else {
|
||||
const idSelector =
|
||||
index === 2
|
||||
? `#${getNavigationSkipLinkId()}`
|
||||
: `#${getColumnSkipLinkId(1)}`;
|
||||
|
||||
document.querySelector<HTMLAnchorElement>(idSelector)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move focus to the column of the passed index (1-based).
|
||||
* Focus is placed on the topmost visible item
|
||||
* Focus is placed on the topmost visible item, or the column title
|
||||
*/
|
||||
export function focusColumn(index = 1) {
|
||||
// Skip the leftmost drawer in multi-column mode
|
||||
@@ -35,11 +60,21 @@ export function focusColumn(index = 1) {
|
||||
`.column:nth-child(${index + indexOffset})`,
|
||||
);
|
||||
|
||||
if (!column) return;
|
||||
function fallback() {
|
||||
focusColumnTitle(index + indexOffset, isMultiColumnLayout);
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = column.querySelector('.scrollable');
|
||||
|
||||
if (!container) return;
|
||||
if (!container) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableItems = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
@@ -50,20 +85,23 @@ export function focusColumn(index = 1) {
|
||||
// Find first item visible in the viewport
|
||||
const itemToFocus = findFirstVisibleWithRect(focusableItems);
|
||||
|
||||
if (itemToFocus) {
|
||||
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.item.focus();
|
||||
if (!itemToFocus) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
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.item.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user