From 5e980501fb73f382c066a2ba90965f2903ea7bcd Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Tue, 23 Jul 2024 15:43:02 +0200 Subject: [PATCH 1/2] :sparkles: Component submenu working --- .../DesignSystem/Typography/index.tsx | 4 ++ .../SubMenuItem/classes.module.scss | 23 +++++++ .../ButtonWithSubMenu/SubMenuItem/index.tsx | 44 ++++++++++++ .../ButtonWithSubMenu/classes.module.scss | 20 ++++++ .../Elements/ButtonWithSubMenu/index.tsx | 69 +++++++++++++++++++ .../Components/Layouts/DesignSystem/index.tsx | 37 +++++++++- 6 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/classes.module.scss create mode 100644 src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx create mode 100644 src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss create mode 100644 src/front/Components/Elements/ButtonWithSubMenu/index.tsx diff --git a/src/front/Components/DesignSystem/Typography/index.tsx b/src/front/Components/DesignSystem/Typography/index.tsx index 30a5551e..8c5e87b1 100644 --- a/src/front/Components/DesignSystem/Typography/index.tsx +++ b/src/front/Components/DesignSystem/Typography/index.tsx @@ -149,6 +149,10 @@ export enum ETypoColor { INPUT_ERROR = "--input-error", TEXT_ACCENT = "--text-accent", + + CONTRAST_DEFAULT = "--contrast-default", + CONTRAST_HOVERED = "--contrast-hovered", + ERROR_WEAK_CONTRAST = "--error-weak-contrast", } export default function Typography(props: IProps) { diff --git a/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/classes.module.scss b/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/classes.module.scss new file mode 100644 index 00000000..3231922d --- /dev/null +++ b/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/classes.module.scss @@ -0,0 +1,23 @@ +.menu-item-wrapper { + width: 100%; + .menu-item { + display: flex; + padding: var(--spacing-md, 16px); + justify-content: flex-start; + align-items: center; + gap: var(--spacing-lg, 24px); + cursor: pointer; + + > svg { + width: 24px; + height: 24px; + transition: all ease-in-out 0.1s; + } + } + + .separator { + width: 100%; + height: 1px; + background-color: var(--separator-stroke-light, #d7dce0); + } +} diff --git a/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx b/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx new file mode 100644 index 00000000..83ccdc75 --- /dev/null +++ b/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx @@ -0,0 +1,44 @@ +import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography"; +import classes from "./classes.module.scss"; +import { useRouter } from "next/router"; +import { ISubElementWithLink, ISubElementWithOnClick } from ".."; +import React, { useCallback } from "react"; +import useHoverable from "@Front/Hooks/useHoverable"; + +type IProps = { + element: ISubElementWithLink | ISubElementWithOnClick; +}; +export default function SubMenuItem(props: IProps) { + const { element } = props; + const router = useRouter(); + const handleClickElement = (e: React.MouseEvent) => { + const link = e.currentTarget.getAttribute("data-link"); + if (link) router.push(link); + }; + + const { handleMouseEnter, handleMouseLeave, isHovered } = useHoverable(); + + // The element has a link or an onClick but not both, if it has a link, show it has a link, if it has an onClick, show it has an onClick + const getColor = useCallback(() => { + if (isHovered && element.color !== ETypoColor.ERROR_WEAK_CONTRAST) return ETypoColor.CONTRAST_HOVERED; + if (element.color) return element.color; + return ETypoColor.CONTRAST_DEFAULT; + }, [element.color, isHovered]); + + return ( +
+
+ {React.cloneElement(element.icon, { color: `var(${getColor()})` })} + + {element.text} + +
+ {element.hasSeparator &&
} +
+ ); +} diff --git a/src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss b/src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss new file mode 100644 index 00000000..bbd8ced7 --- /dev/null +++ b/src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss @@ -0,0 +1,20 @@ +.root { + position: relative; + width: fit-content; + + .sub-menu { + position: absolute; + top: 48px; + left: 0px; + display: inline-flex; + padding: var(--spacing-05, 4px) var(--spacing-2, 16px); + flex-direction: column; + align-items: flex-start; + border-radius: var(--menu-radius, 0px); + border: 1px solid var(--menu-border, #d7dce0); + background: var(--color-generic-white, #fff); + text-wrap: nowrap; + /* shadow/sm */ + box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.1); + } +} diff --git a/src/front/Components/Elements/ButtonWithSubMenu/index.tsx b/src/front/Components/Elements/ButtonWithSubMenu/index.tsx new file mode 100644 index 00000000..7d9bf5e3 --- /dev/null +++ b/src/front/Components/Elements/ButtonWithSubMenu/index.tsx @@ -0,0 +1,69 @@ +import IconButton, { EIconButtonVariant } from "@Front/Components/DesignSystem/IconButton"; +import classes from "./classes.module.scss"; +import { ETypoColor } from "@Front/Components/DesignSystem/Typography"; +import React, { useEffect, useRef, useState } from "react"; +import SubMenuItem from "./SubMenuItem"; + +type ISubElementBase = { + icon: JSX.Element; + text: string; + hasSeparator?: boolean; + color?: ETypoColor; +}; + +export type ISubElementWithLink = ISubElementBase & { + link: string; + onClick?: never; +}; + +export type ISubElementWithOnClick = ISubElementBase & { + onClick: () => void; + link?: never; +}; + +type IProps = { + icon: React.ReactNode; + text?: string; + subElements: (ISubElementWithLink | ISubElementWithOnClick)[]; +}; + +export default function ButtonWithSubMenu(props: IProps) { + const [isSubMenuOpened, setIsSubMenuOpened] = useState(false); + + const subMenuRef = useRef(null); + const iconRef = useRef(null); + + const handleClick = () => { + setIsSubMenuOpened((prev) => !prev); + }; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + subMenuRef.current && + !subMenuRef.current.contains(e.target as Node) && + iconRef.current && + !iconRef.current.contains(e.target as Node) + ) { + setIsSubMenuOpened(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + return ( +
+
+ +
+ + {isSubMenuOpened && ( +
+ {props.subElements.map((element, index) => { + return ; + })} +
+ )} +
+ ); +} diff --git a/src/front/Components/Layouts/DesignSystem/index.tsx b/src/front/Components/Layouts/DesignSystem/index.tsx index 0e475bdf..4152498b 100644 --- a/src/front/Components/Layouts/DesignSystem/index.tsx +++ b/src/front/Components/Layouts/DesignSystem/index.tsx @@ -9,15 +9,24 @@ import Modal from "@Front/Components/DesignSystem/Modal"; import Newsletter from "@Front/Components/DesignSystem/Newsletter"; import Table from "@Front/Components/DesignSystem/Table"; import Tag, { ETagColor, ETagVariant } from "@Front/Components/DesignSystem/Tag"; -import Typography, { ETypo } from "@Front/Components/DesignSystem/Typography"; +import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography"; import NumberPicker from "@Front/Components/Elements/NumberPicker"; import Tabs from "@Front/Components/Elements/Tabs"; import DefaultTemplate from "@Front/Components/LayoutTemplates/DefaultTemplate"; import useOpenable from "@Front/Hooks/useOpenable"; -import { ArrowLongLeftIcon, ArrowLongRightIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { + ArchiveBoxIcon, + ArrowLongLeftIcon, + ArrowLongRightIcon, + EllipsisHorizontalIcon, + PencilSquareIcon, + UsersIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; import { useCallback, useMemo, useState } from "react"; import classes from "./classes.module.scss"; +import ButtonWithSubMenu from "@Front/Components/Elements/ButtonWithSubMenu"; export default function DesignSystem() { const { isOpen, open, close } = useOpenable(); @@ -69,6 +78,30 @@ export default function DesignSystem() {
+ Button icon with menu + } + subElements={[ + { + icon: , + text: "Modifier les collaborateurs", + onClick: () => alert("yo"), + hasSeparator: true, + }, + { + icon: , + text: "Modifier les informations du dossier", + link: "/", + hasSeparator: true, + }, + { + icon: , + text: "Archiver le dossier", + link: "/", + color: ETypoColor.ERROR_WEAK_CONTRAST, + }, + ]} + /> Inputs Number picker avec min à 1 et max à 10 {}} min={1} max={10} /> From d9183701ffee9939050878c692435aeff569c3da Mon Sep 17 00:00:00 2001 From: Maxime Lalo Date: Tue, 23 Jul 2024 15:54:57 +0200 Subject: [PATCH 2/2] :sparkles: submenu on folder --- .../ButtonWithSubMenu/SubMenuItem/index.tsx | 22 +++-- .../ButtonWithSubMenu/classes.module.scss | 11 ++- .../Elements/ButtonWithSubMenu/index.tsx | 19 +++-- .../InformationSection/index.tsx | 81 +++++++++++-------- 4 files changed, 85 insertions(+), 48 deletions(-) diff --git a/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx b/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx index 83ccdc75..81071a54 100644 --- a/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx +++ b/src/front/Components/Elements/ButtonWithSubMenu/SubMenuItem/index.tsx @@ -1,20 +1,26 @@ import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography"; import classes from "./classes.module.scss"; import { useRouter } from "next/router"; -import { ISubElementWithLink, ISubElementWithOnClick } from ".."; import React, { useCallback } from "react"; import useHoverable from "@Front/Hooks/useHoverable"; +import { ISubElement } from ".."; type IProps = { - element: ISubElementWithLink | ISubElementWithOnClick; + element: ISubElement; + closeMenuCb: () => void; }; export default function SubMenuItem(props: IProps) { - const { element } = props; + const { element, closeMenuCb } = props; const router = useRouter(); - const handleClickElement = (e: React.MouseEvent) => { - const link = e.currentTarget.getAttribute("data-link"); - if (link) router.push(link); - }; + const handleClickElement = useCallback( + (e: React.MouseEvent) => { + closeMenuCb(); + const link = e.currentTarget.getAttribute("data-link"); + if (link) router.push(link); + if (element.onClick) element.onClick(); + }, + [closeMenuCb, element, router], + ); const { handleMouseEnter, handleMouseLeave, isHovered } = useHoverable(); @@ -28,7 +34,7 @@ export default function SubMenuItem(props: IProps) { return (
diff --git a/src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss b/src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss index bbd8ced7..28ddf217 100644 --- a/src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss +++ b/src/front/Components/Elements/ButtonWithSubMenu/classes.module.scss @@ -5,7 +5,6 @@ .sub-menu { position: absolute; top: 48px; - left: 0px; display: inline-flex; padding: var(--spacing-05, 4px) var(--spacing-2, 16px); flex-direction: column; @@ -16,5 +15,15 @@ text-wrap: nowrap; /* shadow/sm */ box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.1); + z-index: 2; + &[data-opening-side="left"] { + left: auto; + right: 0px; + } + + &[data-opening-side="right"] { + left: 0px; + right: auto; + } } } diff --git a/src/front/Components/Elements/ButtonWithSubMenu/index.tsx b/src/front/Components/Elements/ButtonWithSubMenu/index.tsx index 7d9bf5e3..1ef1a471 100644 --- a/src/front/Components/Elements/ButtonWithSubMenu/index.tsx +++ b/src/front/Components/Elements/ButtonWithSubMenu/index.tsx @@ -11,23 +11,28 @@ type ISubElementBase = { color?: ETypoColor; }; -export type ISubElementWithLink = ISubElementBase & { +type ISubElementWithLink = ISubElementBase & { link: string; onClick?: never; }; -export type ISubElementWithOnClick = ISubElementBase & { +type ISubElementWithOnClick = ISubElementBase & { onClick: () => void; link?: never; }; +export type ISubElement = ISubElementWithLink | ISubElementWithOnClick; + type IProps = { icon: React.ReactNode; text?: string; - subElements: (ISubElementWithLink | ISubElementWithOnClick)[]; + subElements: ISubElement[]; + openingSide?: "left" | "right"; }; export default function ButtonWithSubMenu(props: IProps) { + const { openingSide = "left" } = props; + const [isSubMenuOpened, setIsSubMenuOpened] = useState(false); const subMenuRef = useRef(null); @@ -37,6 +42,10 @@ export default function ButtonWithSubMenu(props: IProps) { setIsSubMenuOpened((prev) => !prev); }; + const closeMenu = () => { + setIsSubMenuOpened(false); + }; + useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( @@ -58,9 +67,9 @@ export default function ButtonWithSubMenu(props: IProps) {
{isSubMenuOpened && ( -
+
{props.subElements.map((element, index) => { - return ; + return ; })}
)} diff --git a/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx b/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx index 39a6e2c8..4e60f216 100644 --- a/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx +++ b/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx @@ -1,14 +1,14 @@ import CircleProgress from "@Front/Components/DesignSystem/CircleProgress"; -import IconButton, { EIconButtonVariant } from "@Front/Components/DesignSystem/IconButton"; import Tag, { ETagColor } from "@Front/Components/DesignSystem/Tag"; import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography"; import Module from "@Front/Config/Module"; -import { ArchiveBoxIcon, PencilSquareIcon, UserGroupIcon } from "@heroicons/react/24/outline"; +import { ArchiveBoxIcon, EllipsisHorizontalIcon, PencilSquareIcon, UsersIcon } from "@heroicons/react/24/outline"; import { OfficeFolder } from "le-coffre-resources/dist/Notary"; -import Link from "next/link"; import classes from "./classes.module.scss"; import { AnchorStatus } from ".."; +import ButtonWithSubMenu, { ISubElement } from "@Front/Components/Elements/ButtonWithSubMenu"; +import { useCallback } from "react"; type IProps = { folder: OfficeFolder | null; @@ -21,6 +21,49 @@ type IProps = { export default function InformationSection(props: IProps) { const { folder, progress, onArchive, anchorStatus, isArchived } = props; + const getSubMenuElement = useCallback(() => { + let elements: ISubElement[] = []; + + // Creating the three elements and adding them conditionnally + const modifyCollaboratorsElement = { + icon: , + text: "Modifier les collaborateurs", + link: Module.getInstance() + .get() + .modules.pages.Folder.pages.EditCollaborators.props.path.replace("[folderUid]", folder?.uid ?? ""), + hasSeparator: true, + }; + const modifyInformationsElement = { + icon: , + text: "Modifier les informations du dossier", + link: Module.getInstance() + .get() + .modules.pages.Folder.pages.EditInformations.props.path.replace("[folderUid]", folder?.uid ?? ""), + hasSeparator: true, + }; + + const archiveElement = { + icon: , + text: "Archiver le dossier", + onClick: onArchive, + color: ETypoColor.ERROR_WEAK_CONTRAST, + }; + + // If the folder is not anchored, we can modify the collaborators and the informations + if (anchorStatus === AnchorStatus.NOT_ANCHORED) { + elements.push(modifyCollaboratorsElement); + // Remove the separator if it's the last item (if the folder is not archived) + if (isArchived) modifyInformationsElement.hasSeparator = false; + + elements.push(modifyInformationsElement); + } + + // If the folder is not archived, we can archive it + if (!isArchived) { + elements.push(archiveElement); + } + return elements; + }, [anchorStatus, folder?.uid, isArchived, onArchive]); return (
@@ -43,37 +86,7 @@ export default function InformationSection(props: IProps) {
- {anchorStatus === AnchorStatus.NOT_ANCHORED && ( - <> - - } - variant={EIconButtonVariant.NEUTRAL} - /> - - - } - variant={EIconButtonVariant.NEUTRAL} - /> - - - )} - {!isArchived && ( - } - variant={EIconButtonVariant.ERROR} - /> - )} + } subElements={getSubMenuElement()} />