mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-12 18:25:55 -05:00
Redesign: Add mobile navigation menu (#40318)
This commit is contained in:
@@ -31,9 +31,7 @@
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
align-self: stretch;
|
||||
padding: var(--space-2xs);
|
||||
background-color: var(--color-bg-primary);
|
||||
border-radius: var(--radius-round);
|
||||
@@ -48,3 +46,35 @@
|
||||
.avatar img {
|
||||
border-radius: var(--radius-round);
|
||||
}
|
||||
|
||||
.slideOutWrapper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
background: rgb(from var(--color-bg-overlay) r g b/50%);
|
||||
opacity: 0;
|
||||
transition: opacity 250ms ease;
|
||||
|
||||
&[data-is-open='true'] {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slideOut {
|
||||
// We extend the size of the slideOut to go beyond the viewport
|
||||
// so that when it's pulled away from the side it was opened from,
|
||||
// the background underneath the menu isn't revealed
|
||||
--pull-tolerance: 70px;
|
||||
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
inset-inline-start: calc(-1 * var(--pull-tolerance));
|
||||
width: var(--navigation-max-width);
|
||||
max-width: calc(100vw - var(--space-3xl));
|
||||
padding-inline-start: var(--pull-tolerance);
|
||||
touch-action: pan-y;
|
||||
background: var(--color-bg-primary);
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
}
|
||||
|
||||
@@ -1,71 +1,223 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
ChatCircleIcon,
|
||||
HouseIcon,
|
||||
MagnifyingGlassIcon,
|
||||
HamburgerIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import { closeNavigation, openNavigation } from '@/mastodon/actions/navigation';
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { IconButton } from '@/mastodon/components/button/redesign';
|
||||
import { FOCUS_TARGET } from '@/mastodon/components/navigation_focus_target';
|
||||
import { ComposeRedesignButton } from '@/mastodon/features/compose/redesign/trigger';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import { selectUnreadNotificationGroupsCount } from '@/mastodon/selectors/notifications';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { RedesignNavigationPanel } from '.';
|
||||
import classes from './mobile_nav.module.scss';
|
||||
import { MobileNavLink } from './navigation_link';
|
||||
|
||||
export const RedesignMobileNavigation: React.FC = () => {
|
||||
const { accountId } = useIdentity();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { accountId, signedIn } = useIdentity();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const notificationsCount = useAppSelector(
|
||||
selectUnreadNotificationGroupsCount,
|
||||
);
|
||||
|
||||
const handleOpenNavigation = useCallback(() => {
|
||||
dispatch(openNavigation());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!signedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={classes.root}>
|
||||
<ul className={classes.list}>
|
||||
<MobileNavLink to='/home' iconComponent={HouseIcon}>
|
||||
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
to={{
|
||||
pathname: '/explore',
|
||||
state: { focusTarget: FOCUS_TARGET.SEARCH },
|
||||
}}
|
||||
iconComponent={MagnifyingGlassIcon}
|
||||
<>
|
||||
<nav className={classes.root}>
|
||||
<ul className={classes.list}>
|
||||
<MobileNavLink to='/home' iconComponent={HouseIcon}>
|
||||
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
to={{
|
||||
pathname: '/explore',
|
||||
state: { focusTarget: FOCUS_TARGET.SEARCH },
|
||||
}}
|
||||
iconComponent={MagnifyingGlassIcon}
|
||||
>
|
||||
<FormattedMessage id='tabs_bar.search' defaultMessage='Search' />
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to='/conversations' iconComponent={ChatCircleIcon}>
|
||||
<FormattedMessage
|
||||
id='tabs_bar.messages'
|
||||
defaultMessage='Messages'
|
||||
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
|
||||
/>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
to='/notifications'
|
||||
iconComponent={BellIcon}
|
||||
withDot={notificationsCount > 0}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='tabs_bar.notifications'
|
||||
defaultMessage='Notifications'
|
||||
/>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
to={`/@${account?.acct}`}
|
||||
customIcon={
|
||||
<Avatar size={24} account={account} className={classes.avatar} />
|
||||
}
|
||||
>
|
||||
<FormattedMessage id='tabs_bar.profile' defaultMessage='Profile' />
|
||||
</MobileNavLink>
|
||||
</ul>
|
||||
<ComposeRedesignButton inline />
|
||||
<IconButton // Silly placeholder – will be replaced with button in column header
|
||||
icon={HamburgerIcon}
|
||||
variant='solid'
|
||||
onClick={handleOpenNavigation}
|
||||
>
|
||||
<FormattedMessage id='tabs_bar.search' defaultMessage='Search' />
|
||||
</MobileNavLink>
|
||||
<MobileNavLink to='/conversations' iconComponent={ChatCircleIcon}>
|
||||
<FormattedMessage
|
||||
id='tabs_bar.messages'
|
||||
defaultMessage='Messages'
|
||||
description='Message refers to a direct message. For languages where this is confusing, "chat" or "direct message" can be used.'
|
||||
/>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
to='/notifications'
|
||||
iconComponent={BellIcon}
|
||||
withDot={notificationsCount > 0}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='tabs_bar.notifications'
|
||||
defaultMessage='Notifications'
|
||||
/>
|
||||
</MobileNavLink>
|
||||
<MobileNavLink
|
||||
to={`/@${account?.acct}`}
|
||||
customIcon={
|
||||
<Avatar size={24} account={account} className={classes.avatar} />
|
||||
}
|
||||
>
|
||||
<FormattedMessage id='tabs_bar.profile' defaultMessage='Profile' />
|
||||
</MobileNavLink>
|
||||
</ul>
|
||||
<ComposeRedesignButton inline />
|
||||
</nav>
|
||||
Menu
|
||||
</IconButton>
|
||||
</nav>
|
||||
<SlideOutNavigation />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MENU_WIDTH = 320;
|
||||
|
||||
const SlideOutNavigation: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const location = useLocation();
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isOpen = useAppSelector((state) => state.navigation.open);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(closeNavigation());
|
||||
}, [dispatch, location]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent) => {
|
||||
if (overlayRef.current && e.target === overlayRef.current) {
|
||||
dispatch(closeNavigation());
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
dispatch(closeNavigation());
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener('keyup', handleDocumentKeyUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
document.removeEventListener('keyup', handleDocumentKeyUp);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const isLtrDir = getComputedStyle(document.body).direction !== 'rtl';
|
||||
|
||||
const OPEN_MENU_OFFSET = isLtrDir ? -MENU_WIDTH : MENU_WIDTH;
|
||||
|
||||
const [{ x }, spring] = useSpring(
|
||||
() => ({
|
||||
x: isOpen ? 0 : OPEN_MENU_OFFSET,
|
||||
onRest: {
|
||||
x({ value }: { value: number }) {
|
||||
if (value === 0) {
|
||||
dispatch(openNavigation());
|
||||
} else if (value === OPEN_MENU_OFFSET) {
|
||||
dispatch(closeNavigation());
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
[isOpen],
|
||||
);
|
||||
|
||||
const bind = useDrag(
|
||||
({
|
||||
last,
|
||||
offset: [xOffset],
|
||||
velocity: [xVelocity],
|
||||
direction: [xDirection],
|
||||
cancel,
|
||||
}) => {
|
||||
const logicalXDirection = isLtrDir ? -xDirection : xDirection;
|
||||
const logicalXOffset = isLtrDir ? -xOffset : xOffset;
|
||||
const hasReachedDragThreshold = logicalXOffset < -70;
|
||||
|
||||
if (hasReachedDragThreshold) {
|
||||
cancel();
|
||||
}
|
||||
|
||||
if (last) {
|
||||
const isAboveOpenThreshold = logicalXOffset > MENU_WIDTH / 2;
|
||||
const isQuickFlick = xVelocity > 0.5 && logicalXDirection > 0;
|
||||
|
||||
if (isAboveOpenThreshold || isQuickFlick) {
|
||||
void spring.start({ x: OPEN_MENU_OFFSET });
|
||||
} else {
|
||||
void spring.start({ x: 0 });
|
||||
}
|
||||
} else {
|
||||
void spring.start({ x: xOffset, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
from: () => [x.get(), 0],
|
||||
axis: 'x',
|
||||
filterTaps: true,
|
||||
bounds: isLtrDir ? { right: 0 } : { left: 0 },
|
||||
rubberband: true,
|
||||
},
|
||||
);
|
||||
|
||||
const previouslyFocusedElementRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const overlay = overlayRef.current;
|
||||
if (isOpen && overlay) {
|
||||
const firstLink = overlay.querySelector<HTMLAnchorElement>('a');
|
||||
previouslyFocusedElementRef.current =
|
||||
document.activeElement as HTMLElement;
|
||||
firstLink?.focus();
|
||||
} else {
|
||||
previouslyFocusedElementRef.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.slideOutWrapper}
|
||||
data-is-open={isOpen}
|
||||
ref={overlayRef}
|
||||
>
|
||||
<animated.div className={classes.slideOut} {...bind()} style={{ x }}>
|
||||
<RedesignNavigationPanel />
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
@use '@/styles/mastodon/variables';
|
||||
|
||||
.wrapper {
|
||||
padding-bottom: var(--space-3xs);
|
||||
@@ -33,6 +34,10 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (width < variables.$mobile-menu-breakpoint) {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&[aria-current='page'] {
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
|
||||
// Approximate height of fixed navigation elements
|
||||
// to ensure focused elements are scrolled into view
|
||||
// as needed.
|
||||
--header-height: 80px;
|
||||
--footer-height: 200px;
|
||||
|
||||
scroll-padding-block: var(--header-height) var(--footer-height);
|
||||
}
|
||||
|
||||
.list {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ComposeRedesignButton } from '@/mastodon/features/compose/redesign/trigger';
|
||||
import { CollapsibleNavigationPanel } from '@/mastodon/features/navigation_panel';
|
||||
import { RedesignNavigationPanel } from '@/mastodon/features/navigation_panel/redesign';
|
||||
import { RedesignMobileNavigation } from '@/mastodon/features/navigation_panel/redesign/mobile_nav';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
@@ -74,11 +73,6 @@ export const ColumnsAreaRedesign: React.FC<{
|
||||
</div>
|
||||
|
||||
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||
|
||||
{isMobile && (
|
||||
// Legacy mobile navigation, to be removed
|
||||
<CollapsibleNavigationPanel />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user