From b7cf8dcff9c50c7edbbec413f6c9f772e2648269 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 28 Aug 2026 14:32:50 +0000 Subject: [PATCH] Redesign: Add mobile navigation menu (#40318) --- .../redesign/mobile_nav.module.scss | 36 ++- .../navigation_panel/redesign/mobile_nav.tsx | 238 ++++++++++++++---- .../redesign/navigation_link.module.scss | 5 + .../redesign/styles.module.scss | 8 + .../ui/components/columns_area/redesign.tsx | 6 - 5 files changed, 241 insertions(+), 52 deletions(-) diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.module.scss b/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.module.scss index 56ab0470784..a65a7432490 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.module.scss +++ b/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.module.scss @@ -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); +} diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.tsx b/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.tsx index d841c6e782b..fed7ddf63be 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.tsx +++ b/app/javascript/mastodon/features/navigation_panel/redesign/mobile_nav.tsx @@ -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 ( - + Menu + + + + + ); +}; + +const MENU_WIDTH = 320; + +const SlideOutNavigation: React.FC = () => { + const dispatch = useAppDispatch(); + const location = useLocation(); + const overlayRef = useRef(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(null); + + useEffect(() => { + const overlay = overlayRef.current; + if (isOpen && overlay) { + const firstLink = overlay.querySelector('a'); + previouslyFocusedElementRef.current = + document.activeElement as HTMLElement; + firstLink?.focus(); + } else { + previouslyFocusedElementRef.current?.focus(); + } + }, [isOpen]); + + return ( +
+ + + +
); }; diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/navigation_link.module.scss b/app/javascript/mastodon/features/navigation_panel/redesign/navigation_link.module.scss index 4cff65ccb57..15f65d84bc6 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/navigation_link.module.scss +++ b/app/javascript/mastodon/features/navigation_panel/redesign/navigation_link.module.scss @@ -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'] { diff --git a/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss b/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss index 29bac75d290..1159ba26c4c 100644 --- a/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss +++ b/app/javascript/mastodon/features/navigation_panel/redesign/styles.module.scss @@ -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 { diff --git a/app/javascript/mastodon/features/ui/components/columns_area/redesign.tsx b/app/javascript/mastodon/features/ui/components/columns_area/redesign.tsx index e53485421a5..6bac9746e4a 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area/redesign.tsx +++ b/app/javascript/mastodon/features/ui/components/columns_area/redesign.tsx @@ -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<{
{children}
- - {isMobile && ( - // Legacy mobile navigation, to be removed - - )} );