diff --git a/.storybook/modes.js b/.storybook/modes.js new file mode 100644 index 000000000..2cf07b5d7 --- /dev/null +++ b/.storybook/modes.js @@ -0,0 +1,21 @@ +import { destkop as theme } from "../src/theme"; +import numberFromDimension from "../src/utils/numberFromDimension"; + +export const allModes = { + touch: { + locale: "en", + desktopScale: "standard", + theme: "touch", + viewports: [theme.breakpoints.small, theme.breakpoints.medium, theme.breakpoints.large].map(numberFromDimension), + }, + desktop: { + locale: "en", + desktopScale: "standard", + theme: "desktop", + }, + experimental: { + locale: "en", + desktopScale: "experimental", + theme: "desktop", + }, +}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index cfe1881bd..d1dc0d795 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,7 +2,7 @@ import React from "react"; import { desktop as theme } from "../src/theme"; import { ALL_NDS_LOCALES, NDSProvider } from "../src"; -const newViewports = { +const viewports = { extraSmall: { name: "Extra small", styles: { @@ -41,7 +41,7 @@ const newViewports = { }; export const parameters = { - viewport: { viewports: newViewports }, + viewport: { viewports }, layout: "padded", options: { storySort: { diff --git a/cypress/e2e/components/TopBar.spec.ts b/cypress/e2e/components/TopBar.spec.ts new file mode 100644 index 000000000..f355b352f --- /dev/null +++ b/cypress/e2e/components/TopBar.spec.ts @@ -0,0 +1,51 @@ +describe("TopBar", () => { + const menuButton = () => cy.get('[data-testid="topbar-menu-button"]'); + const menu = () => cy.get('[data-testid="topbar-menu"]'); + const menuItems = () => cy.get('[data-testid="topbar-menu-item"]'); + const menuOverlay = () => cy.get('[data-testid="topbar-menu-overlay"]'); + + describe("Menu", () => { + beforeEach(() => { + cy.renderFromStorybook("topbar--default"); + }); + + it("opens menu when menu button is clicked", () => { + menuButton().click(); + menu().should("be.visible"); + }); + + it("closes menu when clicking outside", () => { + menuButton().click(); + menuOverlay().should("be.visible"); + menuOverlay().click({ force: true }); + menu().should("not.exist"); + }); + + it("displays correct number of menu items", () => { + menuButton().click(); + menuItems().should("have.length", 9); // see Topbar/stories/fixtures.ts + }); + }); + + describe("Accessibility", () => { + beforeEach(() => { + cy.renderFromStorybook("topbar--default"); + }); + + it("focuses the first focusable element in the page", () => { + menuButton().focus().type("{enter}"); + menuItems().first().find("a").should("have.focus"); + }); + + it("maintains focus trap in menu", () => { + menuButton().click(); + menuItems().last().find("a").focus().tab(); + menuItems().first().find("a").should("have.focus"); + }); + + it("has correct ARIA attributes", () => { + menuButton().click(); + menu().should("have.attr", "aria-label", "Menu options"); + }); + }); +}); diff --git a/locales/de_DE.json b/locales/de_DE.json index c9b43c089..e7cfe6a22 100644 --- a/locales/de_DE.json +++ b/locales/de_DE.json @@ -1,5 +1,6 @@ { "close menu": "Menü schließen", + "menu options": "Menüoptionen", "close": "Schließen", "collapse row": "Zeile verbergen", "date range": "Datumsbereich", diff --git a/locales/en_US.json b/locales/en_US.json index 695f5e458..948b644c4 100644 --- a/locales/en_US.json +++ b/locales/en_US.json @@ -1,5 +1,6 @@ { "close menu": "Close menu", + "menu options": "Menu options", "close": "Close", "collapse row": "Collapse row", "date range": "Date range", diff --git a/locales/es_MX.json b/locales/es_MX.json index 542d73279..b518bb27c 100644 --- a/locales/es_MX.json +++ b/locales/es_MX.json @@ -1,5 +1,6 @@ { "close menu": "Cerrar menú", + "menu options": "Opciones del menú", "close": "Cerrar", "collapse row": "Colapsar fila", "date range": "Rango de fechas", diff --git a/locales/fr_FR.json b/locales/fr_FR.json index 4c64b507a..41f16b47e 100644 --- a/locales/fr_FR.json +++ b/locales/fr_FR.json @@ -1,5 +1,6 @@ { "close menu": "Fermer le menu", + "menu options": "Options du menu", "close": "Fermer", "collapse row": "Réduire la ligne", "date range": "Plage de dates", diff --git a/locales/nl_NL.json b/locales/nl_NL.json index e81a7e718..9c8713902 100644 --- a/locales/nl_NL.json +++ b/locales/nl_NL.json @@ -1,5 +1,6 @@ { "close menu": "Menu sluiten", + "menu options": "Menu-opties", "close": "Sluiten", "collapse row": "Rij samenvouwen", "date range": "Datumbereik", diff --git a/locales/pl_PL.json b/locales/pl_PL.json index 030c66cfb..84ddb8e16 100644 --- a/locales/pl_PL.json +++ b/locales/pl_PL.json @@ -1,5 +1,6 @@ { "close menu": "Zamknij menu", + "menu options": "Opcje menu", "close": "Zamknij", "collapse row": "Zwiń wiersz", "date range": "Zakres daty", diff --git a/locales/pt_BR.json b/locales/pt_BR.json index 0e1ae7ead..e48a76f72 100644 --- a/locales/pt_BR.json +++ b/locales/pt_BR.json @@ -1,5 +1,6 @@ { "close menu": "Fechar menu", + "menu options": "Opções do menu", "close": "Fechar", "collapse row": "Recolher fila", "date range": "Intervalo de datas", diff --git a/locales/ro_RO.json b/locales/ro_RO.json index 26eb143c6..04b2d2534 100644 --- a/locales/ro_RO.json +++ b/locales/ro_RO.json @@ -1,5 +1,6 @@ { - "close menu": "Close menu", + "close menu": "Închidere meniu", + "menu options": "Opțiuni meniu", "close": "Închidere", "collapse row": "Restrângere rând", "date range": "Interval dată", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8796e628f..4c68dfc90 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1,5 +1,6 @@ { "close menu": "关闭菜单", + "menu options": "菜单选项", "close": "关闭", "collapse row": "折叠行", "date range": "日期范围", diff --git a/package.json b/package.json index 168187cbe..1b6d38c49 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "clean:storybook": "rm -rf node_modules/.cache/storybook && rm -rf ./storybook-static", "build": "rollup -c", "build:storybook": "build-storybook", - "check": "yarn check:types && yarn check:lint && yarn check:format", + "warn:prepush": "echo \"Make sure you also run all the other CI steps before pushing by running 'yarn ci'\"", + "check": "yarn warn:prepush && yarn check:types && yarn check:lint && yarn check:format", "check:types": "tsc && cd cypress && tsc --noEmit", "check:lint": "eslint --config ./.eslintrc .'*/**/*.{js,ts,tsx}'", "check:format": "prettier -c .", @@ -68,7 +69,7 @@ "@babel/preset-env": "7.3.1", "@babel/preset-typescript": "^7.10.4", "@nulogy/eslint-config-nulogy": "^1.0.0", - "@nulogy/icons": "^4.36.0", + "@nulogy/icons": "^4.37.2", "@rollup/plugin-babel": "^5.0.0", "@rollup/plugin-node-resolve": "^7.1.3", "@semantic-release/changelog": "^6.0.2", @@ -173,7 +174,7 @@ }, "husky": { "hooks": { - "pre-push": "yarn run ci" + "pre-push": "yarn run check" } }, "jest": { diff --git a/src/Link/Link.tsx b/src/Link/Link.tsx index 9e77df7a8..3b61288e2 100644 --- a/src/Link/Link.tsx +++ b/src/Link/Link.tsx @@ -1,7 +1,6 @@ import styled from "styled-components"; import { darken } from "polished"; import { themeGet } from "@styled-system/theme-get"; -import { variant } from "styled-system"; import React from "react"; import { DefaultNDSThemeType } from "../theme"; import { addStyledProps, StyledProps } from "../StyledProps"; diff --git a/src/TopBar/README.md b/src/TopBar/README.md new file mode 100644 index 000000000..880f85e8b --- /dev/null +++ b/src/TopBar/README.md @@ -0,0 +1,93 @@ +# TopBar + +The TopBar component provides users a consistent navigation experience across touch application pages. +It combines functionality from the desktop `Header` and the `BrandedNavBar` components. +The component is meant to be used in applications where the primary input device is a touch screen. + +## Basic Usage + +```tsx +import { TopBar } from '@nulogy/components'; + +function MyComponent() { + return ( + + Back + Current Page + + + + + ); +} +``` + +## Components + +### TopBar.Root + +The main container component that provides the header structure. + +```tsx + + {/* Other TopBar components */} + +``` + +### TopBar.BackLink + +Navigation component for going back to previous pages. Extends and accepts props for `React.ComponentProps<'a'>`. Can be customized with a different component using the `as` prop. + +| Prop | Type | Description | Default | +|------|------|-------------|---------| +| `maxWidth` | ResponsiveValue | Controls the maximum width of the back label. Can be customized using any CSS unit such as `px`, `em`, `ch`, etc... | `{ phoneLandscape: "20ch", tabletPortrait: "18ch", tabletLandscape: "20ch", laptop: "24ch" }` | +| `children` | ReactNode | Optional label text (hidden on mobile) | - | + +### TopBar.PageTitle + +Displays the current page title with automatic text overflow handling. + +| Prop | Type | Description | +|------|------|-------------| +| `children` | ReactNode | Title text | + +### TopBar.Menu + +A responsive menu system that displays as a grid of items in an overlay. + +| Prop | Type | Description | Default | +|------|------|-------------|---------| +| `defaultOpened` | boolean | Controls if menu is open by default | `false` | +| `aria-label` | string | Descriptive name for the content of the menu | `t("menu options")` | +| `children` | ReactNode | MenuItem components | - | + +### TopBar.MenuItem + +Individual menu items with icons and descriptions. Extends and accepts props for `React.ComponentProps<'a'>`. Can be customized with a different component using the `as` prop. + +| Prop | Type | Description | +|------|------|-------------| +| `title` | string | Item title | +| `description` | string | Optional description | +| `icon` | IconName | Icon to display | + + +## Accessibility + +- Menu sets the focus to the first in the menu when opened, returns the focus to the menu trigger when closed +- When the Menu is opened, the focus is trapped within the menu +- Supports keyboard navigation +- Includes proper ARIA labels for the menu + +## Best Practices + +- Ensure back navigation is logical within the application flow, maintaining consisitency between the back and current page titles when navigating forward and backward. + +## Technical Considerations + +- The TopBar default to HTML anchor tags but can be customized to use React Router Link components using the `as` prop diff --git a/src/TopBar/TopBar.styled.tsx b/src/TopBar/TopBar.styled.tsx new file mode 100644 index 000000000..0f7ccffc4 --- /dev/null +++ b/src/TopBar/TopBar.styled.tsx @@ -0,0 +1,171 @@ +import { DialogOverlay } from "@reach/dialog"; +import { motion } from "framer-motion"; +import { transparentize } from "polished"; +import styled from "styled-components"; +import { addStyledProps, StyledProps } from "../StyledProps"; +import { TOPBAR } from "./constants"; + +const MenuItemList = styled.ul(({ theme }) => ({ + display: "grid", + width: "100%", + gap: theme.space.x1, + flexWrap: "wrap", + listStyle: "none", + padding: theme.space.x1_5, + margin: 0, + maxHeight: `calc(100dvh - ${theme.space[TOPBAR.themedHeight]})`, + overflow: "auto", + gridTemplateColumns: "1fr", + + [`@media (min-width: ${theme.breakpoints.medium})`]: { + gridTemplateColumns: "repeat(2, 1fr)", + }, + + [`@media (min-width: ${theme.breakpoints.large})`]: { + gridTemplateColumns: "repeat(3, 1fr)", + }, +})); + +const Header = styled.header(({ theme }) => ({ + userSelect: "none", + touchAction: "none", + position: "sticky", + top: "0", + zIndex: theme.zIndices.navBar, + borderBottomWidth: "1px", + borderBottomStyle: "solid", + borderBottomColor: theme.colors.lightGrey, + background: transparentize(0.4, theme.colors.white), + backdropFilter: "blur(8px)", + width: "100dvw", +})); + +const Navigation = styled.nav(({ theme }) => ({ + height: theme.space[TOPBAR.themedHeight], + display: "flex", + alignItems: "center", + paddingLeft: theme.space.x2, + paddingRight: theme.space.x1, +})); + +const StylelessButton = styled.button( + { + backgroundColor: "transparent", + border: "none", + margin: 0, + padding: 0, + textAlign: "inherit", + font: "inherit", + borderRadius: 0, + appearance: "none", + }, + addStyledProps +); + +const MenuButton = styled(StylelessButton)(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + padding: theme.space.x1, + borderRadius: theme.radii.medium, + transition: "background-color 0.2s", + cursor: "pointer", + + "&:active, &:hover": { + backgroundColor: theme.colors.lightGrey, + }, +})); + +const NavigationItemsList = styled.ul({ + padding: 0, + margin: 0, + width: "100%", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + listStyle: "none", + whiteSpace: "nowrap", +}); + +const StyledBackLink = styled.a(({ theme }) => ({ + display: "inline-flex", + alignItems: "center", + justifyContent: "flex-start", + color: theme.colors.midGrey, + textDecoration: "none", + paddingTop: theme.space.x1, + paddingBottom: theme.space.x1, +})); + +const StyledPageTitle = styled.li(({ theme }) => ({ + paddingLeft: theme.space.x1, + paddingRight: theme.space.x1, + color: theme.colors.darkGrey, + textDecoration: "none", + fontSize: theme.fontSizes.small, + fontWeight: theme.fontWeights.medium, + lineHeight: theme.lineHeights.base, + whiteSpace: "nowrap", + flex: "auto", + textAlign: "center", + textOverflow: "ellipsis", + overflow: "hidden", +})); + +const Overlay = styled(motion(DialogOverlay))(({ theme }) => ({ + position: "fixed", + top: theme.space[TOPBAR.themedHeight], + bottom: theme.space.none, + left: theme.space.none, + right: theme.space.none, + display: "flex", + alignItems: "flex-start", + justifyContent: "flex-end", + backgroundColor: transparentize(0.85, theme.colors.white), +})); + +const TileLink = styled.a(({ theme }) => ({ + backgroundColor: transparentize(0.15)(theme.colors.blackBlue), + borderRadius: theme.radii.large, + display: "flex", + height: "100%", + alignItems: "flex-start", + padding: theme.space.x2, + textDecoration: "none", + color: theme.colors.white, + gap: theme.space.x1_5, + whiteSpace: "nowrap", + outlineOffset: theme.space.x0_25, + outlineColor: theme.colors.blue, +})); + +const StyledMenuItem = styled(motion.li)(({ theme }) => ({ + "&:only-child": { + [`@media (min-width: ${theme.breakpoints.medium})`]: { + gridColumn: "span 2", + }, + [`@media (min-width: ${theme.breakpoints.large})`]: { + gridColumn: "span 3", + }, + }, + + [`@media (min-width: ${theme.breakpoints.large})`]: { + "&:first-child:nth-last-child(2), &:last-child:nth-child(2)": { + gridColumn: "span 3", + }, + }, +})); + +export { + Navigation, + Header, + NavigationItemsList, + StyledBackLink, + StyledPageTitle, + Overlay, + TileLink, + StylelessButton, + MenuItemList, + MenuButton, + StyledMenuItem, +}; diff --git a/src/TopBar/TopBar.tsx b/src/TopBar/TopBar.tsx new file mode 100644 index 000000000..0dc3385e8 --- /dev/null +++ b/src/TopBar/TopBar.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { BackLink } from "./components/BackLink"; +import { Menu } from "./components/Menu"; +import { MenuItem } from "./components/MenuItem"; +import { PageTitle } from "./components/PageTitle"; +import { Header, Navigation, NavigationItemsList } from "./TopBar.styled"; + +export interface TopBarProps { + children?: React.ReactNode; +} + +export default function Root({ children }: TopBarProps) { + return ( +
+ + {children} + +
+ ); +} + +export const TopBar = { + Root, + PageTitle, + BackLink, + Menu, + MenuItem, +}; diff --git a/src/TopBar/components/BackLink.tsx b/src/TopBar/components/BackLink.tsx new file mode 100644 index 000000000..bbc667002 --- /dev/null +++ b/src/TopBar/components/BackLink.tsx @@ -0,0 +1,33 @@ +import React, { ComponentProps } from "react"; +import { MaxWidthProps } from "styled-system"; +import { Box } from "../../Box"; +import useMediaQuery from "../../hooks/useMediaQuery"; +import { Icon } from "../../Icon"; +import { Text } from "../../Type"; +import { StyledBackLink } from "../TopBar.styled"; + +const BACK_LINK_MAX_WIDTH: MaxWidthProps["maxWidth"] = { + phoneLandscape: "20ch", + tabletPortrait: "18ch", + tabletLandscape: "20ch", + laptop: "24ch", +}; + +interface BackLinkProps extends MaxWidthProps, ComponentProps {} + +export function BackLink({ children, maxWidth = BACK_LINK_MAX_WIDTH, ...props }: BackLinkProps) { + const md = useMediaQuery("phoneLandscape"); + + return ( + + + + {md && ( + + {children} + + )} + + + ); +} diff --git a/src/TopBar/components/Menu.tsx b/src/TopBar/components/Menu.tsx new file mode 100644 index 000000000..825aed4a3 --- /dev/null +++ b/src/TopBar/components/Menu.tsx @@ -0,0 +1,76 @@ +import { DialogContent } from "@reach/dialog"; +import { AnimatePresence } from "framer-motion"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Flex } from "../../Flex"; +import { Icon } from "../../Icon"; +import { MenuButton, Overlay, MenuItemList } from "../TopBar.styled"; + +const blurVariants = { + hidden: { + backdropFilter: "blur(0px)", + WebkitBackdropFilter: "blur(0px)", + }, + visible: { + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + transition: { + duration: 0.5, + ease: "easeInOut", + }, + }, + exit: { + backdropFilter: "blur(0px)", + WebkitBackdropFilter: "blur(0px)", + transition: { + duration: 0.5, + ease: "easeOut", + }, + }, +}; + +export function Menu({ + children, + defaultOpened = false, + ...props +}: { + defaultOpened?: boolean; + children: React.ReactNode; + "aria-label"?: string; +}) { + const [showMenu, setShowMenu] = React.useState(defaultOpened); + const { t } = useTranslation(); + + function close() { + setShowMenu(false); + } + + function toggle() { + setShowMenu((s) => !s); + } + + return ( + + + + + + {showMenu && ( + + + {children} + + + )} + + + ); +} diff --git a/src/TopBar/components/MenuItem.tsx b/src/TopBar/components/MenuItem.tsx new file mode 100644 index 000000000..456d3a550 --- /dev/null +++ b/src/TopBar/components/MenuItem.tsx @@ -0,0 +1,62 @@ +import { IconName } from "@nulogy/icons"; +import { motion } from "framer-motion"; +import React, { ComponentProps } from "react"; +import { Flex } from "../../Flex"; +import { Icon } from "../../Icon"; +import { Text } from "../../Type"; +import { StyledMenuItem, TileLink } from "../TopBar.styled"; + +const MotionText = motion(Text); + +const fadeInVariants = { + hidden: { + opacity: 0, + filter: "blur(8px)", + y: -16, + scale: 0.95, + transition: { + ease: "easeOut", + duration: 0.25, + }, + }, + visible: { + opacity: 1, + filter: "blur(0px)", + y: 0, + scale: 1, + transition: { + type: "spring", + duration: 0.75, + }, + }, +}; + +interface MenuItemProps extends ComponentProps { + title: string; + description?: string; + icon: IconName; +} + +export function MenuItem({ description, title, icon, ...props }: MenuItemProps) { + return ( + + + + + + {title} + + {description} + + + + ); +} + +export type MenuItems = MenuItemProps[]; diff --git a/src/TopBar/components/PageTitle.tsx b/src/TopBar/components/PageTitle.tsx new file mode 100644 index 000000000..c4263388b --- /dev/null +++ b/src/TopBar/components/PageTitle.tsx @@ -0,0 +1,10 @@ +import React, { ComponentProps } from "react"; +import { StyledPageTitle } from "../TopBar.styled"; + +export function PageTitle({ children, ...props }: ComponentProps) { + return ( + + {children} + + ); +} diff --git a/src/TopBar/constants.ts b/src/TopBar/constants.ts new file mode 100644 index 000000000..fad622df4 --- /dev/null +++ b/src/TopBar/constants.ts @@ -0,0 +1,3 @@ +export const TOPBAR = { + themedHeight: "x6", +}; diff --git a/src/TopBar/stories/TopBar.backButton.story.tsx b/src/TopBar/stories/TopBar.backButton.story.tsx new file mode 100644 index 000000000..fd00058d3 --- /dev/null +++ b/src/TopBar/stories/TopBar.backButton.story.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { Link, BrowserRouter } from "react-router-dom"; +import { TopBar } from "../TopBar"; +import { legacy as theme } from "../../theme/theme"; +import numberFromDimension from "../../utils/numberFromDimension"; +import { menuItems } from "./fixtures"; + +export default { + parameters: { + layout: "fullscreen", + chromatic: { + modes: { + locale: "en", + desktopScale: "standard", + theme: "touch", + viewports: [theme.breakpoints.small, theme.breakpoints.medium, theme.breakpoints.large].map( + numberFromDimension + ), + }, + }, + }, + title: "Components/TopBar/BackLink", +}; + +export const WithNoLabel = () => ( + + + Cycle count #3992 + + {menuItems.map((props) => ( + + ))} + + +); + +export const WithACustomMaxWidth = () => ( + + + Cycle counts + + Cycle count #3992 + + {menuItems.map((props) => ( + + ))} + + +); + +export const WithARouterLink = () => ( + + + + Cycle counts + + Cycle count #3992 + + {menuItems.map((props) => ( + + ))} + + + +); diff --git a/src/TopBar/stories/TopBar.menu.story.tsx b/src/TopBar/stories/TopBar.menu.story.tsx new file mode 100644 index 000000000..927e4bba0 --- /dev/null +++ b/src/TopBar/stories/TopBar.menu.story.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Link, BrowserRouter } from "react-router-dom"; +import { TopBar } from "../TopBar"; +import { legacy as theme } from "../../theme/theme"; +import numberFromDimension from "../../utils/numberFromDimension"; +import { menuItems } from "./fixtures"; + +export default { + parameters: { + layout: "fullscreen", + chromatic: { + delay: 1000, + modes: { + locale: "en", + desktopScale: "standard", + theme: "touch", + viewports: [theme.breakpoints.small, theme.breakpoints.medium, theme.breakpoints.large].map( + numberFromDimension + ), + }, + }, + }, + title: "Components/TopBar/Menu", +}; + +export const withDefaultOpenMenu = () => ( + + Cycle counts + Cycle count #3992 + + {menuItems.map((props) => ( + + ))} + + +); + +export const WithOneMenuItem = () => ( + + Cycle counts + Cycle count #3992 + + {menuItems.slice(0, 1).map((props) => ( + + ))} + + +); + +export const WithTwoItems = () => ( + + Cycle counts + Cycle count #3992 + + {menuItems.slice(0, 2).map((props) => ( + + ))} + + +); + +export const WithThreeItems = () => ( + + Cycle counts + Cycle count #3992 + + {menuItems.slice(0, 3).map((props) => ( + + ))} + + +); + +export const WithRouterLinks = () => ( + + Cycle counts + Cycle count #3992 + + + {[{ ...menuItems[0], as: Link, to: "/home" }].map((props) => ( + + ))} + + + +); diff --git a/src/TopBar/stories/TopBar.story.tsx b/src/TopBar/stories/TopBar.story.tsx new file mode 100644 index 000000000..79e22adbc --- /dev/null +++ b/src/TopBar/stories/TopBar.story.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { FormSection } from "../../Form"; +import { Input } from "../../Input"; +import { ApplicationFrame, Page } from "../../Layout"; +import { TopBar } from "../TopBar"; +import { legacy as theme } from "../../theme/theme"; +import numberFromDimension from "../../utils/numberFromDimension"; +import { menuItems } from "./fixtures"; + +export default { + title: "Components/TopBar", + parameters: { + layout: "fullscreen", + chromatic: { + modes: { + locale: "en", + desktopScale: "standard", + theme: "touch", + viewports: [theme.breakpoints.small, theme.breakpoints.medium, theme.breakpoints.large].map( + numberFromDimension + ), + }, + }, + }, +}; + +export const Default = () => ( + + Cycle counts + Cycle count #3992 + + {menuItems.map((props, i) => ( + + ))} + + +); + +export const WithALongTitle = () => ( + + Previous page title + A long title that can not fit on smaller screens + + {menuItems.map((props, i) => ( + + ))} + + +); + +export const WithAnApplicationFrame = () => ( + + Cycle counts + Cycle count #3992 + + {menuItems.map((props) => ( + + ))} + + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/src/TopBar/stories/fixtures.tsx b/src/TopBar/stories/fixtures.tsx new file mode 100644 index 000000000..de1e15671 --- /dev/null +++ b/src/TopBar/stories/fixtures.tsx @@ -0,0 +1,56 @@ +import { MenuItems } from "../components/MenuItem"; + +export const menuItems: MenuItems = [ + { + title: "Home", + description: "Go to the home page", + icon: "home", + href: "/home", + }, + { + title: "Historical orders", + description: "Manage past orders", + icon: "queryBuilder", + href: "/historical-orders", + }, + { + title: "Pallet inspection", + icon: "barcode", + href: "/historical-orders", + }, + { + title: "Pick Schedule", + icon: "calendarToday", + href: "/pick-schedule", + }, + { + title: "Settings", + description: "Prefrences and configurations", + icon: "wrench", + href: "/settings", + }, + { + title: "Inventory", + description: "Stock level management", + icon: "building", + href: "/inventory", + }, + { + title: "Reports", + description: "Data analytics and reporting", + icon: "publish", + href: "/reports", + }, + { + title: "Users", + description: "User management", + icon: "upArrow", + href: "/users", + }, + { + title: "Shipping", + description: "Manage deliveries", + icon: "warningOutline", + href: "/shipping", + }, +]; diff --git a/src/hooks/useMediaQuery/useMediaQuery.ts b/src/hooks/useMediaQuery/useMediaQuery.ts index 2c6407709..2aab3f162 100644 --- a/src/hooks/useMediaQuery/useMediaQuery.ts +++ b/src/hooks/useMediaQuery/useMediaQuery.ts @@ -1,27 +1,36 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useTheme } from "styled-components"; +import { Breakpoints } from "../../theme"; -function useMediaQuery(query: string): boolean { +type Query = keyof Breakpoints | (string & {}); + +function useMediaQuery(q: Query): boolean { const isUnsupported = typeof window === "undefined" || typeof window.matchMedia === "undefined"; + const theme = useTheme(); + const query = theme?.breakpoints?.[q] ? `(min-width: ${theme.breakpoints[q]})` : q; - const getMatches = (query: string): boolean => { - if (isUnsupported) { - return false; - } + const getMatches = useCallback( + (query: string): boolean => { + if (isUnsupported) { + return false; + } - return window.matchMedia(query).matches; - }; + return window.matchMedia(query).matches; + }, + [isUnsupported] + ); const [matches, setMatches] = useState(getMatches(query)); - function handleChange() { - setMatches(getMatches(query)); - } - useEffect(() => { if (isUnsupported) return; const matchMedia = window.matchMedia(query); + function handleChange() { + setMatches(getMatches(query)); + } + handleChange(); matchMedia.addEventListener("change", handleChange); @@ -29,7 +38,7 @@ function useMediaQuery(query: string): boolean { return () => { matchMedia.removeEventListener("change", handleChange); }; - }, [query]); + }, [getMatches, isUnsupported, query]); return matches; } diff --git a/src/index.ts b/src/index.ts index d60a8f201..671e9e494 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export { DropdownButton, DropdownItem, DropdownLink, DropdownMenu, DropdownText export { FieldLabel, HelpText, RequirementText } from "./FieldLabel"; export { Flex } from "./Flex"; export { Field, Fieldset, Form, FormSection } from "./Form"; +export { default as useMediaQuery } from "./hooks/useMediaQuery"; export { Icon, InlineIcon } from "./Icon"; export { Input } from "./Input"; export { ApplicationFrame, Header, Page, Sidebar } from "./Layout"; diff --git a/src/scripts/generateTheme.ts b/src/scripts/generateTheme.ts index 6f099a6b0..0ab772bfd 100644 --- a/src/scripts/generateTheme.ts +++ b/src/scripts/generateTheme.ts @@ -62,6 +62,13 @@ const BASE_THEME = { medium: tokens.size_breakpoint_medium, large: tokens.size_breakpoint_large, extraLarge: tokens.size_breakpoint_extra_large, + + phonePortrait: "320px", + phoneLandscape: "640px", + tabletPortrait: "768px", + tabletLandscape: "1024px", + laptop: "1280px", + desktop: "1440px", }, zIndices: { content: tokens.z_indices_content, @@ -259,7 +266,7 @@ function generateThemeConfig(baseUnit: number): DefaultNDSThemeType { const generatedThemes = { legacy, - desktop: generateThemeConfig(deviceBaseUnits.desktop), + experimental: generateThemeConfig(deviceBaseUnits.desktop), tablet: generateThemeConfig(deviceBaseUnits.tablet), phone: generateThemeConfig(deviceBaseUnits.phone), }; @@ -268,11 +275,11 @@ const output = `// This file is auto-generated using "yarn generate:theme" // Do not edit directly. import { DefaultNDSThemeType } from "./theme.type"; -type ThemeKey = "legacy" | "desktop" | "tablet" | "phone"; +type ThemeKey = "legacy" | "experimental" | "tablet" | "phone"; export const themes: Record = ${JSON.stringify(generatedThemes, null, 2)}; -export const { legacy, desktop, tablet, phone } = themes; +export const { legacy, experimental, tablet, phone } = themes; `; const outputPath = path.join(__dirname, "..", "theme/theme.ts"); diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 013b3aed6..d0872dae4 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -55,6 +55,12 @@ export const themes: Record = { medium: "1024px", large: "1360px", extraLarge: "1920px", + phonePortrait: "320px", + phoneLandscape: "640px", + tabletPortrait: "768px", + tabletLandscape: "1024px", + laptop: "1280px", + desktop: "1440px", }, zIndices: { content: 100, @@ -203,6 +209,12 @@ export const themes: Record = { medium: "1024px", large: "1360px", extraLarge: "1920px", + phonePortrait: "320px", + phoneLandscape: "640px", + tabletPortrait: "768px", + tabletLandscape: "1024px", + laptop: "1280px", + desktop: "1440px", }, zIndices: { content: 100, @@ -351,6 +363,12 @@ export const themes: Record = { medium: "1024px", large: "1360px", extraLarge: "1920px", + phonePortrait: "320px", + phoneLandscape: "640px", + tabletPortrait: "768px", + tabletLandscape: "1024px", + laptop: "1280px", + desktop: "1440px", }, zIndices: { content: 100, @@ -499,6 +517,12 @@ export const themes: Record = { medium: "1024px", large: "1360px", extraLarge: "1920px", + phonePortrait: "320px", + phoneLandscape: "640px", + tabletPortrait: "768px", + tabletLandscape: "1024px", + laptop: "1280px", + desktop: "1440px", }, zIndices: { content: 100, diff --git a/src/theme/theme.type.ts b/src/theme/theme.type.ts index 20b88c2cf..e56fd210c 100644 --- a/src/theme/theme.type.ts +++ b/src/theme/theme.type.ts @@ -122,6 +122,12 @@ export interface Breakpoints { medium: string; large: string; extraLarge: string; + phonePortrait: string; + phoneLandscape: string; + tabletPortrait: string; + tabletLandscape: string; + laptop: string; + desktop: string; } interface ZIndices { diff --git a/yarn.lock b/yarn.lock index 81b8ae76c..867e0a036 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4090,10 +4090,10 @@ eslint-plugin-react-hooks "^4.0.4" prettier "^2.0.5" -"@nulogy/icons@^4.36.0": - version "4.36.0" - resolved "https://registry.yarnpkg.com/@nulogy/icons/-/icons-4.36.0.tgz#3d7858b7d7a4ec83abe09f8715408344f96cf4c4" - integrity sha512-xpwbTaZbZ4qoV/FgzepxxDXDm6xzYpPnQh6OQL5l/C9aRjs7Khl2sI57zxK4DUGsLGWTeGGdXXjaxMyC99GISw== +"@nulogy/icons@^4.37.2": + version "4.37.2" + resolved "https://registry.yarnpkg.com/@nulogy/icons/-/icons-4.37.2.tgz#2fcf555460036219dd67d82c6a9072096691b4c0" + integrity sha512-Mfu4U7djXis5h8KzRHL6/e/mA3z0Q6ix4B0q9b4UCMPgzgKCJiX+ZSNvEWVP/08luHdhG4Ya8hvE7MIwy2vpIw== "@nulogy/tokens@^5.4.0": version "5.4.0"