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 (
+
+ );
+}
+
+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"