Mobile swipeable menu (#13)

* feat(mobile): swipe right to open menu

* refactor: css for side nav bar

* refactor: side menu desktop & mobile
This commit is contained in:
Sonny
2023-12-19 16:49:31 +01:00
committed by GitHub
parent 0f10092810
commit 5a28917429
24 changed files with 255 additions and 185 deletions

83
package-lock.json generated
View File

@@ -11,7 +11,6 @@
"@svgr/webpack": "^8.1.0",
"@types/react-toggle": "^4.0.5",
"accept-language": "^3.0.18",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"framer-motion": "^10.16.16",
"i18next": "^23.7.11",
@@ -30,6 +29,7 @@
"react-i18next": "^13.5.0",
"react-icons": "^4.12.0",
"react-select": "^5.8.0",
"react-swipeable": "^7.0.1",
"react-tabs": "^6.0.2",
"react-toggle": "^4.1.3",
"sass": "^1.69.5",
@@ -4032,11 +4032,6 @@
"has-symbols": "^1.0.3"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
@@ -4065,16 +4060,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
"integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz",
@@ -4463,17 +4448,6 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -4779,14 +4753,6 @@
"rimraf": "bin.js"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -5814,25 +5780,6 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
"integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -5841,19 +5788,6 @@
"is-callable": "^1.1.3"
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "10.16.16",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.16.tgz",
@@ -7351,6 +7285,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -7359,6 +7294,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -8128,11 +8064,6 @@
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
"integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -8291,6 +8222,14 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-swipeable": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.1.tgz",
"integrity": "sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==",
"peerDependencies": {
"react": "^16.8.3 || ^17 || ^18"
}
},
"node_modules/react-tabs": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-6.0.2.tgz",

View File

@@ -16,7 +16,6 @@
"@svgr/webpack": "^8.1.0",
"@types/react-toggle": "^4.0.5",
"accept-language": "^3.0.18",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"framer-motion": "^10.16.16",
"i18next": "^23.7.11",
@@ -35,6 +34,7 @@
"react-i18next": "^13.5.0",
"react-icons": "^4.12.0",
"react-select": "^5.8.0",
"react-swipeable": "^7.0.1",
"react-tabs": "^6.0.2",
"react-toggle": "^4.1.3",
"sass": "^1.69.5",

View File

