diff --git a/src/local-nav/index.ts b/src/local-nav/index.ts new file mode 100644 index 000000000..94d60150a --- /dev/null +++ b/src/local-nav/index.ts @@ -0,0 +1,3 @@ +export * from "./local-nav-dropdown/local-nav-dropdown"; +export * from "./local-nav-menu/local-nav-menu"; +export * from "./types"; diff --git a/src/local-nav/internal-types.tsx b/src/local-nav/internal-types.tsx new file mode 100644 index 000000000..25fc36e55 --- /dev/null +++ b/src/local-nav/internal-types.tsx @@ -0,0 +1,13 @@ +import { LocalNavItemProps } from "./types"; + +export interface LocalNavItemComponentProps { + handleClick: React.MouseEventHandler; + isSelected: boolean; + item: LocalNavItemProps; + renderItem?: + | (( + item: LocalNavItemProps, + renderProps: { selected: boolean } + ) => React.ReactNode) + | undefined; +} diff --git a/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.tsx b/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.tsx new file mode 100644 index 000000000..36af0d16b --- /dev/null +++ b/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.tsx @@ -0,0 +1,128 @@ +import { ChevronDownIcon } from "@lifesg/react-icons/chevron-down"; +import { TickIcon } from "@lifesg/react-icons/tick"; +import styled, { css } from "styled-components"; +import { Color } from "../../color"; +import { Text } from "../../text/text"; + +// ============================================================================= +// STYLE INTERFACES, transient props are denoted with $ +// See more https://styled-components.com/docs/api#transient-props +// ============================================================================= +interface DropdownNavStyleProps { + $isStickied?: boolean; + $stickyOffset: number; + $sideMargin?: number; +} +interface NavItemListStyleProps { + $viewportHeight?: number; +} + +interface NavItemStyleProps { + $isSelected?: boolean; +} +interface DropdownExpandedProps { + $isDropdownExpanded: boolean; +} + +interface NavIconStyleProps extends DropdownExpandedProps {} +interface NavLabelStyleProps extends DropdownExpandedProps {} +// ============================================================================= +// STYLING +// ============================================================================= +// use #rrggbbaa format for color (D9 is 0.85 alpha) +// LINK: https://rgbacolorpicker.com/rgba-to-hex +export const Backdrop = styled.div` + position: fixed; + top: 0; + right: 0; + left: 0; + bottom: 0; + background-color: ${Color.Neutral[1]}D9; + z-index: -1; +`; + +export const LabelText = styled(Text.BodySmall)` + margin: 0; + ${(props) => + props.$isSelected && + css` + color: ${Color.Primary}; + `} +`; + +export const StyledTickIcon = styled(TickIcon)` + color: ${Color.Primary}; + margin: 0 8px; +`; + +export const NavIcon = styled(ChevronDownIcon)` + color: ${Color.Primary}; + transition: transform 250ms ease-in-out; + transform: rotate(${(props) => (props.$isDropdownExpanded ? 180 : 0)}deg); +`; + +export const NavLabel = styled.div` + cursor: pointer; + background: ${Color.Neutral[8]}; + padding: 12px 16px; + box-shadow: 0px 0px 1px 1px ${Color.Neutral[5]}; + overflow: hidden; + border-radius: ${(props) => + props.$isDropdownExpanded ? "4px 4px 0 0" : "4px"}; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 200ms linear; +`; + +export const NavItem = styled.li` + padding: ${(props) => + props.$isSelected ? "12px 8px 12px 0" : "12px 8px 12px 32px"}; + background: ${(props) => + props.$isSelected ? Color.Accent.Light[5] : Color.Neutral[8]}; + position: relative; /* Ensures that the tick mark is positioned relative to the selected item */ + display: flex; + align-items: center; /* Vertically align text and tick */ +`; + +export const NavItemList = styled.ul` + transition: all 300ms; + transform-origin: top; + list-style-type: none; + padding: 0px 8px 0px 8px; + margin: 0; + background: ${Color.Neutral[8]}; + cursor: pointer; + box-shadow: 0px 0px 1px 1px ${Color.Neutral[5]}; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + overflow-y: auto; /* Enables vertical scrolling */ + max-height: ${(props) => + props.$viewportHeight}px; /* Set a max height for the dropdown list */ +`; + +export const NavWrapper = styled.nav` + display: block; + position: sticky; + top: ${(props) => props.$stickyOffset}px; + width: 100%; + z-index: 10; + + ${(props) => + props.$isStickied && + `${NavLabel} { + ${props.$sideMargin && `margin: 0 -${props.$sideMargin}px;`} + padding: 12px 16px; + border-radius: 0; + } + + ${NavItemList} { + ${props.$sideMargin && `margin-left: -${props.$sideMargin}px;`} + ${props.$sideMargin && `margin-right: -${props.$sideMargin}px;`} + border-radius-bottom-left: 4px; + border-radius-bottom-right: 4px; + + } + + `} +`; diff --git a/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx b/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx new file mode 100644 index 000000000..50a17fad5 --- /dev/null +++ b/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx @@ -0,0 +1,219 @@ +/* eslint-disable react/display-name */ +import React, { useEffect, useImperativeHandle, useRef, useState } from "react"; +import { LocalNavItemComponentProps } from "../internal-types"; +import { LocalNavDropdownProps, LocalNavItemProps } from "../types"; +import { + Backdrop, + LabelText, + NavIcon, + NavItem, + NavItemList, + NavLabel, + NavWrapper, + StyledTickIcon, +} from "./local-nav-dropdown.styles"; + +const Component = ( + { + defaultLabel, + stickyOffset = 0, + onNavItemSelect, + items, + selectedItemIndex, + id, + "data-testid": testId, + className, + renderItem, + }: LocalNavDropdownProps, + ref: React.Ref +): JSX.Element => { + // ============================================================================= + // CONST, STATE, REF + // ============================================================================= + const detectStickyRef = useRef(null); + const dropdownRef = useRef(null); + const navWrapperRef = useRef(null); + const [isStickied, setIsStickied] = useState(false); + const [isDropdownExpanded, setIsDropdownExpanded] = + useState(false); + const [viewportHeight, setViewportHeight] = useState(0); + const [dropdowntHeight, setDropdownHeight] = useState(0); + const [dynamicMargin, setDynamicMargin] = useState(0); + const navTestId = testId || "local-nav-dropdown"; + + useImperativeHandle(ref, () => navWrapperRef.current); + + const labelText = + selectedItemIndex >= 0 && isStickied + ? items[selectedItemIndex].title + : defaultLabel; + + // ============================================================================= + // EFFECTS, EVENT LISTENERS + // ============================================================================ + + useEffect(() => { + if (dropdownRef.current) { + const dropdownHeight = + dropdownRef.current.getBoundingClientRect().height; + setDropdownHeight(dropdownHeight); + } + }, []); + + useEffect(() => { + setViewportHeight(window.innerHeight); + const handleResize = () => { + setViewportHeight(window.innerHeight); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + useEffect(() => { + const callback = (entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + setIsStickied(!entry.isIntersecting); + }; + + const observer = new IntersectionObserver(callback, { + threshold: 1, + rootMargin: `-${stickyOffset}px 0px 0px 0px`, + }); + + if (detectStickyRef.current) { + observer.observe(detectStickyRef.current); + } + + return () => observer.disconnect(); + }, [stickyOffset]); + + useEffect(() => { + document.body.style.overflow = + isDropdownExpanded && isStickied ? "hidden" : "auto"; + }, [isDropdownExpanded, isStickied]); + + useEffect(() => { + const adjustPadding = () => { + const dropdown = navWrapperRef?.current; + if (dropdown) { + const dropdownRect = dropdown.getBoundingClientRect(); + const spaceToRight = + document.body.clientWidth - dropdownRect.right; + const spaceToLeft = dropdownRect.left; + // Calculate the padding needed to balance the dropdown in the viewport + const sidePadding = Math.max(spaceToRight, spaceToLeft); + setDynamicMargin(sidePadding); + } + }; + adjustPadding(); + + window.addEventListener("resize", adjustPadding); + + return () => { + window.removeEventListener("resize", adjustPadding); + }; + }, []); + + // ============================================================================= + // EVENT HANDLERS + // ============================================================================= + + const handleToggleDropdown = () => { + setIsDropdownExpanded((prev) => !prev); + }; + + const handleDismissBackdrop = () => { + setIsDropdownExpanded(false); + }; + + const handleNavItemClick = ( + e: React.MouseEvent, + item: LocalNavItemProps, + index: number + ) => { + if (onNavItemSelect) { + onNavItemSelect(e, item, index); + } + setIsDropdownExpanded(false); + }; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + + const renderDropdownNavItem = ({ + handleClick, + isSelected, + item, + renderItem, + }: LocalNavItemComponentProps) => { + const { id, title } = item; + + const renderTitle = () => { + if (renderItem) { + return renderItem(item, { selected: isSelected }); + } + return ( + <> + {isSelected && } + {title} + + ); + }; + + return ( + + {renderTitle()} + + ); + }; + + return ( + <> + + + + {labelText} + + + {isDropdownExpanded && ( + + {items.map((item, i) => + renderDropdownNavItem({ + handleClick: (e) => + handleNavItemClick(e, item, i), + isSelected: + i === selectedItemIndex && isStickied, + item, + renderItem, + }) + )} + + )} + {isDropdownExpanded && isStickied && ( + + )} + + + ); +}; + +export const LocalNavDropdown = React.forwardRef(Component); diff --git a/src/local-nav/local-nav-menu/local-nav-menu.styles.tsx b/src/local-nav/local-nav-menu/local-nav-menu.styles.tsx new file mode 100644 index 000000000..a38366b74 --- /dev/null +++ b/src/local-nav/local-nav-menu/local-nav-menu.styles.tsx @@ -0,0 +1,47 @@ +import styled from "styled-components"; +import { Color } from "../../color"; +import { Text } from "../../text/text"; + +// ============================================================================= +// STYLE INTERFACES, transient props are denoted with $ +// See more https://styled-components.com/docs/api#transient-props +// ============================================================================= +interface NavItemStyleProps { + $isSelected?: boolean; +} + +// ============================================================================= +// STYLING +// ============================================================================= + +export const Nav = styled.ul` + list-style-type: none; + padding: 0; + margin-top: 0; +`; +export const TextLabel = styled(Text.Body)` + margin: 0; +`; + +export const NavItem = styled.li` + position: relative; + margin: 0; + padding: 1rem; + cursor: pointer; + + &::before { + content: ""; + position: absolute; + left: 0; + width: 4px; + height: 100%; + top: 0; + background-color: ${(props) => + props.$isSelected ? Color.Primary : Color.Accent.Light[5]}; + transition: all 250ms linear; + } + + &:hover { + background-color: ${Color.Accent.Light[6]}; + } +`; diff --git a/src/local-nav/local-nav-menu/local-nav-menu.tsx b/src/local-nav/local-nav-menu/local-nav-menu.tsx new file mode 100644 index 000000000..522e14191 --- /dev/null +++ b/src/local-nav/local-nav-menu/local-nav-menu.tsx @@ -0,0 +1,79 @@ +/* eslint-disable react/display-name */ +import React from "react"; +import { LocalNavItemComponentProps } from "../internal-types"; +import { LocalNavMenuProps } from "../types"; +import { Nav, NavItem, TextLabel } from "./local-nav-menu.styles"; + +/** + * A sidebar navigation element. The currently visible section will be highlighted. + * + * This component should be placed inside a container that aligns it to the side. + * The container can also have `position: sticky` in order to make it a sticky sidebar. + */ +const Component = ( + { + onNavItemSelect, + items, + selectedItemIndex, + id, + "data-testid": dataTestId, + className, + renderItem, + }: LocalNavMenuProps, + ref: React.Ref +): JSX.Element => { + // ============================================================================= + // CONST, STATE, REF + // ============================================================================= + const localNavMenuId = dataTestId || "local-nav-menu"; + + // ============================================================================= + // RENDER FUNCTIONS + // ============================================================================= + + const renderLocalNavItem = ({ + handleClick, + isSelected, + item, + renderItem, + }: LocalNavItemComponentProps) => { + const { id, title } = item; + + const renderTitle = () => { + if (renderItem) { + return renderItem(item, { selected: isSelected }); + } + return ( + + {title} + + ); + }; + + return ( + + {renderTitle()} + + ); + }; + return ( + + ); +}; + +export const LocalNavMenu = React.forwardRef(Component); diff --git a/src/local-nav/types.ts b/src/local-nav/types.ts new file mode 100644 index 000000000..761f217aa --- /dev/null +++ b/src/local-nav/types.ts @@ -0,0 +1,33 @@ +export interface LocalNavItemProps { + title: string | React.ReactNode; + id?: string | undefined; +} + +export interface LocalNavItemRenderProps { + selected: boolean; +} + +interface BaseLocalNavProps { + className?: string | undefined; + id?: string | undefined; + "data-testid"?: string | undefined; + onNavItemSelect: ( + e: React.MouseEvent, + item: LocalNavItemProps, + index: number + ) => void; + items: LocalNavItemProps[]; + selectedItemIndex?: number | undefined; + renderItem?: + | (( + item: LocalNavItemProps, + renderProps: LocalNavItemRenderProps + ) => React.ReactNode) + | undefined; +} + +export interface LocalNavMenuProps extends BaseLocalNavProps {} +export interface LocalNavDropdownProps extends BaseLocalNavProps { + defaultLabel: string | React.ReactNode; + stickyOffset?: number | undefined; +} diff --git a/stories/local-nav/doc-elements.tsx b/stories/local-nav/doc-elements.tsx new file mode 100644 index 000000000..8f4c32475 --- /dev/null +++ b/stories/local-nav/doc-elements.tsx @@ -0,0 +1,48 @@ +import { MediaQuery } from "src/media"; +import { Text } from "src/text"; +import styled from "styled-components"; + +export const Page = styled.div` + height: 200vh; + + display: grid; + gap: 1rem; + grid-template-columns: 1fr 2fr; + + ${MediaQuery.MaxWidth.mobileL} { + grid-template-columns: 1fr; + } + + > main { + padding: 1rem; + } +`; + +const renderSection = (index: number) => ( +
+ Title {index} + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus a + tortor vitae magna sagittis bibendum. + +
+); + +export const Content = () => ( + <> + {renderSection(1)} + {renderSection(2)} + {renderSection(3)} + +); + +export const TopContent = () => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus a + tortor vitae magna sagittis bibendum. Proin dui risus, rhoncus eget + ligula non, tincidunt volutpat erat. Suspendisse vitae mauris pharetra, + ullamcorper massa id, luctus elit. Aliquam at vestibulum nisi. In hac + habitasse platea dictumst. Vestibulum sit amet mollis justo, in iaculis + sem. Vivamus eu blandit sem. + +); diff --git a/stories/local-nav/local-nav.mdx b/stories/local-nav/local-nav.mdx new file mode 100644 index 000000000..bc579ef36 --- /dev/null +++ b/stories/local-nav/local-nav.mdx @@ -0,0 +1,55 @@ +import { Canvas, Meta } from "@storybook/blocks"; +import { Heading3, Secondary, Title } from "../storybook-common"; +import * as LocalNavStories from "./local-nav.stories"; +import { PropsTable } from "./props-table"; + + + +LocalNav + +Components for navigation to different sections within a page. + +Overview + +```tsx +import { + LocalNavMenu, + LocalNavDropdown, +} from "@lifesg/react-design-system/local-nav"; +``` + +Menu + +The `LocalNavMenu` displays navigation items in a vertical list. + + + +You are able to customise the display of the nav items. + + + +Dropdown + +The `LocalNavDropdown` displays navigation items in a dropdown. It becomes +sticky when it reaches the top of the screen. + +> **Note:** The source code below is only an example and is not intended to be used directly in production + + + +You are able to customise the display of the nav items in the dropdown. + + + +Combined usage + +A common use case to to use the menu on desktop, and switch to the dropdown on +smaller screens. + +> **Note:** The source code below is only an example and is not intended to be used directly in production + + + +Component API + + diff --git a/stories/local-nav/local-nav.stories.tsx b/stories/local-nav/local-nav.stories.tsx new file mode 100644 index 000000000..a8b30ba61 --- /dev/null +++ b/stories/local-nav/local-nav.stories.tsx @@ -0,0 +1,232 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useRef, useState } from "react"; +import { useMediaQuery } from "react-responsive"; +import { + LocalNavDropdown, + LocalNavItemProps, + LocalNavMenu, +} from "src/local-nav"; +import { MediaWidths } from "src/media"; +import { Content, Page, TopContent } from "./doc-elements"; + +type MenuComponent = typeof LocalNavMenu; +type DropdownComponent = typeof LocalNavDropdown; + +const meta: Meta = { + title: "Modules/LocalNav", +}; + +export default meta; + +const NAV_ITEMS = [ + { title: "Title 1" }, + { title: "Title 2" }, + { title: "Title 3" }, +]; + +export const Menu: StoryObj = { + render: () => { + const [selectedIndex, setSelectedIndex] = useState(-1); + + const handleNavItemClick = ( + e: React.MouseEvent, + item: LocalNavItemProps, + index: number + ) => { + setSelectedIndex(index); + }; + + return ( + + handleNavItemClick(e, item, index) + } + /> + ); + }, +}; + +export const MenuWithCustomTitle: StoryObj = { + render: () => { + const [selectedIndex, setSelectedIndex] = useState(-1); + + const handleNavItemClick = ( + e: React.MouseEvent, + item: LocalNavItemProps, + index: number + ) => { + setSelectedIndex(index); + }; + + return ( + + handleNavItemClick(e, item, index) + } + renderItem={(item, { selected }) => ( +
+ {selected && ( + + )} + {item.title} +
+ )} + /> + ); + }, +}; + +export const Dropdown: StoryObj = { + render: () => { + const [selectedIndex, setSelectedIndex] = useState(-1); + const contentRef = useRef(null); + + const handleNavItemClick = ( + e: React.MouseEvent, + item: LocalNavItemProps, + index: number + ) => { + setSelectedIndex(index); + + // Scroll to the selected section + const section = NAV_ITEMS[index]; + if (section) { + const element = contentRef.current?.children[index]; + if (element) { + const top = + element.getBoundingClientRect().top + + window.scrollY - + 200; + window.scrollTo({ top, behavior: "smooth" }); + } + } + }; + + return ( +
+ + + handleNavItemClick(e, item, index) + } + /> +
+ +
+
+ ); + }, + parameters: { + layout: "fullscreen", + docs: { story: { inline: false, iframeHeight: 500 } }, + }, +}; + +export const DropdownWithCustomTitle: StoryObj = { + render: () => { + const [selectedIndex, setSelectedIndex] = useState(-1); + + const handleNavItemClick = ( + e: React.MouseEvent, + item: LocalNavItemProps, + index: number + ) => { + setSelectedIndex(index); + }; + + return ( +
+ + ( +
+ {selected && ( + + )} + {item.title} +
+ )} + /> +
+ ); + }, +}; + +export const CombinedUsage: StoryObj = { + render: () => { + const [selectedIndex, setSelectedIndex] = useState(undefined); + const isMobile = useMediaQuery({ + maxWidth: MediaWidths.mobileL, + }); + + const handleNavItemClick = ( + e: React.MouseEvent, + item: LocalNavItemProps, + index: number + ) => { + setSelectedIndex(index); + }; + + return ( + + {!isMobile && ( + + handleNavItemClick(e, item, index) + } + /> + )} +
+ {isMobile && ( + + handleNavItemClick(e, item, index) + } + /> + )} + + +
+
+ ); + }, + parameters: { + layout: "fullscreen", + docs: { story: { inline: false, iframeHeight: 500 } }, + }, +}; diff --git a/stories/local-nav/props-table.tsx b/stories/local-nav/props-table.tsx new file mode 100644 index 000000000..26034231f --- /dev/null +++ b/stories/local-nav/props-table.tsx @@ -0,0 +1,105 @@ +import { ApiTable, TabAttribute, Tabs } from "../storybook-common"; +import { + ApiTableAttributeRowProps, + ApiTableSectionProps, +} from "../storybook-common/api-table/types"; + +const COMMON_ATTRIBUTES: ApiTableAttributeRowProps[] = [ + { + name: "selectedItemIndex", + description: "The current selected nav item", + propTypes: ["number"], + }, + { + name: "className", + description: "The class selector of the element", + propTypes: ["string"], + }, + { + name: "id", + description: "The unique identifier of the element", + propTypes: ["string"], + }, + { + name: "data-testid", + description: "The test identifier of the element", + propTypes: ["string"], + }, + { + name: "items", + description: "The nav items", + propTypes: ["LocalNavItemProps[]"], + }, + { + name: "onNavItemSelect", + description: "Called when the nav item is selected", + propTypes: [ + "(e: React.MouseEvent, item: LocalNavItemProps, index: number) => void", + ], + }, + { + name: "renderItem", + description: "Function to customise the rendering of the nav item", + propTypes: [ + "((item: LocalNavItemProps,renderProps: { selected: boolean }) => React.ReactNode", + ], + }, +]; + +const NAV_ITEM_DATA: ApiTableSectionProps = { + name: "LocalNavItemProps", + attributes: [ + { + name: "title", + description: "Display title of the nav item", + propTypes: ["string", "React.ReactNode"], + mandatory: true, + }, + { + name: "id", + description: "Unique identifier of the nav item", + propTypes: ["string"], + }, + ], +}; + +const MENU_DATA: ApiTableSectionProps[] = [ + { + attributes: [...COMMON_ATTRIBUTES], + }, + NAV_ITEM_DATA, +]; + +const DROPDOWN_DATA: ApiTableSectionProps[] = [ + { + attributes: [ + ...COMMON_ATTRIBUTES, + { + name: "defaultLabel", + description: "Default label when no nav items are selected", + propTypes: ["boolean"], + defaultValue: "0", + mandatory: true, + }, + { + name: "stickyOffset", + description: "The top offset when the dropdown is sticky", + propTypes: ["number"], + }, + ], + }, + NAV_ITEM_DATA, +]; + +const PROPS_TABLE_DATA: TabAttribute[] = [ + { + title: "LocalNavMenu", + component: , + }, + { + title: "LocalNavDropdown", + component: , + }, +]; + +export const PropsTable = () => ;