Redesign: Add mobile navigation bar (#40304)

This commit is contained in:
diondiondion
2026-08-28 09:22:35 +00:00
committed by GitHub
parent 37abf0824b
commit 99ce39132d
11 changed files with 265 additions and 37 deletions

View File

@@ -1,9 +1,12 @@
@use '@/styles/mastodon/variables';
.button {
position: fixed;
bottom: var(--space-md);
right: var(--space-md);
&:not(.buttonInline) {
position: fixed;
bottom: var(--space-md);
right: var(--space-md);
z-index: 2;
}
}
.composer,

View File

@@ -4,6 +4,8 @@ import { lazy, Suspense, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import {
ChatCircleIcon,
NewspaperIcon,
@@ -32,7 +34,12 @@ const ComposeLazyForm = lazy(() =>
})),
);
export const ComposeRedesignButton: React.FC = () => {
export const ComposeRedesignButton: React.FC<{
/**
* Render the button in regular document flow instead of fixed positioning for mobile layout
*/
inline?: boolean;
}> = ({ inline }) => {
const displayState = useAppSelector((state) => state.composer.displayState);
const dispatch = useAppDispatch();
@@ -75,7 +82,7 @@ export const ComposeRedesignButton: React.FC = () => {
as={IconButton}
icon={PenNibIcon}
variant='solid'
className={classes.button}
className={classNames(classes.button, inline && classes.buttonInline)}
size='lg'
>
<FormattedMessage

View File

@@ -0,0 +1,50 @@
@use '@/styles/mastodon/mixins';
@use '@/styles/mastodon/variables' as *;
.root {
position: fixed;
bottom: env(safe-area-inset-bottom);
inset-inline: 0;
padding: 0 var(--space-md) var(--space-md);
z-index: 100;
display: flex;
// This moves the compose FAB above the navigation,
// which somehow makes more sense for the focus order
flex-direction: column-reverse;
align-items: end;
gap: var(--space-xs);
// Allow clicking through this container
pointer-events: none;
& > * {
pointer-events: initial;
}
@media (width > $mobile-menu-breakpoint) {
display: none;
}
}
.list {
box-sizing: border-box;
display: flex;
align-items: center;
align-self: center;
width: 100%;
max-width: 400px;
padding: var(--space-2xs);
background-color: var(--color-bg-primary);
border-radius: var(--radius-round);
@include mixins.elevation-1;
> li {
flex-grow: 1;
}
}
.avatar,
.avatar img {
border-radius: var(--radius-round);
}

View File

@@ -0,0 +1,71 @@
import { FormattedMessage } from 'react-intl';
import {
BellIcon,
ChatCircleIcon,
HouseIcon,
MagnifyingGlassIcon,
} from '@phosphor-icons/react';
import { Avatar } from '@/mastodon/components/avatar';
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 classes from './mobile_nav.module.scss';
import { MobileNavLink } from './navigation_link';
export const RedesignMobileNavigation: React.FC = () => {
const { accountId } = useIdentity();
const account = useAccount(accountId);
const notificationsCount = useAppSelector(
selectUnreadNotificationGroupsCount,
);
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}
>
<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>
);
};

View File

@@ -9,6 +9,9 @@
}
.link {
--icon-size: 20px;
--border-radius: var(--radius-sm);
position: relative;
box-sizing: border-box;
display: flex;
@@ -16,7 +19,7 @@
width: 100%;
padding: var(--space-xs);
column-gap: var(--space-sm);
border-radius: var(--radius-sm);
border-radius: var(--border-radius);
border: none;
color: inherit;
background-color: transparent;
@@ -26,6 +29,10 @@
text-decoration: none;
text-align: start;
&:not(:where(.linkStacked, .linkMobile)) {
color: var(--color-text-secondary);
}
&:hover,
&:focus-visible,
&[aria-current='page'] {
@@ -33,26 +40,42 @@
text-decoration: none;
}
&[aria-current='page'] {
color: var(--color-text-primary);
}
&:focus-visible {
outline: var(--outline-focus-default);
}
&:focus {
// Override silly global focus style
border-radius: var(--radius-sm);
border-radius: var(--border-radius);
}
}
.linkStacked {
--icon-size: 24px;
flex-direction: column;
row-gap: var(--space-2xs);
padding-inline: 0;
@include mixins.type-micro;
}
.linkMobile {
--icon-size: 24px;
--border-radius: var(--radius-round);
width: 100%;
justify-content: center;
gap: 0;
padding-block: var(--space-sm);
}
.icon {
width: 20px;
height: 20px;
width: var(--icon-size);
height: var(--icon-size);
& > svg {
width: inherit;
@@ -60,6 +83,23 @@
}
}
// A dot is shown instead of a number badge on mobile
.iconWithDot {
position: relative;
&::after {
content: '';
position: absolute;
top: var(--space-4xs);
left: calc(50% + var(--space-3xs));
width: var(--space-xs);
height: var(--space-xs);
border-radius: var(--radius-round);
background-color: var(--color-bg-brand-base);
border: 1px solid var(--color-bg-primary);
}
}
.label {
max-width: 100%;
min-width: 0;

View File

@@ -1,12 +1,13 @@
import type { SVGProps } from 'react';
import type { ReactNode, SVGProps } from 'react';
import classNames from 'classnames';
import { NavLink, matchPath, useLocation } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import type { NavLinkProps } from 'react-router-dom';
import type { Icon } from '@phosphor-icons/react';
import { Badge } from '@/mastodon/components/badge';
import { useIsLinkActive } from '@/mastodon/hooks/useIsLinkActive';
import classes from './navigation_link.module.scss';
@@ -29,20 +30,14 @@ export const NavigationLink: React.FC<NavigationLinkProps> = ({
children,
...otherProps
}) => {
const location = useLocation();
let Comp: React.ElementType = as;
if (as === 'link') {
Comp = NavLink;
}
const to = 'to' in otherProps && otherProps.to;
const isActive =
to && typeof to !== 'function'
? !!matchPath(location.pathname, {
path: typeof to === 'string' ? to : to.pathname,
})
: false;
const isActive = useIsLinkActive(
'to' in otherProps ? otherProps.to : undefined,
);
return (
<li
@@ -57,7 +52,10 @@ export const NavigationLink: React.FC<NavigationLinkProps> = ({
>
{IconComp && (
<span className={classes.icon}>
<IconComp size={20} weight={isActive ? 'fill' : undefined} />
<IconComp
size={stacked ? 24 : 20}
weight={isActive ? 'fill' : undefined}
/>
</span>
)}
<span className={classes.label}>{children}</span>
@@ -72,3 +70,49 @@ export const NavigationLink: React.FC<NavigationLinkProps> = ({
</li>
);
};
type MobileNavLink = NavLinkProps & {
withDot?: boolean;
children: ReactNode;
} & (
| {
iconComponent: Icon | React.FC<SVGProps<SVGSVGElement>>;
customIcon?: never;
}
| {
customIcon: ReactNode;
iconComponent?: never;
}
);
export const MobileNavLink: React.FC<MobileNavLink> = ({
iconComponent: IconComp,
customIcon,
withDot,
children,
...otherProps
}) => {
const isActive = useIsLinkActive(
'to' in otherProps ? otherProps.to : undefined,
);
return (
<li>
<NavLink
{...otherProps}
className={classNames(classes.link, classes.linkMobile)}
>
<span
className={classNames(classes.icon, withDot && classes.iconWithDot)}
>
{IconComp ? (
<IconComp size={24} weight={isActive ? 'fill' : undefined} />
) : (
customIcon
)}
</span>
<span className='sr-only'>{children}</span>
</NavLink>
</li>
);
};

View File

@@ -1,11 +1,12 @@
@use '@/styles/mastodon/variables' as *;
.root {
--main-column-max-width: 640px;
--main-column-max-width: 600px;
--navigation-min-width: 280px;
--navigation-max-width: 360px;
--navigation-max-width: 320px;
box-sizing: border-box;
display: flex;
width: 100%;
height: 100%;
min-height: 100vh;

View File

@@ -2,8 +2,10 @@ 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';
import { Footer } from 'mastodon/features/custom_homepage/components/footer';
import { Header } from 'mastodon/features/custom_homepage/components/header';
@@ -38,7 +40,7 @@ export const ColumnsAreaRedesign: React.FC<{
const isModalOpen = useAppSelector(
(state) => !state.modal.get('stack').isEmpty(),
);
const renderLegacyNavForMobile = useBreakpoint('openable');
const isMobile = useBreakpoint('openable');
if (minimalShell) {
return (
@@ -64,6 +66,7 @@ export const ColumnsAreaRedesign: React.FC<{
<div className={classes.navigationWrapper}>
<RedesignNavigationPanel />
</div>
{isMobile ? <RedesignMobileNavigation /> : <ComposeRedesignButton />}
<main className={classes.main}>
<div className='tabs-bar__wrapper'>
@@ -72,7 +75,10 @@ export const ColumnsAreaRedesign: React.FC<{
<div className='columns-area columns-area--mobile'>{children}</div>
{renderLegacyNavForMobile && <CollapsibleNavigationPanel />}
{isMobile && (
// Legacy mobile navigation, to be removed
<CollapsibleNavigationPanel />
)}
</main>
</div>
);

View File

@@ -272,11 +272,6 @@ class SwitchingColumnsArea extends PureComponent {
}
}
const LazyRedesignComposeButton = lazy(
() => import('@/mastodon/features/compose/redesign/trigger')
.then(({ ComposeRedesignButton }) => ({ default: ComposeRedesignButton }))
);
class UI extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
@@ -662,7 +657,7 @@ class UI extends PureComponent {
{children}
</SwitchingColumnsArea>
{!minimalShell && <NavigationBar />}
{!minimalShell && !isRedesignEnabled() && <NavigationBar />}
{layout !== 'mobile' && <PictureInPicture />}
<AlertsController />
{!disableHoverCards && <HoverCardController />}
@@ -670,12 +665,6 @@ class UI extends PureComponent {
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />
{isRedesignEnabled() && (
<Suspense>
<LazyRedesignComposeButton />
</Suspense>
)}
</div>
</Hotkeys>
);

View File

@@ -0,0 +1,16 @@
import { matchPath, useLocation } from 'react-router';
import type { NavLinkProps } from 'react-router-dom';
export function useIsLinkActive(to?: NavLinkProps['to']) {
const location = useLocation();
if (!to) {
return false;
}
const linkTarget = typeof to === 'function' ? to(location) : to;
return !!matchPath(location.pathname, {
path: typeof linkTarget === 'string' ? linkTarget : linkTarget.pathname,
});
}

View File

@@ -1411,6 +1411,7 @@
"tabs_bar.messages": "Messages",
"tabs_bar.more": "More",
"tabs_bar.notifications": "Notifications",
"tabs_bar.profile": "Profile",
"tabs_bar.publish": "New Post",
"tabs_bar.quick_links": "Quick links",
"tabs_bar.saved": "Saved",