@@ -3,22 +3,24 @@ import { CSSProperties, ReactNode } from 'react';
export default function ButtonLink({
href = '#',
title = '',
onClick,
children,
style = {},
className = '',
}: {
}: Readonly<{
href?: string;
title?;
onClick?: (...args: any) => any;
children: ReactNode;
style?: CSSProperties;
className?: string;
}) {
}>) {
const handleClick = (event) => {
if (!href || href === '#') {
event.preventDefault();
}
onClick && onClick();
onClick && onClick?.();
};
return (
<Link
@@ -26,6 +28,7 @@ export default function ButtonLink({
onClick={handleClick}
style={style}
className={className}
title={title}
>
{children}
</Link>

View File

@@ -4,5 +4,5 @@
font-size: 0.9em;
color: $grey;
text-align: center;
padding: 0.75em 0 0.25em;
padding-top: 0.75em;
}

View File

@@ -1,6 +1,6 @@
import clsx from 'clsx';
import ButtonLink from 'components/ButtonLink';
import Footer from 'components/Footer/Footer';
import MobileCategoriesModal from 'components/MobileCategoriesModal';
import CreateItem from 'components/QuickActions/CreateItem';
import EditItem from 'components/QuickActions/EditItem';
import RemoveItem from 'components/QuickActions/RemoveItem';
@@ -10,15 +10,20 @@ import useActiveCategory from 'hooks/useActiveCategory';
import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link';
import { BiSearchAlt } from 'react-icons/bi';
import { RxHamburgerMenu } from 'react-icons/rx';
import quickActionStyles from '../QuickActions/quickactions.module.scss';
import LinkItem from './LinkItem';
import styles from './links.module.scss';
interface LinksProps {
isMobile: boolean;
openSideMenu: () => void;
}
export default function Links({ isMobile }: Readonly<LinksProps>) {
export default function Links({
isMobile,
openSideMenu,
}: Readonly<LinksProps>) {
const { t } = useTranslation('home');
const { activeCategory } = useActiveCategory();
@@ -35,7 +40,14 @@ export default function Links({ isMobile }: Readonly<LinksProps>) {
return (
<div className={styles['links-wrapper']}>
<h2 className={styles['category-header']}>
{isMobile && <MobileCategoriesModal />}
{isMobile && (
<ButtonLink
onClick={openSideMenu}
title='Open side nav bar'
>
<RxHamburgerMenu size={'1.5em'} />
</ButtonLink>
)}
<span className={styles['category-name']}>
{name}
{links.length > 0 && (

View File

@@ -31,9 +31,13 @@
& h2 {
color: $blue;
margin-bottom: 0.25em;
margin-bottom: 0.5em;
font-weight: 500;
& svg {
display: flex;
}
& .links-count {
color: $grey;
font-weight: 300;
@@ -44,7 +48,7 @@
.category-header {
display: flex;
gap: 0.25em;
gap: 0.4em;
align-items: center;
justify-content: space-between;
@@ -188,3 +192,9 @@
animation: rotate 1s both reverse infinite linear;
}
}
@media (max-width: 768px) {
.links-wrapper {
padding: 0;
}
}

View File

@@ -1,42 +0,0 @@
import PATHS from 'constants/paths';
import { AnimatePresence } from 'framer-motion';
import useModal from 'hooks/useModal';
import { useTranslation } from 'next-i18next';
import { RxHamburgerMenu } from 'react-icons/rx';
import BlockWrapper from './BlockWrapper/BlockWrapper';
import ButtonLink from './ButtonLink';
import Modal from './Modal/Modal';
import Categories from './SideMenu/Categories/Categories';
export default function MobileCategoriesModal() {
const { t } = useTranslation();
const mobileModal = useModal();
return (
<>
<ButtonLink
style={{
display: 'flex',
}}
onClick={mobileModal.open}
>
<RxHamburgerMenu
size={'1.5em'}
style={{ marginRight: '.5em' }}
/>
</ButtonLink>
<AnimatePresence>
{mobileModal.isShowing && (
<Modal close={mobileModal.close}>
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
<ButtonLink href={PATHS.CATEGORY.CREATE}>
{t('common:category.create')}
</ButtonLink>
<Categories />
</BlockWrapper>
</Modal>
)}
</AnimatePresence>
</>
);
}

View File

@@ -1,25 +1,44 @@
import BlockWrapper from 'components/BlockWrapper/BlockWrapper';
import Categories from './Categories/Categories';
import Favorites from './Favorites/Favorites';
import NavigationLinks from './NavigationLinks';
import UserCard from './UserCard/UserCard';
import styles from './sidemenu.module.scss';
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useSwipeable } from 'react-swipeable';
import styles from './side-menu.module.scss';
export default function SideMenu() {
return (
<div className={styles['side-menu']}>
<BlockWrapper>
<Favorites />
</BlockWrapper>
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
<Categories />
</BlockWrapper>
<BlockWrapper>
<NavigationLinks />
</BlockWrapper>
<BlockWrapper>
<UserCard />
</BlockWrapper>
</div>
interface SideMenuProps {
close?: (...args: any) => void;
children: ReactNode;
}
export default function SideMenu({ close, children }: Readonly<SideMenuProps>) {
const handlers = useSwipeable({
trackMouse: true,
onSwipedLeft: close,
});
const handleWrapperClick = (event) =>
event.target.classList?.[0] === styles['side-menu-wrapper'] &&
close &&
close();
return createPortal(
<motion.div
className={styles['side-menu-wrapper']}
onClick={handleWrapperClick}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.1, delay: 0.1 } }}
{...handlers}
>
<motion.div
className={styles['side-menu-container']}
initial={{ opacity: 0, x: '-100%' }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: '-100%', transition: { duration: 0.1 } }}
transition={{ type: 'tween' }}
>
{children}
</motion.div>
</motion.div>,
document.body,
);
}

View File

@@ -0,0 +1,20 @@
@import 'styles/colors.scss';
.side-menu-wrapper {
z-index: 9999;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: $black-blur;
box-shadow: 0 0 1em 0 $black-blur;
}
.side-menu-container {
height: 100%;
width: fit-content;
max-width: 100%;
background: $light-grey;
padding: 0.75em;
}

View File

@@ -3,7 +3,7 @@ import SearchModal from 'components/SearchModal/SearchModal';
import PATHS from 'constants/paths';
import useActiveCategory from 'hooks/useActiveCategory';
import { useTranslation } from 'next-i18next';
import styles from './sidemenu.module.scss';
import styles from './side-nav.module.scss';
export default function NavigationLinks() {
const { t } = useTranslation();

View File

@@ -0,0 +1,25 @@
import BlockWrapper from 'components/BlockWrapper/BlockWrapper';
import Categories from './Categories/Categories';
import Favorites from './Favorites/Favorites';
import NavigationLinks from './NavigationLinks';
import UserCard from './UserCard/UserCard';
import styles from './side-nav.module.scss';
export default function SideNavigation() {
return (
<div className={styles['side-menu']}>
<BlockWrapper>
<Favorites />
</BlockWrapper>
<BlockWrapper style={{ minHeight: '0', flex: '1' }}>
<Categories />
</BlockWrapper>
<BlockWrapper>
<NavigationLinks />
</BlockWrapper>
<BlockWrapper>
<UserCard />
</BlockWrapper>
</div>
);
}

View File

@@ -3,9 +3,6 @@
.side-menu {
height: 100%;
width: 325px;
padding: 0 25px 0 10px;
border-right: 1px solid $lightest-grey;
margin-right: 15px;
display: flex;
align-items: center;
flex-direction: column;

View File

@@ -2,20 +2,24 @@ import clsx from 'clsx';
import Links from 'components/Links/Links';
import PageTransition from 'components/PageTransition';
import SideMenu from 'components/SideMenu/SideMenu';
import UserCard from 'components/SideMenu/UserCard/UserCard';
import SideNavigation from 'components/SideNavigation/SideNavigation';
import * as Keys from 'constants/keys';
import PATHS from 'constants/paths';
import ActiveCategoryContext from 'contexts/activeCategoryContext';
import CategoriesContext from 'contexts/categoriesContext';
import FavoritesContext from 'contexts/favoritesContext';
import GlobalHotkeysContext from 'contexts/globalHotkeysContext';
import { AnimatePresence } from 'framer-motion';
import { useMediaQuery } from 'hooks/useMediaQuery';
import useModal from 'hooks/useModal';
import { getServerSideTranslation } from 'i18n';
import getUserCategories from 'lib/category/getUserCategories';
import sortCategoriesByNextId from 'lib/category/sortCategoriesByNextId';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSwipeable } from 'react-swipeable';
import styles from 'styles/home.module.scss';
import { CategoryWithLinks, LinkWithCategory } from 'types/types';
import { withAuthentication } from 'utils/session';
@@ -25,10 +29,63 @@ interface HomePageProps {
}
export default function HomePage(props: Readonly<HomePageProps>) {
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 768px)');
const { isShowing, open, close } = useModal();
const handlers = useSwipeable({
trackMouse: true,
onSwipedRight: open,
});
const [globalHotkeysEnable, setGlobalHotkeysEnabled] =
useEffect(() => {
if (!isMobile && isShowing) {
close();
}
}, [close, isMobile, isShowing]);
return (
<PageTransition
className={clsx('App', 'flex-row')}
hideLangageSelector
>
<HomeProviders
categories={props.categories}
activeCategory={props.activeCategory}
>
<div
className={styles['swipe-handler']}
{...handlers}
>
{!isMobile && (
<div className={styles['side-bar']}>
<SideNavigation />
</div>
)}
<AnimatePresence>
{isShowing && (
<SideMenu close={close}>
<SideNavigation />
</SideMenu>
)}
</AnimatePresence>
<Links
isMobile={isMobile}
openSideMenu={open}
/>
</div>
</HomeProviders>
</PageTransition>
);
}
function HomeProviders(
props: Readonly<{
children: ReactNode;
categories: CategoryWithLinks[];
activeCategory: CategoryWithLinks;
}>,
) {
const router = useRouter();
const [globalHotkeysEnabled, setGlobalHotkeysEnabled] =
useState<boolean>(true);
const [categories, setCategories] = useState<CategoryWithLinks[]>(
props.categories,
@@ -36,10 +93,13 @@ export default function HomePage(props: Readonly<HomePageProps>) {
const [activeCategory, setActiveCategory] =
useState<CategoryWithLinks | null>(props.activeCategory || categories?.[0]);
const handleChangeCategory = (category: CategoryWithLinks) => {
setActiveCategory(category);
router.push(`${PATHS.HOME}?categoryId=${category.id}`);
};
const handleChangeCategory = useCallback(
(category: CategoryWithLinks) => {
setActiveCategory(category);
router.push(`${PATHS.HOME}?categoryId=${category.id}`);
},
[router],
);
const favorites = useMemo<LinkWithCategory[]>(
() =>
@@ -52,44 +112,47 @@ export default function HomePage(props: Readonly<HomePageProps>) {
[categories],
);
const categoriesContextValue = useMemo(
() => ({ categories, setCategories }),
[categories],
);
const activeCategoryContextValue = useMemo(
() => ({ activeCategory, setActiveCategory: handleChangeCategory }),
[activeCategory, handleChangeCategory],
);
const favoritesContextValue = useMemo(() => ({ favorites }), [favorites]);
const globalHotkeysContextValue = useMemo(
() => ({
globalHotkeysEnabled: globalHotkeysEnabled,
setGlobalHotkeysEnabled,
}),
[globalHotkeysEnabled],
);
useHotkeys(
Keys.OPEN_CREATE_LINK_KEY,
() => {
router.push(`${PATHS.LINK.CREATE}?categoryId=${activeCategory.id}`);
},
{ enabled: globalHotkeysEnable },
{ enabled: globalHotkeysEnabled },
);
useHotkeys(
Keys.OPEN_CREATE_CATEGORY_KEY,
() => {
router.push(PATHS.CATEGORY.CREATE);
},
{ enabled: globalHotkeysEnable },
{ enabled: globalHotkeysEnabled },
);
return (
<PageTransition
className={clsx('App', 'flex-row')}
hideLangageSelector
>
<CategoriesContext.Provider value={{ categories, setCategories }}>
<ActiveCategoryContext.Provider
value={{ activeCategory, setActiveCategory: handleChangeCategory }}
>
<FavoritesContext.Provider value={{ favorites }}>
<GlobalHotkeysContext.Provider
value={{
globalHotkeysEnabled: globalHotkeysEnable,
setGlobalHotkeysEnabled,
}}
>
{isMobile ? <UserCard /> : <SideMenu />}
<Links isMobile={isMobile} />
</GlobalHotkeysContext.Provider>
</FavoritesContext.Provider>
</ActiveCategoryContext.Provider>
</CategoriesContext.Provider>
</PageTransition>
<CategoriesContext.Provider value={categoriesContextValue}>
<ActiveCategoryContext.Provider value={activeCategoryContextValue}>
<FavoritesContext.Provider value={favoritesContextValue}>
<GlobalHotkeysContext.Provider value={globalHotkeysContextValue}>
{props.children}
</GlobalHotkeysContext.Provider>
</FavoritesContext.Provider>
</ActiveCategoryContext.Provider>
</CategoriesContext.Provider>
);
}

View File

@@ -30,7 +30,7 @@
@media (max-width: 768px) {
.form-container {
width: 100%;
margin-top: 5em;
margin-top: 1em;
padding: 0 1em;
}
}

View File

@@ -214,6 +214,7 @@ kbd {
@media (max-width: 768px) {
.App {
padding: 0.75em;
flex-direction: column;
}

View File

@@ -0,0 +1,23 @@
@import 'colors.scss';
.swipe-handler {
height: 100%;
width: 100%;
display: flex;
transition: background-color 0.15s;
}
.side-menu {
position: absolute;
top: 0;
height: 100%;
background-color: $light-grey;
box-shadow: 0 0 1em 0 $black-blur;
padding: 0.75em;
}
.side-bar {
padding-right: 0.75em;
border-right: 1px solid $lightest-grey;
margin-right: 5px;
}