From 72816da5a47dbb6eaebe0bc27cde4890931623ac Mon Sep 17 00:00:00 2001 From: Stephen Watkins Date: Thu, 19 Dec 2024 08:37:10 -0500 Subject: [PATCH] feat: ForgeLayout (#1545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Changes - Adds ForgeLayout ## ✅ Checklist Easy UI has certain UX standards that must be met. In general, non-trivial changes should meet the following criteria: - [x] Visuals match Design Specs in Figma - [x] Stories accompany any component changes - [x] Code is in accordance with our style guide - [x] Design tokens are utilized - [x] Unit tests accompany any component changes - [x] TSDoc is written for any API surface area - [x] Specs are up-to-date - [x] Console is free from warnings - [x] No accessibility violations are reported - [x] Cross-browser check is performed (Chrome, Safari, Firefox) - [x] Changeset is added ~Strikethrough~ any items that are not applicable to this pull request. --- .changeset/tasty-news-own.md | 6 + documentation/specs/ForgeLayout.md | 120 ++++++++ easy-ui-icons/src/AccountTree.json | 5 + easy-ui-icons/src/DoorOpen.json | 5 + easy-ui-icons/src/Key.json | 5 + easy-ui-icons/src/Shield.json | 5 + easy-ui-icons/src/ViewList.json | 5 + easy-ui-icons/src/Widgets.json | 5 + easy-ui-react/src/ForgeLayout/ForgeLayout.mdx | 40 +++ .../src/ForgeLayout/ForgeLayout.module.scss | 100 +++++++ .../src/ForgeLayout/ForgeLayout.stories.tsx | 157 ++++++++++ .../src/ForgeLayout/ForgeLayout.test.tsx | 169 +++++++++++ easy-ui-react/src/ForgeLayout/ForgeLayout.tsx | 273 ++++++++++++++++++ .../ForgeLayoutActions.module.scss | 51 ++++ .../src/ForgeLayout/ForgeLayoutActions.tsx | 165 +++++++++++ .../ForgeLayout/ForgeLayoutNav.module.scss | 35 +++ .../src/ForgeLayout/ForgeLayoutNav.tsx | 154 ++++++++++ easy-ui-react/src/ForgeLayout/index.ts | 1 + 18 files changed, 1301 insertions(+) create mode 100644 .changeset/tasty-news-own.md create mode 100644 documentation/specs/ForgeLayout.md create mode 100644 easy-ui-icons/src/AccountTree.json create mode 100644 easy-ui-icons/src/DoorOpen.json create mode 100644 easy-ui-icons/src/Key.json create mode 100644 easy-ui-icons/src/Shield.json create mode 100644 easy-ui-icons/src/ViewList.json create mode 100644 easy-ui-icons/src/Widgets.json create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayout.mdx create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayout.tsx create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutActions.module.scss create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutActions.tsx create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutNav.module.scss create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutNav.tsx create mode 100644 easy-ui-react/src/ForgeLayout/index.ts diff --git a/.changeset/tasty-news-own.md b/.changeset/tasty-news-own.md new file mode 100644 index 00000000..b3ec2452 --- /dev/null +++ b/.changeset/tasty-news-own.md @@ -0,0 +1,6 @@ +--- +"@easypost/easy-ui-icons": minor +"@easypost/easy-ui": minor +--- + +feat: ForgeLayout diff --git a/documentation/specs/ForgeLayout.md b/documentation/specs/ForgeLayout.md new file mode 100644 index 00000000..c94bec70 --- /dev/null +++ b/documentation/specs/ForgeLayout.md @@ -0,0 +1,120 @@ +# `ForgeLayout` Component Specification + +## Overview + +`ForgeLayout` defines the header, nav, and main content areas of a Forge product page. + +### Prior Art + +- [Primer ``](https://primer.style/design/components/page-layout/react) +- [Paste `](https://paste.twilio.design/components/sidebar-navigation) + +--- + +## Design + +`ForgeLayout` will be a compound component consisting of `ForgeLayout`, `ForgeLayout.Nav`, `ForgeLayout.Header`, and `ForgeLayout.Content`. + +`ForgeLayout` is highly composable. Subcomponents within a `ForgeLayout` can be replaced as needed. Subcomponents are lightweight wrappers with built-in styles and constraints. + +`ForgeLayout` is concerned only with presentational structure. It is meant to be wrapped by an app layout that may include app-specific business logic and configuration. + +`ForgeLayout` can be in an `expanded` or `collapsed` navigational state by using the `navState` prop. When `expanded`, the navigation is present, along with any relevant header controls. When `collapsed`, the navigation is hidden, and the relevant controls are presented in the header. + +`ForgeLayout` is aware of a global `mode` prop. When passed `test`, the shell is decorated with a color to indicate a non-production environment. The `mode` can be changed with the `ModeSwitcher` control. + +### API + +```tsx +import { ForgeLayout } from "@easypost/easy-ui/ForgeLayout"; + +function App() { + return ( + + + + Item 1 + + Title}> + + Item 2 + + + Item 3 + + + Title}> + + Item 4 + + + Item 5 + + + + + + + {}}> + Back + + + Breadcrumb + Breadcrumb + + + + + + {}} /> + + + } + > + + Action 1:1 + Action 1:2 + + + + + Action 2:1 + Action 2:2 + + + + + + Page Content + + ); +} +``` + +--- + +## Behavior + +### Accessibility + +- `ForgeLayout.Header` will render as `header` +- `ForgeLayout.Content` will render as `main` +- `ForgeLayout.Nav` will be rendered as `nav` with associated `aria-label` +- `ForgeLayout.NavLink` will render as `` +- Selected nav links will be decorated as `aria-current="page"` + +### Dependencies + +- `Text` +- `useLink` +- Will use `EasyUIProvider`'s navigation hooks to support client-side links. See [client side routing](https://react-spectrum.adobe.com/react-aria/routing.html#routerprovider). This was added as part of `NexusLayout`. diff --git a/easy-ui-icons/src/AccountTree.json b/easy-ui-icons/src/AccountTree.json new file mode 100644 index 00000000..ec970532 --- /dev/null +++ b/easy-ui-icons/src/AccountTree.json @@ -0,0 +1,5 @@ +{ + "name": "account_tree", + "style": "outlined", + "source": "@material-symbols/svg-300" +} diff --git a/easy-ui-icons/src/DoorOpen.json b/easy-ui-icons/src/DoorOpen.json new file mode 100644 index 00000000..e7d05831 --- /dev/null +++ b/easy-ui-icons/src/DoorOpen.json @@ -0,0 +1,5 @@ +{ + "name": "door_open", + "style": "outlined", + "source": "@material-symbols/svg-300" +} diff --git a/easy-ui-icons/src/Key.json b/easy-ui-icons/src/Key.json new file mode 100644 index 00000000..458b0a1b --- /dev/null +++ b/easy-ui-icons/src/Key.json @@ -0,0 +1,5 @@ +{ + "name": "key", + "style": "outlined", + "source": "@material-symbols/svg-300" +} diff --git a/easy-ui-icons/src/Shield.json b/easy-ui-icons/src/Shield.json new file mode 100644 index 00000000..55ca9f9d --- /dev/null +++ b/easy-ui-icons/src/Shield.json @@ -0,0 +1,5 @@ +{ + "name": "shield", + "style": "outlined", + "source": "@material-symbols/svg-300" +} diff --git a/easy-ui-icons/src/ViewList.json b/easy-ui-icons/src/ViewList.json new file mode 100644 index 00000000..05aa795d --- /dev/null +++ b/easy-ui-icons/src/ViewList.json @@ -0,0 +1,5 @@ +{ + "name": "view_list", + "style": "outlined", + "source": "@material-symbols/svg-300" +} diff --git a/easy-ui-icons/src/Widgets.json b/easy-ui-icons/src/Widgets.json new file mode 100644 index 00000000..12784695 --- /dev/null +++ b/easy-ui-icons/src/Widgets.json @@ -0,0 +1,5 @@ +{ + "name": "widgets", + "style": "outlined", + "source": "@material-symbols/svg-300" +} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.mdx b/easy-ui-react/src/ForgeLayout/ForgeLayout.mdx new file mode 100644 index 00000000..893005b5 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.mdx @@ -0,0 +1,40 @@ +import React from "react"; +import { Canvas, Meta, ArgTypes, Controls } from "@storybook/blocks"; +import { ForgeLayout } from "./ForgeLayout"; +import * as ForgeLayoutStories from "./ForgeLayout.stories"; + + + +# ForgeLayout + +`ForgeLayout` defines the header, main content, and multipage content areas of a Nexus product page. + + + +`ForgeLayout` is a compound component consisting of `ForgeLayout`, `ForgeLayout.Nav`, `ForgeLayout.Header`, and `ForgeLayout.Content`. + +`ForgeLayout` also provides components for building navigation with `ForgeLayout.Nav`, `ForgeLayout.NavLink`, and `ForgeLayout.NavSection`. + +`ForgeLayout` is highly composable. Subcomponents within a `ForgeLayout` can be replaced as needed. Subcomponents are simple lightweight wrappers with built-in styles and constraints. + +## Test Mode + +`ForgeLayout` supports an obvious visual indicator for test mode with the `mode="test"` prop. + + + + + +## Collapsed Navigation + +`ForgeLayout` supports a distraction-free content mode with the `navState="collapsed"` prop. + + + + + +## Properties + +### ForgeLayout + + diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss new file mode 100644 index 00000000..7697b155 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss @@ -0,0 +1,100 @@ +@use "../styles/common" as *; +@use "../styles/unstyled"; + +.ForgeLayout { + @include component-token("forge-layout", "header-height", 56px); + @include component-token("forge-layout", "shell-gutter", 20px); + @include component-token("forge-layout", "menu-border-color", transparent); + @include component-token( + "forge-layout", + "header-border-color", + design-token("color.neutral.300") + ); + @include component-token("forge-layout", "header-border-width", 1px); + + display: flex; + flex-direction: row; + align-items: flex-start; + padding-left: component-token("forge-layout", "shell-gutter"); + padding-right: component-token("forge-layout", "shell-gutter"); + gap: component-token("forge-layout", "shell-gutter"); + min-height: 100svh; + background-color: design-token("color.neutral.025"); + position: relative; +} + +.modeTest { + @include component-token( + "forge-layout", + "menu-border-color", + design-token("color.warning.600") + ); + @include component-token( + "forge-layout", + "header-border-color", + design-token("color.warning.600") + ); + @include component-token("forge-layout", "header-border-width", 2px); +} + +.backgroundDecoration01 { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzA5IiBoZWlnaHQ9IjI5NSIgdmlld0JveD0iMCAwIDMwOSAyOTUiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik00NC4xMTc1IDIyOC41NjdDMzQuNjg2OSAyMjYuOTU3IDI3LjcwMjEgMjE4LjkxOCAyNy40MjQgMjA5LjM1N0wyNS41MDk2IDE0My41NzNDMjUuMzM2NSAxMzcuNTU5IDI3Ljg2OTkgMTMxLjc4IDMyLjQxMTIgMTI3LjgzNEw2MS41MjM5IDEwMi41MzRDNjYuMDY1MiA5OC41ODc5IDcyLjEzOTggOTYuODg0NSA3OC4wNzQ4IDk3Ljg5NzhMMTA2LjE4NCAxMDIuNjk4TDExNi4zNjYgMTU0LjMzNkMxMTcuNzQ0IDE2MS4zMzQgMTIyLjczMyAxNjcuMDc2IDEyOS40NzMgMTY5LjQxN0wxNjEuMTU5IDE4MC40MzdMMTYxLjU1MiAxOTMuOTY3QzE2MS43MjkgMTk5Ljk4MiAxNTkuMTk1IDIwNS43NjEgMTU0LjY1NCAyMDkuNzA3TDEyNS41NDEgMjM1LjAwN0MxMjAuOTk3IDIzOC45NTIgMTE0LjkyMiAyNDAuNjU1IDEwOC45OTEgMjM5LjY0M0w0NC4xMTc1IDIyOC41NjdaIiBmaWxsPSIjRkI1QzU5Ii8+CjxwYXRoIGQ9Ik0yMDQuNjczIDE4NC44ODZDMTk5LjI0NCAxODkuNjAzIDE5MS43MDUgMTkxLjA1NSAxODQuOTEgMTg4LjY5NkwxNjEuMTU5IDE4MC40MzdMMTU5LjYzOCAxMjguMTgzQzE1OS4zNiAxMTguNjIyIDE1Mi4zNzkgMTEwLjU4NSAxNDIuOTQ4IDEwOC45NzRMMTA2LjE4NCAxMDIuNjk4TDEwNS4wMTMgOTYuNzUzM0MxMDMuNjIyIDg5LjY5NDIgMTA2LjExMiA4Mi40MzI2IDExMS41NDYgNzcuNzEzM0wxNTYuMDY0IDM5LjAyNzVDMTYxLjQ5MyAzNC4zMTA1IDE2OS4wMzIgMzIuODU3NyAxNzUuODI3IDM1LjIxNzZMMjMxLjI2NCA1NC40OTU3QzIzOC4wMDQgNTYuODM2OSAyNDIuOTkyIDYyLjU3OTMgMjQ0LjM3IDY5LjU3NzFMMjUyLjUzNSAxMTAuOTc4TDI0Ni40MzEgMTA2LjAwOEMyNDIuNjQzIDEwMi45MjQgMjM3LjE4OSAxMDMuMDE0IDIzMy41MDMgMTA2LjIxNUwyMDguOTM5IDEyNy41NjFDMjA1LjI1MyAxMzAuNzYyIDIwNC40MDggMTM2LjE1MiAyMDYuOTMyIDE0MC4zMjlMMjIwLjI5MyAxNjIuNDQ2QzIyMC44ODkgMTYzLjQzMiAyMjEuNjUzIDE2NC4zMSAyMjIuNTQ3IDE2NS4wMzlMMjI1LjEwOCAxNjcuMTI3TDIwNC42NzMgMTg0Ljg4NloiIGZpbGw9IiMxNjRERkYiLz4KPHBhdGggZD0iTTE2MS4xNTkgMTgwLjQzN0wxMjkuNDczIDE2OS40MTdDMTIyLjczMyAxNjcuMDc2IDExNy43NDQgMTYxLjMzNCAxMTYuMzY3IDE1NC4zMzZMMTA2LjE4NCAxMDIuNjk4TDE0Mi45NDggMTA4Ljk3NEMxNTIuMzc5IDExMC41ODQgMTU5LjM2IDExOC42MjIgMTU5LjYzOCAxMjguMTgzTDE2MS4xNTkgMTgwLjQzN1oiIGZpbGw9IiNGRURFREUiLz4KPHBhdGggZD0iTTI1NS41MDYgMTgxLjE0NkMyNTEuODIgMTg0LjM0OCAyNDYuMzYzIDE4NC40MzYgMjQyLjU3OCAxODEuMzUzTDIyNS4xMDggMTY3LjEyN0wyNDkuMTkxIDE0Ni4yQzI1NC42MjQgMTQxLjQ4IDI1Ny4xMTUgMTM0LjIxOSAyNTUuNzI0IDEyNy4xNkwyNTIuNTM1IDExMC45NzhMMjY2LjQ2MiAxMjIuMzIyQzI2Ny4zNTYgMTIzLjA1MSAyNjguMTIgMTIzLjkyOSAyNjguNzE2IDEyNC45MTZMMjgyLjA3NiAxNDcuMDMyQzI4NC42MDEgMTUxLjIwOSAyODMuNzUzIDE1Ni41OTggMjgwLjA2NyAxNTkuNzk5TDI1NS41MDYgMTgxLjE0NlYxODEuMTQ2WiIgZmlsbD0iIzAwRTVBRSIvPgo8cGF0aCBkPSJNMjI1LjEwOCAxNjcuMTI3TDIyMi41NDcgMTY1LjAzOUMyMjEuNjUzIDE2NC4zMSAyMjAuODg5IDE2My40MzIgMjIwLjI5MyAxNjIuNDQ1TDIwNi45MzIgMTQwLjMyOUMyMDQuNDA4IDEzNi4xNTIgMjA1LjI1MyAxMzAuNzYyIDIwOC45MzkgMTI3LjU2MUwyMzMuNTAzIDEwNi4yMTVDMjM3LjE4OSAxMDMuMDE0IDI0Mi42NDMgMTAyLjkyNCAyNDYuNDMxIDEwNi4wMDhMMjUyLjUzNSAxMTAuOTc4TDI1NS43MjQgMTI3LjE2QzI1Ny4xMTUgMTM0LjIxOSAyNTQuNjI0IDE0MS40OCAyNDkuMTkxIDE0Ni4yTDIyNS4xMDggMTY3LjEyN1YxNjcuMTI3WiIgZmlsbD0iI0ZGRjlGNSIvPgo8L3N2Zz4K"), + url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzEyIiBoZWlnaHQ9IjIyMyIgdmlld0JveD0iMCAwIDMxMiAyMjMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0yNzAuNDE1IDEwLjI3MTNDMjc0LjU0NCAxMS4zOSAyNzcuNzcxIDE0LjYxMzYgMjc4Ljg5IDE4Ljc0NjJMMjg4LjA5OCA1Mi43MjlDMjg5LjIzMSA1Ni44OTMgMjg4LjA0MSA2MS4zNDYgMjg0Ljk5IDY0LjM5OUwyNzMuOTUxIDc1LjQzN0wyNTYuMDQyIDUzLjU3MkMyNTEuNDcgNDcuOTg0OSAyNDQuNjI4IDQ0Ljc0NyAyMzcuNDA2IDQ0Ljc0ODdMMTk3LjY4OCA0NC43NDQ3TDE5Ni42MzUgNDAuODU2N0MxOTUuNTA2IDM2LjY4OTkgMTk2LjY5MiAzMi4yMzk2IDE5OS43NDMgMjkuMTg2NkwyMjQuNzYzIDQuMTcwNDFDMjI3LjgxNCAxLjExNzQxIDIzMi4yNjYgLTAuMDY3MjkxOSAyMzYuNDMgMS4wNjA5MUwyNzAuNDE1IDEwLjI3MTNaIiBmaWxsPSIjRkI1QzU5Ii8+CjxwYXRoIGQ9Ik0zMDYuMDg0IDExNC42NThDMzEzLjM1NSAxMjMuNTM1IDMxMy4zNTUgMTM2LjMxMiAzMDYuMDgyIDE0NS4xOUwyNTYuMDQ1IDIwNi4yNzRDMjUxLjQ3MSAyMTEuODYxIDI0NC42MjggMjE1LjEwMSAyMzcuNDA4IDIxNS4wOTdMMTkxLjEzMSAyMTUuMDk5QzE4My45MTMgMjE1LjA5OCAxNzcuMDcxIDIxMS44NjEgMTcyLjQ5NSAyMDYuMjc2TDE1NS41ODIgMTg1LjYyOUMxNTYuODc1IDE4My43NTUgMTU4LjA5MyAxODEuODIgMTU5LjIzNSAxNzkuODI0QzE3NC4yODkgMTUzLjU3NyAxNzQuMjg4IDEyMS4zMTggMTU5LjIzNSA5NS4wNzJDMTU2LjQ0IDkwLjE5OCAxNTMuMjA2IDg1LjY3MSAxNDkuNTk2IDgxLjUyNkwxNzIuNDkzIDUzLjU3M0MxNzcuMDY2IDQ3Ljk4NjcgMTgzLjkwNSA0NC43NDg3IDE5MS4xMjcgNDQuNzQ2OUwxOTcuNjg0IDQ0Ljc0NjZMMjA1LjgzOSA3NC44NDFDMjA2Ljk2MiA3OC45NzIgMjEwLjE4NiA4Mi4xOTcgMjE0LjMxNiA4My4zMkwyNDguMyA5Mi41MjdDMjUyLjQ2OSA5My42NTYgMjU2LjkxNiA5Mi40NyAyNTkuOTY5IDg5LjQyMUwyNzMuOTQ3IDc1LjQzOUwzMDYuMDggMTE0LjY2TDMwNi4wODQgMTE0LjY1OFoiIGZpbGw9IiMxNjRERkYiLz4KPHBhdGggZD0iTTI1Ni4wNDIgNTMuNTcyTDI3My45NTEgNzUuNDM3TDI1OS45NzIgODkuNDE5QzI1Ni45MTkgOTIuNDY4IDI1Mi40NzMgOTMuNjU1IDI0OC4zMDMgOTIuNTI1TDIxNC4zMiA4My4zMThDMjEwLjE4OSA4Mi4xOTYgMjA2Ljk2NiA3OC45NyAyMDUuODQzIDc0Ljg0TDE5Ny42ODggNDQuNzQ0OUwyMzcuNDA2IDQ0Ljc0OUMyNDQuNjI4IDQ0Ljc0NzIgMjUxLjQ3IDQ3Ljk4NTEgMjU2LjA0MiA1My41NzJaIiBmaWxsPSIjRkVERURFIi8+CjxwYXRoIGQ9Ik0xNTkuMjM2IDk1LjA2N0MxNzQuMjkyIDEyMS4zMTYgMTc0LjI5MyAxNTMuNTc1IDE1OS4yMzkgMTc5LjgyMkMxNTguMDk2IDE4MS44MTggMTU2Ljg3OCAxODMuNzUzIDE1NS41ODYgMTg1LjYyN0wxMjIuNDYxIDE0NS4xODZDMTE1LjE5IDEzNi4zMDkgMTE1LjE5IDEyMy41MzIgMTIyLjQ2MyAxMTQuNjUzTDE0OS42MDMgODEuNTIzQzE1My4yMTMgODUuNjY4IDE1Ni40NDcgOTAuMTk0IDE1OS4yNCA5NS4wNjVMMTU5LjIzNiA5NS4wNjdaIiBmaWxsPSIjQ0NGQUVGIi8+CjxwYXRoIGQ9Ik0xNDkuNTk5IDgxLjUyNUwxMjIuNDU5IDExNC42NTZDMTE1LjE4NiAxMjMuNTM0IDExNS4xODYgMTM2LjMxMSAxMjIuNDU3IDE0NS4xODhMMTU1LjU4MiAxODUuNjI5QzEzOS43ODkgMjA4LjY2MiAxMTMuNTY5IDIyMi42MjMgODUuMzU3IDIyMi42MjJMODUuMzUzIDIyMi42MjRDNTQuODM2NiAyMjIuNjIzIDI2LjY1MzIgMjA2LjI5NyAxMS40NjgyIDE3OS44MjRDMy45NDI0IDE2Ni43MDMgMC4xNzczMDIgMTUyLjA3NiAwLjE3OTkwMiAxMzcuNDQ1QzAuMTc4ODAyIDEyMi44MTYgMy45NDAyIDEwOC4xOTEgMTEuNDY5MiA5NS4wNjZDMTkuMDgxOCA4MS43OTQgMjkuOTYwMyA3MS4wNzIgNDIuNzY1NiA2My42NzlDNTUuNTcwOCA1Ni4yODYgNzAuMTM2IDUyLjI2OCA4NS4zNTMgNTIuMjdDMTEwLjI1NSA1Mi4yNjkgMTMzLjYwMyA2My4xMzYgMTQ5LjU5OSA4MS41MjVaIiBmaWxsPSIjMDBFNUFFIi8+Cjwvc3ZnPgo="); + background-repeat: no-repeat; + background-position: + -40px -90px, + calc(100% + 24px) calc(100% + 24px); +} + +.body { + flex: 1; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + min-width: 0; + border-bottom: 2px solid transparent; + z-index: calc(#{design-token("z-index.nav")} + 1); +} + +.content { + padding-top: component-token("forge-layout", "shell-gutter"); + padding-bottom: component-token("forge-layout", "shell-gutter"); +} + +.controls { + display: flex; + align-items: center; + gap: design-token("space.2"); +} + +.fauxContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + display: flex; + flex-direction: column; + pointer-events: none; +} + +.fauxHeader { + background-color: design-token("color.neutral.025"); + border-bottom: component-token("forge-layout", "header-border-width") solid + component-token("forge-layout", "header-border-color"); + z-index: design-token("z-index.nav"); +} + +.fauxHeader, +.header { + position: sticky; + top: 0; + height: component-token("forge-layout", "header-height"); +} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx new file mode 100644 index 00000000..8a287ec7 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx @@ -0,0 +1,157 @@ +import AccountBalanceIcon from "@easypost/easy-ui-icons/AccountBalance"; +import AccountCircleIcon from "@easypost/easy-ui-icons/AccountCircle"; +import AccountTreeIcon from "@easypost/easy-ui-icons/AccountTree"; +import DoorOpenIcon from "@easypost/easy-ui-icons/DoorOpen"; +import GroupsIcon from "@easypost/easy-ui-icons/Groups"; +import HomeIcon from "@easypost/easy-ui-icons/Home"; +import KeyIcon from "@easypost/easy-ui-icons/Key"; +import LocalShippingIcon from "@easypost/easy-ui-icons/LocalShipping"; +import RadarIcon from "@easypost/easy-ui-icons/Radar"; +import SettingsIcon from "@easypost/easy-ui-icons/Settings"; +import ShieldIcon from "@easypost/easy-ui-icons/Shield"; +import SupportIcon from "@easypost/easy-ui-icons/Support"; +import ViewListIcon from "@easypost/easy-ui-icons/ViewList"; +import WebhookIcon from "@easypost/easy-ui-icons/Webhook"; +import WidgetsIcon from "@easypost/easy-ui-icons/Widgets"; +import { action } from "@storybook/addon-actions"; +import { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { Menu } from "../Menu"; +import { ForgeLayout, ForgeLayoutProps } from "./ForgeLayout"; +import { Card } from "../Card"; + +type Story = StoryObj; + +const Template = (args: Partial) => { + return ( + + + + Dashboard + + Management}> + + Insurance + + + Sub Accounts + + + Carriers + + + Wallet + + + Branding + + + Members + + + Account Settings + + + Development}> + + Logs + + + API Keys + + + Webhooks + + + + + + +
Controls when collapsed
+
+ +
Controls when expanded
+
+ + } + > + + Action 1:1 + Action 1:2 + + + + + Action 2:1 + Action 2:2 + + + + + +
+ + +
Page Content
+
+
+
+
+ ); +}; + +const meta: Meta = { + title: "Components/ProductLayout/ForgeLayout", + component: ForgeLayout, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +export const Default: Story = { + render: Template.bind({}), +}; + +export const TestMode: Story = { + render: Template.bind({}), + args: { + mode: "test", + }, + parameters: { + controls: { + include: ["mode"], + }, + }, +}; + +export const Collapsed: Story = { + render: Template.bind({}), + args: { + navState: "collapsed", + }, + parameters: { + controls: { + include: ["navState"], + }, + }, +}; diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx new file mode 100644 index 00000000..4b906a04 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx @@ -0,0 +1,169 @@ +import { screen } from "@testing-library/react"; +import React, { ReactNode } from "react"; +import { vi } from "vitest"; +import { Menu } from "../Menu"; +import { + mockGetComputedStyle, + mockIntersectionObserver, + render, + userClick, +} from "../utilities/test"; +import { ForgeLayout, Mode, NavState } from "./ForgeLayout"; + +describe("", () => { + let restoreGetComputedStyle: () => void; + let restoreIntersectionObserver: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + restoreIntersectionObserver = mockIntersectionObserver(); + restoreGetComputedStyle = mockGetComputedStyle(); + }); + + afterEach(() => { + restoreGetComputedStyle(); + restoreIntersectionObserver(); + vi.useRealTimers(); + }); + + it("should render a forge layout", async () => { + const handleMenuAction1 = vi.fn(); + + const { user } = render( + createForgeLayout({ + selectedHref: "/1", + onMenuAction1: handleMenuAction1, + }), + ); + + expect(screen.getByText("Content")).toBeInTheDocument(); + expect(screen.getByRole("main")).toBeInTheDocument(); + expect(screen.getByRole("banner")).toBeInTheDocument(); + expect( + screen.getByRole("navigation", { name: "Main" }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Nav Link 1" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "Action 3" })).toBeInTheDocument(); + + await userClick( + user, + screen.getByRole("button", { name: "Menu Action 1" }), + ); + await userClick( + user, + screen.getByRole("menuitem", { name: "Menu Action 1:1" }), + ); + + expect(handleMenuAction1).toBeCalled(); + }); + + it("should render collapsed state", async () => { + render( + createForgeLayout({ + navState: "collapsed", + selectedHref: "/1", + }), + ); + expect( + screen.queryByRole("navigation", { name: "Main" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Controls when expanded"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Controls when collapsed")).toBeInTheDocument(); + }); + + it("should render test mode", async () => { + render( + createForgeLayout({ + mode: "test", + selectedHref: "/1", + }), + ); + expect(screen.getByTestId("ForgeLayout")).toHaveAttribute( + "class", + expect.stringContaining("modeTest"), + ); + }); +}); + +function createForgeLayout( + props: { + mode?: Mode; + navState?: NavState; + content?: ReactNode; + selectedHref?: string; + onMenuAction1?: () => void; + onMenuAction2?: () => void; + } = {}, +) { + const { + mode = "production", + navState = "expanded", + content =
Content
, + selectedHref = "/1", + onMenuAction1 = vi.fn(), + onMenuAction2 = vi.fn(), + } = props; + return ( + + + + Nav Link 1 + + Nav Section Title}> + + Nav Link 2 + + + Nav Link 3 + + + + + +
Controls when collapsed
+
+ +
Controls when expanded
+
+ + } + > + + Menu Action 1:1 + Menu Action 1:2 + + + + + Menu Action 2:1 + Menu Action 2:2 + + + + +
+ {content} +
+ ); +} + +function Icon() { + return ( + + ); +} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.tsx new file mode 100644 index 00000000..78631b26 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.tsx @@ -0,0 +1,273 @@ +import React, { ReactNode, useContext, useMemo } from "react"; +import { classNames, variationName } from "../utilities/css"; +import { + ForgeLayoutActionBadge, + ForgeLayoutActions, + ForgeLayoutLinkAction, + ForgeLayoutMenuAction, +} from "./ForgeLayoutActions"; +import { + ForgeLayoutNav, + ForgeLayoutNavLink, + ForgeLayoutNavSection, + useForgeLayoutNav, +} from "./ForgeLayoutNav"; + +import styles from "./ForgeLayout.module.scss"; + +export type Mode = "test" | "production"; + +export type NavState = "expanded" | "collapsed"; + +export type ForgeLayoutProps = { + /** Layout children. */ + children: ReactNode; + + /** + * Provides obvious visual indicator for non-production modes. + * + * @default production + */ + mode?: Mode; + + /** + * Display state of the nav menu. + * + * @default expanded + */ + navState?: NavState; + + /** + * Background decoration for layout. + * + * @default 01 + */ + backgroundDecoration?: "01"; +}; + +export type ForgeLayoutHeaderProps = { + /** Header children. */ + children: ReactNode; +}; + +export type ForgeLayoutContentProps = { + /** Content children. */ + children: ReactNode; +}; + +export type ForgeLayoutControlsProps = { + /** Controls children. */ + children: ReactNode; + + /** + * Display state of the nav menu for when these controls show. + * + * @default expanded + */ + visibleWhenNavStateIs?: NavState; +}; + +export type ForgeLayoutContextType = { + mode?: Mode; + navState?: NavState; +}; + +const ForgeLayoutContext = React.createContext( + null, +); + +export const useForgeLayout = () => { + const context = useContext(ForgeLayoutContext); + if (!context) { + throw new Error("useForgeLayout must be used within a ForgeLayout"); + } + return context; +}; + +/** + * `ForgeLayout` defines the header, nav, and main content areas of a Forge product page. + * + * @example + * ```tsx + * + * + * + * Item 1 + * + * Title}> + * + * Item 2 + * + * + * Item 3 + * + * + * Title}> + * + * Item 4 + * + * + * Item 5 + * + * + * + * + * + * + * {}}> + * Back + * + * + * Breadcrumb + * Breadcrumb + * + * + * + * + * + * {}} /> + * + * + * } + * > + * + * Action 1:1 + * Action 1:2 + * + * + * + * + * Action 2:1 + * Action 2:2 + * + * + * + * + * + * Page Content + * + * ``` + */ +export function ForgeLayout(props: ForgeLayoutProps) { + const { + backgroundDecoration = "01", + mode = "production", + navState = "expanded", + children, + } = props; + const className = classNames( + styles.ForgeLayout, + styles[variationName("backgroundDecoration", backgroundDecoration)], + styles[variationName("mode", mode)], + styles[variationName("navState", navState)], + ); + const context = useMemo(() => { + return { mode, navState }; + }, [mode, navState]); + return ( + +
+ {children} +
+
+
+
+
+ ); +} + +function ForgeLayoutHeader(props: ForgeLayoutHeaderProps) { + const { children } = props; + return
{children}
; +} + +function ForgeLayoutControls(props: ForgeLayoutControlsProps) { + const { navState } = useForgeLayout(); + const { children, visibleWhenNavStateIs = "expanded" } = props; + + if (navState !== visibleWhenNavStateIs) { + return null; + } + + return
{children}
; +} + +function ForgeLayoutBody(props: ForgeLayoutContentProps) { + const { children } = props; + return
{children}
; +} + +function ForgeLayoutContent(props: ForgeLayoutContentProps) { + const { children } = props; + return
{children}
; +} + +/** + * Represents the primary nav of a ``. + */ +ForgeLayout.Nav = ForgeLayoutNav; + +/** + * Represents a section in the primary nav of a ``. + */ +ForgeLayout.NavSection = ForgeLayoutNavSection; + +/** + * Represents a primary nav link of a ``. + */ +ForgeLayout.NavLink = ForgeLayoutNavLink; + +/** + * Represents a body that holds the header and main content in a ``. + */ +ForgeLayout.Body = ForgeLayoutBody; + +/** + * Represents the header of a ``. + */ +ForgeLayout.Header = ForgeLayoutHeader; + +/** + * Represents the controls of a ``. + */ +ForgeLayout.Controls = ForgeLayoutControls; + +/** + * Represents the secondary actions of a ``. + */ +ForgeLayout.Actions = ForgeLayoutActions; + +/** + * Represents an action badge in a ``. + */ +ForgeLayout.ActionBadge = ForgeLayoutActionBadge; + +/** + * Represents a secondary menu action of a ``. + */ +ForgeLayout.MenuAction = ForgeLayoutMenuAction; + +/** + * Represents a secondary link action of a ``. + */ +ForgeLayout.LinkAction = ForgeLayoutLinkAction; + +/** + * Represents the main content of a ``. + */ +ForgeLayout.Content = ForgeLayoutContent; + +/** + * Helper hook for retrieving nav state. Useful for custom nav links. + */ +ForgeLayout.useForgeLayoutNav = useForgeLayoutNav; diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.module.scss new file mode 100644 index 00000000..2b3113d8 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.module.scss @@ -0,0 +1,51 @@ +@use "../styles/common" as *; +@use "../styles/unstyled"; + +.button { + @include unstyled.link; + @include unstyled.button; + cursor: pointer; + + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + + border-radius: design-token("shape.border_radius.lg"); + padding: design-token("space.0.5"); + margin: calc(#{design-token("space.0.5")} * -1); + outline: none; +} + +.focused { + @include native-focus-ring; +} + +.hovered { + background-color: design-token("color.neutral.050"); +} + +.selected, +.open { + background-color: design-token("color.primary.700"); + color: design-token("color.neutral.000"); +} + +.badgeContainer { + position: absolute; + right: design-token("space.0.5"); + bottom: design-token("space.0.5"); + display: inline-flex; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + + background-color: design-token("color.positive.500"); + border-radius: 100%; + color: design-token("color.primary.800"); + height: design-token("space.1.5"); + width: design-token("space.1.5"); +} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.tsx new file mode 100644 index 00000000..6b1c21a1 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutActions.tsx @@ -0,0 +1,165 @@ +import React, { + ComponentProps, + forwardRef, + ReactNode, + useCallback, + useRef, + useState, +} from "react"; +import { + AriaLinkOptions, + mergeProps, + PressHookProps, + useFocusRing, + useHover, + useLink, + usePress, +} from "react-aria"; +import { HorizontalStack } from "../HorizontalStack"; +import { Icon } from "../Icon"; +import { Menu } from "../Menu"; +import { Text } from "../Text"; +import { IconSymbol } from "../types"; +import { classNames } from "../utilities/css"; + +import styles from "./ForgeLayoutActions.module.scss"; + +export type ForgeLayoutActionsProps = { + /** Actions children. */ + children: ReactNode; +}; + +export type ForgeLayoutActionBadgeProps = { + /** Badge children. */ + children?: ReactNode; +}; + +export type ForgeLayoutMenuActionProps = { + /** Optional custom accessibility label describing the menu action. */ + accessibilityLabel?: string; + + /** Icon symbol for the action. */ + iconSymbol: IconSymbol; + + /** Badge for the action. */ + renderBadge?: () => ReactNode; + + /** Render the menu overlay. */ + children: ReactNode; +}; + +export type ForgeLayoutLinkActionProps = { + /** Optional custom accessibility label describing the menu action. */ + accessibilityLabel?: string; + + /** Action link icon symbol. */ + iconSymbol: IconSymbol; + + /** Whether or not action link is selected. */ + isSelected?: boolean; + + /** Badge for the action. */ + renderBadge?: () => ReactNode; +} & AriaLinkOptions; + +export function ForgeLayoutActions(props: ForgeLayoutActionsProps) { + const { children } = props; + return ( + + {children} + + ); +} + +export function ForgeLayoutMenuAction(props: ForgeLayoutMenuActionProps) { + const { + accessibilityLabel = "Actions", + iconSymbol, + children, + renderBadge, + } = props; + const [isOpen, setIsOpen] = useState(false); + const handleOpenChange = useCallback((isOpen: boolean) => { + setIsOpen(isOpen); + }, []); + const { focusProps, isFocusVisible } = useFocusRing({}); + const { hoverProps, isHovered } = useHover({}); + const className = classNames( + styles.button, + isFocusVisible && styles.focused, + isHovered && styles.hovered, + isOpen && styles.open, + ); + return ( + + + + {accessibilityLabel} + + {renderBadge && ( +
{renderBadge()}
+ )} +
+
+ {children} +
+ ); +} + +export function ForgeLayoutLinkAction(props: ForgeLayoutLinkActionProps) { + const { + accessibilityLabel = "Actions", + iconSymbol, + renderBadge, + isSelected, + } = props; + const ref = useRef(null); + const { linkProps } = useLink(props, ref); + const { focusProps, isFocusVisible } = useFocusRing(props); + const { hoverProps, isHovered } = useHover(props); + const className = classNames( + styles.button, + isFocusVisible && styles.focused, + isHovered && styles.hovered, + isSelected && styles.selected, + ); + return ( +
+ {accessibilityLabel} + + {renderBadge && ( +
{renderBadge()}
+ )} +
+ ); +} + +export function ForgeLayoutActionBadge(props: ForgeLayoutActionBadgeProps) { + const { children } = props; + return
{children}
; +} + +/** TODO: Figure out how to work with UnstyledButton instead */ +export const PressableButton = forwardRef< + HTMLButtonElement, + ComponentProps<"button"> & PressHookProps +>((props, ref) => { + const { pressProps } = usePress(props); + return ( +