From 462b0a4e32372cd3892912a0e09c343c5354d04b Mon Sep 17 00:00:00 2001 From: Shelvin Date: Mon, 23 Dec 2024 15:11:47 -0800 Subject: [PATCH] =?UTF-8?q?feat(ForgeLayout):=20add=20modeswitcher,=20brea?= =?UTF-8?q?dcrumb=20navigation,=20and=20=E2=80=A6=20(#1550)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Changes - Adds control components for ForgeLayout [x] `ForgeLayout.ModeSwitcher` [x] `ForgeLayout.BreadcrumbsNavigation` [x] `ForgeLayout.BackButton` [x] `ForgeLayout.Breadcrumbs` [x] `ForgeLayout.Breadcrumb` ## ✅ 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/unlucky-seas-allow.md | 5 + .../src/ForgeLayout/ForgeLayout.module.scss | 34 --- .../src/ForgeLayout/ForgeLayout.stories.tsx | 17 +- .../src/ForgeLayout/ForgeLayout.test.tsx | 50 +++- easy-ui-react/src/ForgeLayout/ForgeLayout.tsx | 90 +++--- .../ForgeLayoutControls.module.scss | 75 +++++ .../src/ForgeLayout/ForgeLayoutControls.tsx | 259 ++++++++++++++++++ .../ForgeLayout/ForgeLayoutHeader.module.scss | 29 ++ .../src/ForgeLayout/ForgeLayoutHeader.tsx | 46 ++++ 9 files changed, 518 insertions(+), 87 deletions(-) create mode 100644 .changeset/unlucky-seas-allow.md create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutControls.module.scss create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutControls.tsx create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.module.scss create mode 100644 easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.tsx diff --git a/.changeset/unlucky-seas-allow.md b/.changeset/unlucky-seas-allow.md new file mode 100644 index 00000000..b108f198 --- /dev/null +++ b/.changeset/unlucky-seas-allow.md @@ -0,0 +1,5 @@ +--- +"@easypost/easy-ui": minor +--- + +feat(ForgeLayout): add modeswitcher, breadcrumb navigation, and search diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss index 544eb8cd..e3b719c1 100644 --- a/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.module.scss @@ -61,41 +61,7 @@ flex-direction: column; } -.header { - position: sticky; - top: 0; - height: component-token("forge-layout", "header-height"); - - display: flex; - flex-wrap: nowrap; - align-items: center; - justify-content: space-between; - min-width: 0; - - border-bottom: 2px solid transparent; - z-index: design-token("z-index.nav"); -} - -.headerBg { - position: absolute; - top: 0; - left: -100vw; - width: 300vw; - height: component-token("forge-layout", "header-height"); - - background-color: design-token("color.neutral.025"); - border-bottom: component-token("forge-layout", "header-border-width") solid - component-token("forge-layout", "header-border-color"); -} - .content { padding-top: component-token("forge-layout", "shell-gutter"); padding-bottom: component-token("forge-layout", "shell-gutter"); } - -.controls { - position: relative; - display: flex; - align-items: center; - gap: design-token("space.2"); -} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx index 232eb171..747cdc33 100644 --- a/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.stories.tsx @@ -67,10 +67,21 @@ const Template = (args: Partial) => { -
Controls when collapsed
+ + + Back + + + Sub Account + + Sub Account Name + + +
-
Controls when expanded
+ +
) => {
-
Page Content
+
Page Content
diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx index a7eda77d..439ebd5a 100644 --- a/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx +++ b/easy-ui-react/src/ForgeLayout/ForgeLayout.test.tsx @@ -28,11 +28,13 @@ describe("", () => { it("should render a forge layout", async () => { const handleMenuAction1 = vi.fn(); + const handleModeChange = vi.fn(); const { user } = render( createForgeLayout({ selectedHref: "/1", onMenuAction1: handleMenuAction1, + onModeChange: handleModeChange, }), ); @@ -58,22 +60,44 @@ describe("", () => { ); expect(handleMenuAction1).toBeCalled(); + + expect( + screen.getByRole("button", { name: "Production" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("searchbox", { name: "Search for content" }), + ).toBeInTheDocument(); + + await userClick(user, screen.getByRole("button", { name: "Production" })); + const radios = screen.getAllByRole("radio"); + expect(radios[0]).not.toBeChecked(); + expect(radios[1]).toBeChecked(); + await userClick(user, radios[0]); + + expect(handleModeChange).toBeCalled(); }); it("should render collapsed state", async () => { - render( + const handleBackButton = vi.fn(); + const { user } = render( createForgeLayout({ navState: "collapsed", selectedHref: "/1", + onBackButton: handleBackButton, }), ); + expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument(); + expect(screen.queryByText("Breadcrumb One")).toBeInTheDocument(); + expect(screen.queryByText("Breadcrumb Two")).toBeInTheDocument(); + expect(screen.queryByText("Breadcrumb Three")).toBeInTheDocument(); expect( - screen.queryByRole("navigation", { name: "Main" }), + screen.queryByRole("button", { name: "Production" }), ).not.toBeInTheDocument(); expect( - screen.queryByText("Controls when expanded"), + screen.queryByRole("searchbox", { name: "Search for content" }), ).not.toBeInTheDocument(); - expect(screen.queryByText("Controls when collapsed")).toBeInTheDocument(); + await userClick(user, screen.getByRole("button", { name: "Back" })); + expect(handleBackButton).toBeCalled(); }); it("should render test mode", async () => { @@ -98,6 +122,8 @@ function createForgeLayout( selectedHref?: string; onMenuAction1?: () => void; onMenuAction2?: () => void; + onBackButton?: () => void; + onModeChange?: () => void; } = {}, ) { const { @@ -107,6 +133,8 @@ function createForgeLayout( selectedHref = "/1", onMenuAction1 = vi.fn(), onMenuAction2 = vi.fn(), + onBackButton = vi.fn(), + onModeChange = vi.fn(), } = props; return ( @@ -125,10 +153,20 @@ function createForgeLayout( -
Controls when collapsed
+ + + Back + + + Breadcrumb One + Breadcrumb Two + Breadcrumb Three + +
-
Controls when expanded
+ +
{ * * * - * + * * {}}> * Back * - * - * Breadcrumb - * Breadcrumb - * - * + * + * Breadcrumb + * Breadcrumb + * + * * * * @@ -188,27 +181,6 @@ export function ForgeLayout(props: ForgeLayoutProps) { ); } -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}
; @@ -249,6 +221,36 @@ ForgeLayout.Header = ForgeLayoutHeader; */ ForgeLayout.Controls = ForgeLayoutControls; +/** + * Represents the breadcrumbs and navigation in a ``. + */ +ForgeLayout.BreadcrumbsNavigation = ForgeLayoutBreadcrumbsNavigation; + +/** + * Represents a navigation back button in a ``. + */ +ForgeLayout.BackButton = ForgeLayoutBackButton; + +/** + * Represents breadcrumbs in a ``. + */ +ForgeLayout.Breadcrumbs = ForgeLayoutBreadcrumbs; + +/** + * Represents a breadcrumb in a ``. + */ +ForgeLayout.Breadcrumb = ForgeLayoutBreadcrumb; + +/** + * Represents a mode switcher in a ``. + */ +ForgeLayout.ModeSwitcher = ForgeLayoutModeSwitcher; + +/** + * Represents a search input in a ``. + */ +ForgeLayout.Search = ForgeLayoutSearch; + /** * Represents the secondary actions of a ``. */ diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutControls.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayoutControls.module.scss new file mode 100644 index 00000000..108db44e --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutControls.module.scss @@ -0,0 +1,75 @@ +@use "../styles/common" as *; +@use "../styles/unstyled"; + +.controls { + display: flex; + align-items: center; + gap: design-token("space.2"); + width: 100%; +} + +.breadcrumbNavigationContainer { + border: design-token("shape.border_width.1") solid + theme-token("color.neutral.300"); + margin-right: design-token("space.3"); + display: inline-flex; + z-index: design-token("z-index.nav"); +} + +.backButtonContainer { + background: theme-token("color.neutral.050"); + border-right: design-token("shape.border_width.1") solid + theme-token("color.neutral.300"); + padding: design-token("space.0-5") design-token("space.1.5") + design-token("space.0-5") design-token("space.1"); + display: inline-flex; + align-items: center; +} + +.backButton { + display: inline-flex; + align-items: center; + cursor: pointer; + color: theme-token("color.primary.600"); +} + +.breadcrumbsContainer { + padding: design-token("space.0-5") design-token("space.1"); +} + +.trigger { + @include unstyled.button; + display: flex; + align-items: center; + justify-content: space-between; + gap: design-token("space.1"); + border: design-token("shape.border_width.1") solid + theme-token("color.neutral.300"); + padding: calc( + #{design-token("space.1")} - #{design-token("shape.border_width.1")} + ); + border-radius: design-token("shape.border_radius.md"); + cursor: pointer; + width: 100%; + max-width: 133px; + z-index: design-token("z-index.nav"); +} + +.triggerPopoverOpen { + border-color: theme-token("color.neutral.800"); +} + +.popover { + border: design-token("shape.border_width.1") solid + theme-token("color.neutral.300"); + border-radius: design-token("shape.border_radius.md"); + background-color: theme-token("color.neutral.000"); + padding: design-token("space.2"); + box-shadow: design-token("shadow.overlay"); +} + +.searchContainer { + width: 100%; + max-width: 715px; + margin-right: design-token("space.3"); +} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutControls.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayoutControls.tsx new file mode 100644 index 00000000..f527a6b6 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutControls.tsx @@ -0,0 +1,259 @@ +import React, { ReactNode, useMemo, useState, Fragment } from "react"; +import { Button, Dialog, DialogTrigger, Popover } from "react-aria-components"; +import ArrowBackIcon from "@easypost/easy-ui-icons/ArrowBack"; +import SearchIcon from "@easypost/easy-ui-icons/Search"; +import ExpandMoreIcon400 from "@easypost/easy-ui-icons/ExpandMore400"; +import { useForgeLayout } from "./ForgeLayout"; +import type { NavState } from "./ForgeLayout"; +import { useForgeLayoutHeader } from "./ForgeLayoutHeader"; +import { UnstyledButton } from "../UnstyledButton"; +import type { UnstyledButtonProps } from "../UnstyledButton"; +import { Text } from "../Text"; +import { Icon } from "../Icon"; +import { + RadioGroup, + RadioGroupItemProps, + useRadioGroupContext, +} from "../RadioGroup"; +import type { TextFieldProps } from "../TextField"; +import { TextField } from "../TextField"; +import { HorizontalStack } from "../HorizontalStack"; +import { VerticalStack } from "../VerticalStack"; +import { flattenChildren } from "../utilities/react"; +import { classNames } from "../utilities/css"; + +import styles from "./ForgeLayoutControls.module.scss"; + +const TEST_MODE = "Test"; +const PRODUCTION_MODE = "Production"; + +const POPOVER_CROSS_OFFSET = 116; +const POPOVER_OFFSET = 2; + +export type ForgeLayoutControlsProps = { + /** Controls children. */ + children: ReactNode; + + /** + * Display state of the nav menu for when these controls show. + * + * @default expanded + */ + visibleWhenNavStateIs?: NavState; +}; + +export function ForgeLayoutControls(props: ForgeLayoutControlsProps) { + const { navState } = useForgeLayout(); + const { areControlsGrouped } = useForgeLayoutHeader(); + const { children, visibleWhenNavStateIs = "expanded" } = props; + + if (navState !== visibleWhenNavStateIs) { + return null; + } + + return ( + <> + {areControlsGrouped ? ( +
{children}
+ ) : ( + <>{children} + )} + + ); +} + +export type ForgeLayoutBreadcrumbsNavigationProps = { + /** Renders ForgeLayout.BackButton and ForgeLayout.Breadcrumbs */ + children?: ReactNode; +}; + +export function ForgeLayoutBreadcrumbsNavigation( + props: ForgeLayoutBreadcrumbsNavigationProps, +) { + const { children } = props; + const { backButton, breadcrumbs } = useMemo(() => { + const breadcrumbNavigationChildren = flattenChildren(children); + const backButton = + breadcrumbNavigationChildren.length > 0 + ? breadcrumbNavigationChildren[0] + : null; + + const breadcrumbs = + breadcrumbNavigationChildren.length > 1 + ? breadcrumbNavigationChildren[1] + : null; + + if (!backButton || !breadcrumbs) { + throw new Error( + "ForgeLayout.BreadcrumbNavigation must contain ForgeLayout.BackButton and ForgeLayout.Breadcrumbs", + ); + } + return { + backButton, + breadcrumbs, + }; + }, [children]); + + return ( +
+
{backButton}
+
{breadcrumbs}
+
+ ); +} + +export function ForgeLayoutBackButton(props: UnstyledButtonProps) { + const { children, ...restButtonProps } = props; + + return ( + + + {children} + + ); +} + +export type ForgeLayoutBreadcrumbsProps = { + /** Renders breadcrumbs */ + children?: ReactNode; +}; + +export function ForgeLayoutBreadcrumbs(props: ForgeLayoutBreadcrumbsProps) { + const { children } = props; + + const breadcrumbs = useMemo(() => flattenChildren(children), [children]); + + return ( + + {breadcrumbs.map((breadcrumb, idx) => { + if (idx === 0) { + return {breadcrumb}; + } else { + return ( + + {">"} + {breadcrumb} + + ); + } + })} + + ); +} + +export type ForgeLayoutBreadcrumbProps = { + /** Breadcrum content */ + children?: ReactNode; +}; + +export function ForgeLayoutBreadcrumb(props: ForgeLayoutBreadcrumbProps) { + const { children } = props; + + return ( + + {children} + + ); +} + +export type ForgeLayoutModeSwitcherProps = { + /** Mode change callback function */ + onModeChange?: (value: string) => void; +}; + +export function ForgeLayoutModeSwitcher(props: ForgeLayoutModeSwitcherProps) { + const { onModeChange } = props; + const { mode } = useForgeLayout(); + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + + Instance Switcher + + + + + + + + + + ); +} + +function ForgeLayoutModeSwitcherRadioGroupItem( + props: Omit, +) { + const { value, ...restProps } = props; + const state = useRadioGroupContext(); + const isSelected = value === state.selectedValue; + return ( + + + + {value === "test" ? TEST_MODE : PRODUCTION_MODE} + + + View data using the{" "} + + {value === "test" + ? TEST_MODE.toUpperCase() + : PRODUCTION_MODE.toUpperCase()}{" "} + API{" "} + + keys + + + + ); +} + +export function ForgeLayoutSearch(props: TextFieldProps) { + const { "aria-label": ariaLabel = "Search for content", ...textFieldProps } = + props; + return ( +
+ +
+ ); +} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.module.scss b/easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.module.scss new file mode 100644 index 00000000..9fc83513 --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.module.scss @@ -0,0 +1,29 @@ +@use "../styles/common" as *; + +.header { + position: sticky; + top: 0; + height: component-token("forge-layout", "header-height"); + + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + min-width: 0; + + border-bottom: 2px solid transparent; + z-index: design-token("z-index.nav"); + gap: design-token("space.1"); +} + +.headerBg { + position: absolute; + top: 0; + left: -100vw; + width: 300vw; + height: component-token("forge-layout", "header-height"); + + background-color: design-token("color.neutral.025"); + border-bottom: component-token("forge-layout", "header-border-width") solid + component-token("forge-layout", "header-border-color"); +} diff --git a/easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.tsx b/easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.tsx new file mode 100644 index 00000000..9cb81d7b --- /dev/null +++ b/easy-ui-react/src/ForgeLayout/ForgeLayoutHeader.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useContext, ReactNode } from "react"; + +import styles from "./ForgeLayoutHeader.module.scss"; + +export type ForgeLayoutHeaderProps = { + /** + * Whether ForgeLayout.Controls are grouped together or + * spaced evenly with ForgeLayout.Actions + * + * @default true + */ + areControlsGrouped?: boolean; + + /** Header children. */ + children: ReactNode; +}; + +export type ForgeLayoutHeaderContextType = { + areControlsGrouped?: boolean; +}; + +const ForgeLayoutHeaderContext = + createContext(null); + +export const useForgeLayoutHeader = () => { + const context = useContext(ForgeLayoutHeaderContext); + if (!context) { + throw new Error( + "useForgeLayoutHeader must be used within a ForgeLayoutHeader", + ); + } + return context; +}; + +export function ForgeLayoutHeader(props: ForgeLayoutHeaderProps) { + const { areControlsGrouped = true, children } = props; + + return ( + +
+
+ {children} +
+
+ ); +}