mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 02:36:36 -05:00
Redesign: Add mobile navigation bar (#40304)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
16
app/javascript/mastodon/hooks/useIsLinkActive.ts
Normal file
16
app/javascript/mastodon/hooks/useIsLinkActive.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user