From 2723896dfffdd6ff6d57f0476f22706090053836 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Wed, 9 Sep 2026 12:43:20 +0800 Subject: [PATCH 01/22] feat(nav): collapsible sidebar nav --- packages/styles/dist/gitlab-ui.css | 89 ++++ packages/ui/src/base/nav/nav.css | 123 +++++ packages/ui/src/base/nav/nav.stories.tsx | 235 ++++++++- packages/ui/src/base/nav/nav.test.tsx | 146 +++++- packages/ui/src/base/nav/nav.tsx | 626 +++++++++++++++++++++-- packages/ui/src/index.ts | 6 + 6 files changed, 1174 insertions(+), 51 deletions(-) diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index 9da3db2..6bef815 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -7132,6 +7132,95 @@ input[type="color"].gl-form-input.form-control:disabled { .gl-sub-nav-button.gl-nav-item-has-start-slot { padding-left: var(--gl-spacing-scale-7); } +.gl-collapsible-nav { + --gl-collapsible-nav-expanded-width: 15rem; + --gl-collapsible-nav-collapsed-width: 3.5rem; + flex-shrink: 0; + width: var(--gl-collapsible-nav-expanded-width); + transition: width 250ms ease-out, transform 250ms ease-out, visibility 0s; +} +.gl-collapsible-nav .gl-nav-item-label, .gl-collapsible-nav .gl-nav-item-addon, .gl-collapsible-nav .gl-nav-item-chevron { + transition: opacity 150ms ease-out, transform 150ms ease-out, max-width 150ms ease-out; +} +.gl-collapsible-nav .gl-nav-item-label { + min-width: 0; + max-width: calc(var(--gl-collapsible-nav-expanded-width) - 4rem); + overflow: hidden; + white-space: nowrap; +} +.gl-collapsible-nav .gl-nav-item-addon, .gl-collapsible-nav .gl-nav-item-chevron { + max-width: 1.5rem; +} +.gl-collapsible-nav-backdrop { + position: fixed; + inset: 0; + z-index: 1029; + background-color: rgb(0 0 0 / 0.5); + opacity: 0; + pointer-events: none; + transition: opacity 250ms ease-out; +} +.gl-collapsible-nav-backdrop[data-open="true"] { + opacity: 1; + pointer-events: auto; +} +.gl-collapsible-nav-flyout { + min-width: 12rem; +} +.gl-collapsible-nav-flyout .gl-sub-nav { + margin-top: var(--gl-spacing-scale-0); +} +@media (min-width: 1200px) { + .gl-collapsible-nav[data-open="false"] { + width: var(--gl-collapsible-nav-collapsed-width); + } + .gl-collapsible-nav[data-open="false"] .gl-nav-item-is-icon-only { + justify-content: center; + gap: var(--gl-spacing-scale-0); + } + .gl-collapsible-nav[data-open="false"] .gl-nav-item-label, .gl-collapsible-nav[data-open="false"] .gl-nav-item-addon, .gl-collapsible-nav[data-open="false"] .gl-nav-item-chevron { + flex-grow: 0; + max-width: 0; + overflow: hidden; + opacity: 0; + transform: translateX(-0.25rem); + } + .gl-collapsible-nav[data-open="false"] .gl-nav-item-addon, .gl-collapsible-nav[data-open="false"] .gl-nav-item-chevron { + width: 0; + } +} +@media (max-width: 1199px) { + .gl-collapsible-nav { + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: 1030; + width: var(--gl-collapsible-nav-expanded-width); + max-width: calc(100vw - 3rem); + overflow-y: auto; + background-color: var(--gl-background-color-default); + box-shadow: var(--gl-shadow-lg); + } + .gl-collapsible-nav[data-open="false"] { + visibility: hidden; + pointer-events: none; + transform: translateX(-100%); + transition-delay: 0s, 0s, 250ms; + } + .gl-collapsible-nav[data-open="true"] { + visibility: visible; + transform: translateX(0); + transition-delay: 0s; + } +} +@media (prefers-reduced-motion: reduce) { + .gl-collapsible-nav, .gl-collapsible-nav .gl-nav-item-label, .gl-collapsible-nav .gl-nav-item-addon, .gl-collapsible-nav .gl-nav-item-chevron, .gl-collapsible-nav-backdrop { + transition-duration: 0s; + } + .gl-collapsible-nav[data-open="false"], .gl-collapsible-nav[data-open="true"] { + transition-delay: 0s; + } +} .gl-pagination { font-size: var(--gl-font-size-base); text-wrap: nowrap; diff --git a/packages/ui/src/base/nav/nav.css b/packages/ui/src/base/nav/nav.css index 04d2903..3bb3f08 100644 --- a/packages/ui/src/base/nav/nav.css +++ b/packages/ui/src/base/nav/nav.css @@ -169,3 +169,126 @@ @apply gl-pl-7; } } + +/* + * GlCollapsibleNav is a React-only convenience API inspired by GitLab's + * Super Sidebar. Its state and responsive shell intentionally live here + * instead of being presented as an upstream Pajamas component. + */ +.gl-collapsible-nav { + --gl-collapsible-nav-expanded-width: 15rem; + --gl-collapsible-nav-collapsed-width: 3.5rem; + + @apply gl-shrink-0; + width: var(--gl-collapsible-nav-expanded-width); + transition: width 250ms ease-out, transform 250ms ease-out, visibility 0s; + + .gl-nav-item-label, + .gl-nav-item-addon, + .gl-nav-item-chevron { + transition: opacity 150ms ease-out, transform 150ms ease-out, max-width 150ms ease-out; + } + + .gl-nav-item-label { + min-width: 0; + max-width: calc(var(--gl-collapsible-nav-expanded-width) - 4rem); + overflow: hidden; + white-space: nowrap; + } + + .gl-nav-item-addon, + .gl-nav-item-chevron { + max-width: 1.5rem; + } +} + +.gl-collapsible-nav-backdrop { + position: fixed; + inset: 0; + z-index: 1029; + background-color: rgb(0 0 0 / 0.5); + opacity: 0; + pointer-events: none; + transition: opacity 250ms ease-out; + + &[data-open="true"] { + opacity: 1; + pointer-events: auto; + } +} + +.gl-collapsible-nav-flyout { + min-width: 12rem; + + .gl-sub-nav { + @apply gl-mt-0; + } +} + +@media (min-width: 1200px) { + .gl-collapsible-nav[data-open="false"] { + width: var(--gl-collapsible-nav-collapsed-width); + + .gl-nav-item-is-icon-only { + @apply gl-justify-center; + @apply gl-gap-0; + } + + .gl-nav-item-label, + .gl-nav-item-addon, + .gl-nav-item-chevron { + @apply gl-grow-0; + max-width: 0; + overflow: hidden; + opacity: 0; + transform: translateX(-0.25rem); + } + + .gl-nav-item-addon, + .gl-nav-item-chevron { + width: 0; + } + } +} + +@media (max-width: 1199px) { + .gl-collapsible-nav { + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: 1030; + width: var(--gl-collapsible-nav-expanded-width); + max-width: calc(100vw - 3rem); + overflow-y: auto; + background-color: var(--gl-background-color-default); + box-shadow: var(--gl-shadow-lg); + + &[data-open="false"] { + visibility: hidden; + pointer-events: none; + transform: translateX(-100%); + transition-delay: 0s, 0s, 250ms; + } + + &[data-open="true"] { + visibility: visible; + transform: translateX(0); + transition-delay: 0s; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .gl-collapsible-nav, + .gl-collapsible-nav .gl-nav-item-label, + .gl-collapsible-nav .gl-nav-item-addon, + .gl-collapsible-nav .gl-nav-item-chevron, + .gl-collapsible-nav-backdrop { + transition-duration: 0s; + } + + .gl-collapsible-nav[data-open="false"], + .gl-collapsible-nav[data-open="true"] { + transition-delay: 0s; + } +} diff --git a/packages/ui/src/base/nav/nav.stories.tsx b/packages/ui/src/base/nav/nav.stories.tsx index 63bd5f6..96b1214 100644 --- a/packages/ui/src/base/nav/nav.stories.tsx +++ b/packages/ui/src/base/nav/nav.stories.tsx @@ -1,13 +1,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Fragment, forwardRef, useState, type ComponentPropsWithoutRef } from "react"; -import { expect, fn, userEvent } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import GlAvatar from "../avatar/avatar"; import GlButton from "../button/button"; import GlIcon from "../icon/icon"; import GlNav, { + GlCollapsibleNav, + GlCollapsibleNavToggle, GlNavButton, GlNavItem, GlNavItemAddon, + GlNavProvider, GlSubNav, GlSubNavButton, GlSubNavItem, @@ -443,3 +446,233 @@ export const IconOnly: Story = { await expect(canvas.queryByText("12")).not.toBeInTheDocument(); }, }; + +function CollapsibleNavItems({ + internalToggle = false, + withSubNav = false, +}: { + internalToggle?: boolean; + withSubNav?: boolean; +}) { + return ( + <> + + + + Issues + 12 + + + {withSubNav ? ( + + + + Manage + + + + Members + + + Integrations + + + + ) : null} + + + + Repository + + + {internalToggle ? ( + + ) : null} + + ); +} + +export const ProviderRemoteControl: Story = { + render: () => ( + +
+ + + {CollapsibleNavItems({})} + +
+
+ ), + play: async ({ canvas }) => { + const toggle = canvas.getByRole("button", { name: "Collapse sidebar" }); + const nav = canvas.getByRole("navigation", { + name: "Remote-controlled project navigation", + }); + + await expect(toggle).toHaveAttribute("aria-controls", "remote-project-navigation"); + await expect(nav).toHaveAttribute("data-open", "true"); + await userEvent.click(toggle); + await expect(nav).toHaveAttribute("data-open", "false"); + await expect(canvas.getByRole("button", { name: "Expand sidebar" })) + .toHaveAttribute("aria-expanded", "false"); + const issues = canvas.getByRole("link", { name: "Issues" }); + await userEvent.hover(issues); + const tooltip = await within(document.body).findByRole("tooltip", { name: "Issues" }); + await waitFor(() => expect(tooltip).toBeVisible()); + }, +}; + +export const InternalToggle: Story = { + render: () => ( + + + {CollapsibleNavItems({ internalToggle: true })} + + + ), + play: async ({ canvas }) => { + const nav = canvas.getByRole("navigation", { name: "Navigation with internal toggle" }); + await userEvent.click(canvas.getByRole("button", { name: "Collapse sidebar" })); + await expect(nav).toHaveAttribute("data-open", "false"); + await expect(canvas.getByRole("button", { name: "Expand sidebar" })) + .toHaveClass("gl-nav-item-is-icon-only"); + }, +}; + +const preventedToggleClick = fn(); + +export const MultipleToggles: Story = { + render: () => ( + +
+ + + {CollapsibleNavItems({ internalToggle: true })} + + + { + preventedToggleClick(); + event.preventDefault(); + }} /> +
+
+ ), + play: async ({ canvas }) => { + preventedToggleClick.mockClear(); + const nav = canvas.getByRole("navigation", { name: "Navigation with multiple toggles" }); + await userEvent.click(canvas.getByRole("button", { name: "Blocked expand" })); + await expect(preventedToggleClick).toHaveBeenCalledOnce(); + await expect(nav).toHaveAttribute("data-open", "false"); + const toggles = canvas.getAllByRole("button", { name: "Expand sidebar" }); + await expect(toggles).toHaveLength(3); + await userEvent.click(toggles[0]); + await expect(canvas.getAllByRole("button", { name: "Collapse sidebar" })).toHaveLength(3); + }, +}; + +function ControlledCollapsibleNavExample() { + const [open, setOpen] = useState(false); + + return ( + +
+ + Sidebar is {open ? "expanded" : "collapsed"} + + {CollapsibleNavItems({ internalToggle: true })} + +
+
+ ); +} + +export const CollapsibleControlled: Story = { + name: "Collapsible / Controlled", + render: () => , + play: async ({ canvas }) => { + await expect(canvas.getByText("Sidebar is collapsed")).toBeInTheDocument(); + await userEvent.click(canvas.getAllByRole("button", { name: "Expand sidebar" })[0]); + await expect(canvas.getByText("Sidebar is expanded")).toBeInTheDocument(); + }, +}; + +export const SubNavFlyout: Story = { + render: () => ( + + + + {CollapsibleNavItems({ withSubNav: true })} + + + ), + play: async ({ canvas }) => { + const parent = canvas.getByRole("button", { name: "Manage" }); + parent.focus(); + await expect(within(document.body).queryByRole("link", { name: "Members" })) + .not.toBeInTheDocument(); + await userEvent.click(parent); + await expect(within(document.body).queryByRole("link", { name: "Members" })) + .not.toBeInTheDocument(); + await userEvent.hover(parent); + const members = await within(document.body).findByRole("link", { name: "Members" }); + await waitFor(() => expect(members).toBeVisible()); + await userEvent.unhover(parent); + parent.focus(); + await userEvent.keyboard("{Enter}"); + const integrations = await within(document.body).findByRole("link", { name: "Integrations" }); + await waitFor(() => expect(integrations).toBeVisible()); + integrations.focus(); + await userEvent.keyboard("{Escape}"); + await expect(parent).toHaveFocus(); + parent.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" })); + parent.click(); + const touchFlyout = await within(document.body).findByRole("link", { name: "Members" }); + await waitFor(() => expect(touchFlyout).toBeVisible()); + await userEvent.keyboard("{Escape}"); + await userEvent.click(canvas.getByRole("button", { name: "Expand sidebar" })); + await expect(canvas.getByRole("button", { name: "Manage" })) + .toHaveAttribute("aria-expanded", "true"); + await expect(canvas.getByRole("link", { name: "Members" })).toBeVisible(); + }, +}; + +export const MobileOverlay: Story = { + parameters: { viewport: { defaultViewport: "mobile1" } }, + render: () => ( + + + + {CollapsibleNavItems({ internalToggle: true })} + + + ), + play: async ({ canvas }) => { + const externalToggle = canvas.getByRole("button", { name: "Expand sidebar" }); + await userEvent.click(externalToggle); + const nav = canvas.getByRole("navigation", { name: "Mobile project navigation" }); + const firstLink = canvas.getByRole("link", { name: "Issues 12" }); + const internalToggle = within(nav).getByRole("button", { name: "Collapse sidebar" }); + const backdrop = within(document.body).getByTestId("collapsible-nav-backdrop"); + await expect(nav).toHaveAttribute("data-open", "true"); + await expect(backdrop).toHaveAttribute("data-open", "true"); + await expect(document.body.style.overflow).toBe("hidden"); + await waitFor(() => expect(firstLink).toHaveFocus()); + await userEvent.click(canvas.getByRole("button", { name: "Repository" })); + await expect(nav).toHaveAttribute("data-open", "true"); + firstLink.focus(); + await userEvent.keyboard("{Shift>}{Tab}{/Shift}"); + await expect(internalToggle).toHaveFocus(); + await userEvent.tab(); + await expect(firstLink).toHaveFocus(); + await userEvent.click(backdrop); + await expect(nav).toHaveAttribute("data-open", "false"); + await expect(externalToggle).toHaveFocus(); + await expect(document.body.style.overflow).not.toBe("hidden"); + await userEvent.click(externalToggle); + await userEvent.keyboard("{Escape}"); + await expect(externalToggle).toHaveFocus(); + }, +}; diff --git a/packages/ui/src/base/nav/nav.test.tsx b/packages/ui/src/base/nav/nav.test.tsx index 87ba64d..a0b6dfc 100644 --- a/packages/ui/src/base/nav/nav.test.tsx +++ b/packages/ui/src/base/nav/nav.test.tsx @@ -12,9 +12,12 @@ import { describe, expect, it, vi } from "vitest"; import GlAvatar from "../avatar/avatar"; import GlIcon from "../icon/icon"; import GlNav, { + GlCollapsibleNav, + GlCollapsibleNavToggle, GlNavButton, GlNavItem, GlNavItemAddon, + GlNavProvider, GlSubNav, GlSubNavButton, GlSubNavItem, @@ -283,7 +286,7 @@ describe("GlNavButton", () => { expect(markup).toMatch( /]*>[\s\S]*data-testid="nav-item-start"[\s\S]*data-testid="nav-item-label"[\s\S]*data-testid="nav-item-end"[\s\S]*<\/a>/u, ); - expect(markup).toContain("class=\"gl-nav-item-slot count\""); + expect(markup).toContain("class=\"gl-nav-item-slot gl-nav-item-addon count\""); expect(markup).toContain(">12"); }); @@ -412,6 +415,125 @@ describe("GlSubNav", () => { }); }); +describe("GlCollapsibleNav", () => { + it("shares the provider ID and state with external and internal toggles during SSR", () => { + const markup = renderToStaticMarkup( + + + + + Issues + + + + , + ); + + expect(markup).toMatch(/]*id="project-sidebar"[^>]*data-open="true"/u); + expect(markup.match(/aria-controls="project-sidebar"/gu)).toHaveLength(2); + expect(markup.match(/aria-expanded="true"/gu)).toHaveLength(2); + expect(markup).toContain("data-gl-collapsible-nav-toggle=\"external\""); + expect(markup).toContain("Collapse sidebar"); + expect(markup).toContain("collapse-left"); + }); + + it("lets controlled state take precedence over defaultOpen", () => { + const markup = renderToStaticMarkup( + + + + Issues + + , + ); + + expect(markup).toContain("data-open=\"false\""); + expect(markup).toContain("aria-expanded=\"false\""); + expect(markup).toContain("aria-label=\"Expand sidebar\""); + }); + + it("does not make a plain GlNav respond to its surrounding provider", () => { + const markup = renderToStaticMarkup( + + + Text-only plain item + + , + ); + + expect(markup).toContain("Text-only plain item"); + expect(markup).not.toContain("gl-collapsible-nav"); + expect(markup).not.toContain("gl-nav-item-is-icon-only"); + }); + + it("uses a generated stable provider ID when navId is omitted", () => { + const markup = renderToStaticMarkup( + + + + Issues + + , + ); + const controls = markup.match(/aria-controls="([^"]+)"/u)?.[1]; + const id = markup.match(/]*id="([^"]+)"/u)?.[1]; + + expect(controls).toBeTruthy(); + expect(id).toBe(controls); + }); + + it("forwards refs and applicable attributes to both toggle forms", () => { + const externalRef = createRef(); + const internalRef = createRef(); + const markup = renderToStaticMarkup( + + + + + + + + , + ); + + expect(markup).toContain("external"); + expect(markup).toContain("title=\"Remote\""); + expect(markup).toContain("internal"); + expect(markup).toContain("title=\"Inline\""); + expect(markup).toContain("disabled=\"\""); + }); + + it("requires a direct leading and an accessible label for complex content", () => { + expect(() => renderToStaticMarkup( + + + Issues + + , + )).toThrow("requires a direct leading GlIcon or GlAvatar"); + expect(() => renderToStaticMarkup( + + + + Issues + + + , + )).toThrow("requires aria-label when its label is not simple text"); + expect(() => renderToStaticMarkup( + + + + + Issues + + + + , + )).not.toThrow(); + }); +}); + describe("composition errors", () => { it("rejects non-item children of GlNav", () => { expect(() => renderToStaticMarkup(Wrong)) @@ -519,4 +641,26 @@ describe("composition errors", () => { , )).toThrow("requires exactly one GlSubNavButton"); }); + + it("enforces collapsible navigation provider and toggle boundaries", () => { + expect(() => renderToStaticMarkup()) + .toThrow("[GlCollapsibleNav] must be used within GlNavProvider"); + expect(() => renderToStaticMarkup()) + .toThrow("[GlCollapsibleNavToggle] must be used within GlNavProvider"); + expect(() => renderToStaticMarkup( + + + , + )).toThrow("GlCollapsibleNavToggle is only accepted by GlCollapsibleNav"); + expect(() => renderToStaticMarkup( + + + + + + + + , + )).toThrow("GlCollapsibleNavToggle cannot be used with GlSubNav"); + }); }); diff --git a/packages/ui/src/base/nav/nav.tsx b/packages/ui/src/base/nav/nav.tsx index 27e90cc..50340b8 100644 --- a/packages/ui/src/base/nav/nav.tsx +++ b/packages/ui/src/base/nav/nav.tsx @@ -10,6 +10,8 @@ * - Parent expansion uses an internal Base UI Collapsible with uncontrolled * and controlled APIs whose trigger and panel merge into the public button * and list elements. + * - GlNavProvider and GlCollapsibleNav are React-only responsive composition + * helpers inspired by, but not exposed by, GitLab's Super Sidebar. */ import { @@ -20,22 +22,39 @@ import { createElement, forwardRef, isValidElement, + useCallback, useContext, + useEffect, + useId, + useMemo, + useRef, + useState, type ButtonHTMLAttributes, type ElementType, type HTMLAttributes, type KeyboardEventHandler, type LiHTMLAttributes, + type MouseEventHandler, + type PointerEventHandler, type ReactElement, type ReactNode, type Ref, } from "react"; +import { createPortal } from "react-dom"; import { Button as BaseButton } from "@base-ui/react/button"; import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible"; import { cva } from "class-variance-authority"; +import { mergeRefs } from "../../internal/utils/merge-refs"; import GlAvatar from "../avatar/avatar"; +import GlButton from "../button/button"; import GlIcon from "../icon/icon"; import GlLink, { type GlLinkProps } from "../link/link"; +import GlPopover, { + GlPopoverContent, + GlPopoverTitle, + GlPopoverTrigger, +} from "../popover/popover"; +import GlTooltip, { GlTooltipContent, GlTooltipTrigger } from "../tooltip/tooltip"; export type GlNavItemIndicatorPosition = "bottom" | "left" | "right"; @@ -43,6 +62,33 @@ export type GlNavProps = Omit, "children"> & { children?: ReactNode; }; +export type GlCollapsibleNavProps = Omit & { + /** The navigation ID is owned by GlNavProvider so every remote toggle shares it. */ + id?: never; +}; + +export type GlNavProviderProps = { + children?: ReactNode; + /** Initial state when uncontrolled. */ + defaultOpen?: boolean; + /** ID used by the navigation and every toggle's aria-controls. */ + navId?: string; + /** Called when a toggle, backdrop, or Escape requests a state change. */ + onOpenChange?: (open: boolean) => void; + /** Controlled expanded state. */ + open?: boolean; +}; + +export type GlCollapsibleNavToggleProps = Omit< + ButtonHTMLAttributes, + "aria-controls" | "aria-expanded" | "aria-label" | "children" +> & { + /** Accessible and visible label used while the sidebar is expanded. */ + collapseLabel?: string; + /** Accessible label used while the sidebar is collapsed. */ + expandLabel?: string; +}; + export type GlNavItemProps = Omit, "children"> & { children: ReactNode; /** Prevents the item's button or link from being activated. */ @@ -137,13 +183,39 @@ export type GlNavItemAddonProps = Omit< type ButtonOwner = { disabled: boolean; + flyout?: { + close(): void; + open: boolean; + toggle(): void; + }; hasSubNav: boolean; indicatorPosition: GlNavItemIndicatorPosition; + isToggle?: boolean; level: "nav" | "subnav"; selected: boolean; + triggerRef?: { current: HTMLElement | null }; +}; + +type NavProviderContextValue = { + activeFlyoutId: string | null; + isDesktop: boolean; + navId: string; + open: boolean; + registerNav(id: symbol): () => void; + requestOpen( + open: boolean, + options?: { externalOpener?: HTMLElement | null; restoreFocus?: boolean }, + ): void; + setActiveFlyoutId(id: string | null): void; + viewportReady: boolean; }; -const NavContext = createContext(false); +type NavContextValue = + | { kind: "collapsible"; provider: NavProviderContextValue } + | { kind: "plain" }; + +const NavProviderContext = createContext(null); +const NavContext = createContext(null); const SubNavContext = createContext(false); const SubNavPanelIdContext = createContext(undefined); const ButtonOwnerContext = createContext(null); @@ -179,6 +251,7 @@ const navButtonVariants = cva(navItemClass, { }); const navVariants = cva(navClass); +const collapsibleNavVariants = cva([navClass, "gl-collapsible-nav"]); const navListVariants = cva("gl-nav-list"); const navListItemVariants = cva("gl-nav-list-item"); const subNavVariants = cva("gl-sub-nav"); @@ -189,6 +262,110 @@ function invariant(component: string, message: string): never { throw new Error(`[${component}] ${message}`); } +function useNavProviderContext(component: string) { + const context = useContext(NavProviderContext); + if(!context) invariant(component, "must be used within GlNavProvider."); + return context; +} + +const DESKTOP_NAV_QUERY = "(min-width: 1200px)"; + +export function GlNavProvider({ + children, + defaultOpen, + navId, + onOpenChange, + open, +}: GlNavProviderProps) { + const generatedId = useId(); + const resolvedNavId = navId ?? `gl-collapsible-nav-${generatedId}`; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false); + const [isDesktop, setIsDesktop] = useState(false); + const [viewportReady, setViewportReady] = useState(false); + const [activeFlyoutId, setActiveFlyoutId] = useState(null); + const registeredNav = useRef(null); + const externalOpener = useRef(null); + const restoreFocusOnClose = useRef(false); + const previousOpen = useRef(open ?? uncontrolledOpen); + const hasAutomaticInitialState = open === undefined && defaultOpen === undefined; + const isControlled = open !== undefined; + const resolvedOpen = open ?? uncontrolledOpen; + + useEffect(() => { + if(typeof window === "undefined") return; + if(typeof window.matchMedia !== "function") { + setViewportReady(true); + return; + } + + const mediaQuery = window.matchMedia(DESKTOP_NAV_QUERY); + setIsDesktop(mediaQuery.matches); + if(hasAutomaticInitialState) setUncontrolledOpen(mediaQuery.matches); + setViewportReady(true); + + const handleChange = (event: MediaQueryListEvent) => setIsDesktop(event.matches); + mediaQuery.addEventListener?.("change", handleChange); + return () => mediaQuery.removeEventListener?.("change", handleChange); + }, [hasAutomaticInitialState]); + + useEffect(() => { + if(resolvedOpen || !isDesktop) setActiveFlyoutId(null); + }, [isDesktop, resolvedOpen]); + + useEffect(() => { + const wasOpen = previousOpen.current; + previousOpen.current = resolvedOpen; + if(!wasOpen || resolvedOpen || !restoreFocusOnClose.current) return; + + restoreFocusOnClose.current = false; + const opener = externalOpener.current; + if(opener?.isConnected) opener.focus(); + }, [resolvedOpen]); + + const requestOpen = useCallback(( + nextOpen, + options, + ) => { + if(nextOpen === resolvedOpen) return; + if(nextOpen && options?.externalOpener) externalOpener.current = options.externalOpener; + if(!nextOpen && options?.restoreFocus) restoreFocusOnClose.current = true; + if(!isControlled) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen); + }, [isControlled, onOpenChange, resolvedOpen]); + + const registerNav = useCallback((id: symbol) => { + if(registeredNav.current && registeredNav.current !== id) { + invariant("GlCollapsibleNav", "GlNavProvider accepts exactly one GlCollapsibleNav."); + } + + registeredNav.current = id; + return () => { + if(registeredNav.current === id) registeredNav.current = null; + }; + }, []); + + const value = useMemo(() => ({ + activeFlyoutId, + isDesktop, + navId: resolvedNavId, + open: resolvedOpen, + registerNav, + requestOpen, + setActiveFlyoutId, + viewportReady, + }), [ + activeFlyoutId, + isDesktop, + registerNav, + requestOpen, + resolvedNavId, + resolvedOpen, + viewportReady, + ]); + + return {children}; +} + function flattenChildren(children: ReactNode, result: ReactNode[] = []): ReactNode[] { Children.forEach(children, (child) => { if(child === null || child === undefined || typeof child === "boolean") return; @@ -275,6 +452,12 @@ function resolveButtonContent(children: ReactNode, component: string): ResolvedB return { addon, label: addon ? nodes.slice(0, -1) : nodes, leading }; } +function simpleTextLabel(nodes: ReactNode[]) { + if(nodes.some((node) => typeof node !== "string" && typeof node !== "number")) return undefined; + const label = nodes.join("").replace(/\s+/gu, " ").trim(); + return label || undefined; +} + function NavItemChevron() { return ( ; render?: GlLinkProps["render"]; type?: ButtonHTMLAttributes["type"]; }; function useNavButton( - component: "GlNavButton" | "GlSubNavButton", + component: "GlCollapsibleNavToggle" | "GlNavButton" | "GlSubNavButton", props: GlNavButtonProps, forwardedRef: Ref, ) { const owner = useContext(ButtonOwnerContext); + const nav = useContext(NavContext); const subNavPanelId = useContext(SubNavPanelIdContext); - const expectedLevel = component === "GlNavButton" ? "nav" : "subnav"; + const expectedLevel = component === "GlSubNavButton" ? "subnav" : "nav"; - if(owner?.level !== expectedLevel) { + if(owner?.level !== expectedLevel || (component === "GlCollapsibleNavToggle" && !owner.isToggle)) { invariant( component, - component === "GlNavButton" + component === "GlSubNavButton" + ? "must be the direct button child of GlSubNavItem." + : component === "GlNavButton" ? "must be the direct button child of GlNavItem." - : "must be the direct button child of GlSubNavItem.", + : "must be the direct toggle child of GlNavItem in GlCollapsibleNav.", ); } @@ -340,6 +527,7 @@ function useNavButton( onClick, onEscape, onKeyDown, + onPointerDown, onPointerLeave, onPointerOver, ping, @@ -351,8 +539,12 @@ function useNavButton( ...elementProps } = props as NavButtonRuntimeProps; const { addon, label, leading } = resolveButtonContent(children, component); - const hasAutomaticChevron = Boolean(owner.hasSubNav && !addon && !isIconOnly); - const hasEndSlot = Boolean(!isIconOnly && (addon || hasAutomaticChevron)); + const isCollapsibleTopLevel = nav?.kind === "collapsible" && owner.level === "nav"; + const isRail = Boolean(isCollapsibleTopLevel && nav.provider.isDesktop && !nav.provider.open); + const effectiveIconOnly = isIconOnly || isRail; + const derivedLabel = simpleTextLabel(label); + const hasAutomaticChevron = Boolean(owner.hasSubNav && !addon && !effectiveIconOnly); + const hasEndSlot = Boolean(!effectiveIconOnly && (addon || hasAutomaticChevron)); const resolvedAriaControls = subNavPanelId && (ariaExpanded === true || ariaExpanded === "true") ? subNavPanelId @@ -361,7 +553,7 @@ function useNavButton( className, hasEndSlot, hasStartSlot: Boolean(leading), - iconOnly: isIconOnly, + iconOnly: effectiveIconOnly, indicatorPosition: owner.indicatorPosition, level: owner.level, selected: owner.selected, @@ -371,11 +563,45 @@ function useNavButton( invariant(component, "isIconOnly requires a leading GlIcon or GlAvatar."); } if(isIconOnly && !ariaLabel) invariant(component, "isIconOnly requires aria-label."); + if(isCollapsibleTopLevel && !leading) { + invariant(component, "requires a direct leading GlIcon or GlAvatar in GlCollapsibleNav."); + } + if(isCollapsibleTopLevel && !ariaLabel && !derivedLabel) { + invariant(component, "requires aria-label when its label is not simple text."); + } + + const pointerType = useRef(""); + const buttonRef = mergeRefs(forwardedRef, owner.triggerRef); + const handlePointerDown: PointerEventHandler = (event) => { + pointerType.current = event.pointerType; + onPointerDown?.(event); + }; + const handleClick: MouseEventHandler = (event) => { + onClick?.(event); + if(event.defaultPrevented || !owner.flyout || pointerType.current !== "touch") return; + + event.preventDefault(); + owner.flyout.toggle(); + }; const handleKeyDown: KeyboardEventHandler = (event) => { onKeyDown?.(event); - if(!event.defaultPrevented && event.key === "Escape") onEscape?.(event); + if(event.defaultPrevented) return; + if(owner.flyout && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + owner.flyout.toggle(); + return; + } + if(event.key === "Escape") { + if(owner.flyout?.open) { + event.preventDefault(); + owner.flyout.close(); + event.currentTarget.focus(); + } + onEscape?.(event); + } }; + const resolvedAriaLabel = ariaLabel ?? (isRail ? derivedLabel : undefined); const content = ( <> {leading ? ( @@ -394,15 +620,16 @@ function useNavButton( ); + let button: ReactElement; if(Boolean(href) || render !== undefined) { - return ( + button = ( } + ref={buttonRef as Ref} aria-controls={resolvedAriaControls} aria-current={ariaCurrent ?? (owner.selected ? "page" : undefined)} aria-expanded={ariaExpanded} - aria-label={ariaLabel} + aria-label={resolvedAriaLabel} className={classes} disabled={owner.disabled} download={download} @@ -410,8 +637,9 @@ function useNavButton( hrefLang={hrefLang} isUnsafeLink={isUnsafeLink} media={media} - onClick={onClick as React.MouseEventHandler} + onClick={handleClick as React.MouseEventHandler} onKeyDown={handleKeyDown as KeyboardEventHandler} + onPointerDown={handlePointerDown as React.PointerEventHandler} onPointerLeave={onPointerLeave as React.PointerEventHandler} onPointerOver={onPointerOver as React.PointerEventHandler} ping={ping} @@ -423,26 +651,39 @@ function useNavButton( {content} ); + } else { + button = ( + + {content} + + ); } + const tooltipLabel = ariaLabel ?? derivedLabel; + if(!isRail || owner.hasSubNav || !tooltipLabel) return button; + return ( - - {content} - + + {button} + + {tooltipLabel} + + ); } @@ -459,6 +700,91 @@ export const GlSubNavButton = forwardRef( }, ); +function InternalCollapsibleNavToggle({ + collapseLabel = "Collapse sidebar", + disabled: _disabled, + expandLabel = "Expand sidebar", + forwardedRef, + onClick, + type = "button", + ...buttonProps +}: GlCollapsibleNavToggleProps & { forwardedRef: Ref }) { + const provider = useNavProviderContext("GlCollapsibleNavToggle"); + const label = provider.open ? collapseLabel : expandLabel; + + const handleClick: MouseEventHandler = (event) => { + (onClick as MouseEventHandler | undefined)?.(event); + if(event.defaultPrevented) return; + provider.requestOpen(!provider.open, { + restoreFocus: !provider.isDesktop && provider.open, + }); + }; + + return useNavButton("GlCollapsibleNavToggle", { + ...buttonProps, + "aria-controls": provider.navId, + "aria-expanded": provider.open, + "aria-label": label, + children: ( + <> + + {label} + + ), + onClick: handleClick, + type, + } as GlNavButtonProps, forwardedRef); +} + +function ExternalCollapsibleNavToggle({ + collapseLabel = "Collapse sidebar", + expandLabel = "Expand sidebar", + forwardedRef, + onClick, + type = "button", + ...buttonProps +}: GlCollapsibleNavToggleProps & { forwardedRef: Ref }) { + const provider = useNavProviderContext("GlCollapsibleNavToggle"); + const label = provider.open ? collapseLabel : expandLabel; + const handleClick: MouseEventHandler = (event) => { + (onClick as MouseEventHandler | undefined)?.(event); + if(event.defaultPrevented) return; + provider.requestOpen(!provider.open, { + externalOpener: event.currentTarget, + }); + }; + const button = ( + , "children">} + ref={forwardedRef} + aria-controls={provider.navId} + aria-expanded={provider.open} + aria-label={label} + category="tertiary" + data-gl-collapsible-nav-toggle="external" + icon="sidebar" + onClick={handleClick} + type={type} /> + ); + + return ( + + {button} + {label} + + ); +} + +export const GlCollapsibleNavToggle = forwardRef( + function GlCollapsibleNavToggle(props, forwardedRef) { + const owner = useContext(ButtonOwnerContext); + const componentProps = { ...props, forwardedRef }; + return owner?.isToggle + ? + : ; + }, +); + export const GlNavItemAddon = forwardRef( function GlNavItemAddon({ children, className, ...elementProps }, forwardedRef) { if(!useContext(AddonContext)) { @@ -472,7 +798,7 @@ export const GlNavItemAddon = forwardRef( {children} @@ -482,17 +808,13 @@ export const GlNavItemAddon = forwardRef( function requireSingleButton( children: ReactNode, - component: "GlNavItem" | "GlSubNavItem", + component: "GlSubNavItem", ) { const nodes = flattenChildren(children); - const ButtonComponent = component === "GlNavItem" ? GlNavButton : GlSubNavButton; - const buttons = nodes.filter((node) => hasElementType(node, ButtonComponent)); + const buttons = nodes.filter((node) => hasElementType(node, GlSubNavButton)); if(buttons.length !== 1) { - invariant( - component, - `requires exactly one ${component === "GlNavItem" ? "GlNavButton" : "GlSubNavButton"}.`, - ); + invariant(component, "requires exactly one GlSubNavButton."); } return { button: buttons[0] as ReactElement, nodes }; @@ -521,27 +843,70 @@ export const GlNavItem = forwardRef(function GlNa selected = false, ...elementProps }, forwardedRef) { - if(!useContext(NavContext)) invariant("GlNavItem", "must be a direct child of GlNav."); + const nav = useContext(NavContext); + const generatedFlyoutId = useId(); + const triggerRef = useRef(null); + if(!nav) invariant("GlNavItem", "must be a direct child of GlNav or GlCollapsibleNav."); + + const nodes = flattenChildren(children); + const buttons = nodes.filter((node) => hasElementType(node, GlNavButton)); + const toggles = nodes.filter((node) => hasElementType(node, GlCollapsibleNavToggle)); + if(nav.kind === "plain" && toggles.length > 0) { + invariant("GlNavItem", "GlCollapsibleNavToggle is only accepted by GlCollapsibleNav."); + } + if(buttons.length + toggles.length !== 1) { + invariant( + "GlNavItem", + nav.kind === "collapsible" + ? "requires exactly one GlNavButton or GlCollapsibleNavToggle." + : "requires exactly one GlNavButton.", + ); + } - const { button, nodes } = requireSingleButton(children, "GlNavItem"); + const button = (buttons[0] ?? toggles[0]) as ReactElement; + const isToggle = toggles.length === 1; const subNavs = nodes.filter((node) => hasElementType(node, GlSubNav)); const unexpected = nodes.filter( - (node) => !hasElementType(node, GlNavButton) && !hasElementType(node, GlSubNav), + (node) => !hasElementType(node, GlNavButton) + && !hasElementType(node, GlCollapsibleNavToggle) + && !hasElementType(node, GlSubNav), ); if(unexpected.length > 0) { - invariant("GlNavItem", "only accepts GlNavButton and an optional GlSubNav."); + invariant( + "GlNavItem", + "only accepts GlNavButton or GlCollapsibleNavToggle and an optional GlSubNav.", + ); } if(subNavs.length > 1) invariant("GlNavItem", "accepts at most one GlSubNav."); - if(nodes[0] !== button) invariant("GlNavItem", "GlNavButton must precede GlSubNav."); + if(nodes[0] !== button) { + invariant( + "GlNavItem", + isToggle ? "GlCollapsibleNavToggle must precede GlSubNav." : "GlNavButton must precede GlSubNav.", + ); + } + if(isToggle && subNavs.length > 0) { + invariant("GlNavItem", "GlCollapsibleNavToggle cannot be used with GlSubNav."); + } const subNav = subNavs[0] as ReactElement | undefined; + const provider = nav.kind === "collapsible" ? nav.provider : undefined; + const flyoutId = `gl-collapsible-nav-flyout-${generatedFlyoutId}`; + const isFlyout = Boolean(subNav && provider?.isDesktop && !provider.open); + const flyoutOpen = provider?.activeFlyoutId === flyoutId; const owner: ButtonOwner = { disabled, + flyout: isFlyout && provider ? { + close: () => provider.setActiveFlyoutId(null), + open: flyoutOpen, + toggle: () => provider.setActiveFlyoutId(flyoutOpen ? null : flyoutId), + } : undefined, hasSubNav: Boolean(subNav), indicatorPosition, + isToggle, level: "nav", selected, + triggerRef, }; const item =
  • ; @@ -562,6 +927,53 @@ export const GlNavItem = forwardRef(function GlNa const open = typeof subNavProps.open === "boolean" ? subNavProps.open : undefined; const panelId = typeof subNavProps.id === "string" ? subNavProps.id : undefined; + if(isFlyout && provider) { + const resolvedContent = resolveButtonContent(buttonProps.children, "GlNavButton"); + const flyoutLabel = typeof buttonProps["aria-label"] === "string" + ? buttonProps["aria-label"] + : simpleTextLabel(resolvedContent.label) ?? "Navigation"; + const handleFlyoutOpenChange = (nextOpen: boolean) => { + if(nextOpen) provider.setActiveFlyoutId(flyoutId); + else if(provider.activeFlyoutId === flyoutId) provider.setActiveFlyoutId(null); + }; + const handleFlyoutKeyDown: KeyboardEventHandler = (event) => { + if(event.defaultPrevented || event.key !== "Escape") return; + + event.preventDefault(); + event.stopPropagation(); + provider.setActiveFlyoutId(null); + triggerRef.current?.focus(); + }; + + return ( + } + defaultOpen={Boolean(subNavProps.defaultOpen)} + disabled={disabled} + onOpenChange={(nextOpen) => onOpenChange?.(nextOpen)} + open={open} + render={item}> + + + {button} + + + {flyoutLabel} + {subNav} + + + + ); + } + return ( } @@ -640,18 +1052,134 @@ export const GlSubNavItem = forwardRef( }, ); +function validateNavChildren(children: ReactNode, component: "GlCollapsibleNav" | "GlNav") { + const nodes = flattenChildren(children); + if(nodes.some((node) => !hasElementType(node, GlNavItem))) { + invariant(component, "only accepts GlNavItem children."); + } +} + +function getFocusableElements(container: HTMLElement) { + const selector = [ + "a[href]:not([aria-disabled='true'])", + "button:not(:disabled)", + "[tabindex]:not([tabindex='-1'])", + ].join(","); + return Array.from(container.querySelectorAll(selector)) + .filter((element) => !element.closest("[aria-hidden='true'], [hidden], [inert]")); +} + +export const GlCollapsibleNav = forwardRef( + function GlCollapsibleNav({ + children, + className, + onKeyDown, + ...elementProps + }, forwardedRef) { + const provider = useNavProviderContext("GlCollapsibleNav"); + const registerNav = provider.registerNav; + const instanceId = useRef(Symbol("GlCollapsibleNav")); + const navElement = useRef(null); + const [mounted, setMounted] = useState(false); + const previousMobileOpen = useRef(false); + const isMobile = provider.viewportReady && !provider.isDesktop; + const isMobileOpen = isMobile && provider.open; + + validateNavChildren(children, "GlCollapsibleNav"); + + useEffect(() => registerNav(instanceId.current), [registerNav]); + useEffect(() => setMounted(true), []); + + useEffect(() => { + if(!isMobileOpen || typeof document === "undefined") return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isMobileOpen]); + + useEffect(() => { + const wasOpen = previousMobileOpen.current; + previousMobileOpen.current = isMobileOpen; + if(wasOpen || !isMobileOpen) return; + + getFocusableElements(navElement.current!).at(0)?.focus(); + }, [isMobileOpen]); + + const handleKeyDown: KeyboardEventHandler = (event) => { + onKeyDown?.(event); + if(event.defaultPrevented || !isMobileOpen) return; + + if(event.key === "Escape") { + event.preventDefault(); + provider.requestOpen(false, { restoreFocus: true }); + return; + } + + if(event.key !== "Tab") return; + const focusable = getFocusableElements(event.currentTarget); + if(focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable.at(-1)!; + if(event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if(!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + const nav = ( + + + + ); + + const backdrop = mounted && isMobile && typeof document !== "undefined" + ? createPortal( +
  • ; @@ -928,51 +615,21 @@ export const GlNavItem = forwardRef(function GlNa const open = typeof subNavProps.open === "boolean" ? subNavProps.open : undefined; const panelId = typeof subNavProps.id === "string" ? subNavProps.id : undefined; - if(isFlyout && provider) { + if(nav.kind === "collapsible") { const resolvedContent = resolveButtonContent(buttonProps.children, "GlNavButton"); const flyoutLabel = typeof buttonProps["aria-label"] === "string" ? buttonProps["aria-label"] : simpleTextLabel(resolvedContent.label) ?? "Navigation"; - const handleFlyoutOpenChange = (nextOpen: boolean) => { - if(nextOpen) provider.setActiveFlyoutId(flyoutId); - else if(provider.activeFlyoutId === flyoutId) provider.setActiveFlyoutId(null); - }; - const handleFlyoutKeyDown: KeyboardEventHandler = (event) => { - if(event.defaultPrevented || event.key !== "Escape") return; - - event.preventDefault(); - event.stopPropagation(); - provider.setActiveFlyoutId(null); - triggerRef.current?.focus(); - }; - return ( - } - defaultOpen={Boolean(subNavProps.defaultOpen)} - disabled={disabled} - onOpenChange={(nextOpen) => onOpenChange?.(nextOpen)} - open={open} - render={item}> - - - {button} - - - {flyoutLabel} - {subNav} - - - - ); + return nav.renderSubNav({ + button, + disabled, + flyoutLabel, + forwardedRef, + item, + owner, + subNav, + }); } return ( @@ -1053,131 +710,19 @@ export const GlSubNavItem = forwardRef( }, ); -function validateNavChildren(children: ReactNode, component: "GlCollapsibleNav" | "GlNav") { +function validateNavChildren(children: ReactNode) { const nodes = flattenChildren(children); if(nodes.some((node) => !hasElementType(node, GlNavItem))) { - invariant(component, "only accepts GlNavItem children."); + invariant("GlNav", "only accepts GlNavItem children."); } } -function getFocusableElements(container: HTMLElement) { - const selector = [ - "a[href]:not([aria-disabled='true'])", - "button:not(:disabled)", - "[tabindex]:not([tabindex='-1'])", - ].join(","); - return Array.from(container.querySelectorAll(selector)) - .filter((element) => !element.closest("[aria-hidden='true'], [hidden], [inert]")); -} - -export const GlCollapsibleNav = forwardRef( - function GlCollapsibleNav({ - children, - className, - onKeyDown, - ...elementProps - }, forwardedRef) { - const provider = useNavProviderContext("GlCollapsibleNav"); - const registerNav = provider.registerNav; - const instanceId = useRef(Symbol("GlCollapsibleNav")); - const navElement = useRef(null); - const [mounted, setMounted] = useState(false); - const previousMobileOpen = useRef(false); - const isMobile = provider.viewportReady && !provider.isDesktop; - const isMobileOpen = isMobile && provider.open; - - validateNavChildren(children, "GlCollapsibleNav"); - - useEffect(() => registerNav(instanceId.current), [registerNav]); - useEffect(() => setMounted(true), []); - - useEffect(() => { - if(!isMobileOpen || typeof document === "undefined") return; - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = previousOverflow; - }; - }, [isMobileOpen]); - - useEffect(() => { - const wasOpen = previousMobileOpen.current; - previousMobileOpen.current = isMobileOpen; - if(wasOpen || !isMobileOpen) return; - - getFocusableElements(navElement.current!).at(0)?.focus(); - }, [isMobileOpen]); - - const handleKeyDown: KeyboardEventHandler = (event) => { - onKeyDown?.(event); - if(event.defaultPrevented || !isMobileOpen) return; - - if(event.key === "Escape") { - event.preventDefault(); - provider.requestOpen(false, { restoreFocus: true }); - return; - } - - if(event.key !== "Tab") return; - const focusable = getFocusableElements(event.currentTarget); - if(focusable.length === 0) { - event.preventDefault(); - return; - } - - const first = focusable[0]; - const last = focusable.at(-1)!; - if(event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if(!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - - const nav = ( - - - - ); - - const backdrop = mounted && isMobile && typeof document !== "undefined" - ? createPortal( -