diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index 73ea037..80ed69e 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -7365,6 +7365,136 @@ .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 200ms ease, transform 200ms ease, visibility 0s; + } + .gl-collapsible-nav .gl-nav-item { + transition: width 200ms ease; + } + .gl-collapsible-nav > .gl-nav-list > .gl-nav-list-item > .gl-nav-item-has-start-slot { + padding-inline-start: var(--gl-spacing-scale-1); + } + .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-portal-host, .gl-collapsible-nav-portal, .gl-collapsible-nav-dialog { + display: contents; + } + .gl-collapsible-nav-toggle-icon:dir(rtl) { + transform: scaleX(-1); + } + .gl-collapsible-nav-backdrop { + position: fixed; + inset: 0; + z-index: 1029; + background-color: rgb(0 0 0 / 0.5); + opacity: 1; + transition: opacity 250ms ease-out; + } + .gl-collapsible-nav-backdrop[data-starting-style], .gl-collapsible-nav-backdrop[data-ending-style] { + opacity: 0; + } + .gl-collapsible-nav-flyout { + min-width: 12rem; + } + .gl-collapsible-nav-flyout.fade[data-ending-style] { + transition-duration: 0s; + } + .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 { + gap: var(--gl-spacing-scale-0); + width: 1.75rem; + height: 1.75rem; + margin-inline-start: 0; + margin-inline-end: auto; + } + .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[data-viewport-ready="false"] { + display: none; + } + .gl-collapsible-nav-dialog { + display: flex; + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: 1030; + width: max-content; + max-width: 100vw; + transform: translateX(0); + transition: transform 200ms ease; + } + .gl-collapsible-nav-dialog[data-closed], .gl-collapsible-nav-dialog[data-starting-style], .gl-collapsible-nav-dialog[data-ending-style] { + transform: translateX(-100%); + } + .gl-collapsible-nav-dialog:dir(rtl)[data-closed], .gl-collapsible-nav-dialog:dir(rtl)[data-starting-style], .gl-collapsible-nav-dialog:dir(rtl)[data-ending-style] { + transform: translateX(100%); + } + .gl-collapsible-nav { + background-color: var(--gl-background-color-overlap); + display: flex; + height: 100%; + flex-direction: column; + overflow-y: auto; + --tw-shadow: var(--gl-shadow-lg); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + font-size: var(--gl-font-size-base); + --tw-leading: 1rem; + line-height: 1rem; + position: static; + width: var(--gl-collapsible-nav-expanded-width); + max-width: calc(100vw - 3rem); + border-start-end-radius: var(--gl-drawer-border-radius); + border-end-end-radius: var(--gl-drawer-border-radius); + } + .gl-collapsible-nav > .gl-nav-list { + flex-grow: 1; + padding: var(--gl-spacing-scale-5); + padding-inline-start: var(--gl-spacing-scale-3); + } + .gl-collapsible-nav:dir(rtl) .gl-nav-item-indicator-left.selected::before { + left: auto; + right: calc(var(--gl-spacing-scale-3) * -1); + border-top-left-radius: var(--gl-border-radius-default); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: var(--gl-border-radius-default); + } + } + @media (prefers-reduced-motion: reduce) { + .gl-collapsible-nav, .gl-collapsible-nav .gl-nav-item, .gl-collapsible-nav .gl-nav-item-label, .gl-collapsible-nav .gl-nav-item-addon, .gl-collapsible-nav .gl-nav-item-chevron, .gl-collapsible-nav-dialog, .gl-collapsible-nav-backdrop { + transition-duration: 0s; + } + } .gl-pagination { font-size: var(--gl-font-size-base); text-wrap: nowrap; diff --git a/packages/ui/src/base/nav/collapsible-nav.tsx b/packages/ui/src/base/nav/collapsible-nav.tsx new file mode 100644 index 0000000..1e61041 --- /dev/null +++ b/packages/ui/src/base/nav/collapsible-nav.tsx @@ -0,0 +1,577 @@ +/** + * React-only responsive navigation helpers inspired by GitLab's Super Sidebar. + * These components intentionally compose the base Nav API instead of exposing + * the upstream application's sidebar state and persistence mechanisms. + */ + +import { + createContext, + forwardRef, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type ButtonHTMLAttributes, + type KeyboardEventHandler, + type MouseEventHandler, + type ReactNode, + type Ref, +} from "react"; +import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible"; +import { Dialog as BaseDialog } from "@base-ui/react/dialog"; +import { cva } from "class-variance-authority"; +import { useMergedRefs } from "../../internal/utils/merge-refs"; +import GlButton from "../button/button"; +import GlIcon from "../icon/icon"; +import GlPopover, { + GlPopoverContent, + GlPopoverTitle, + GlPopoverTrigger, +} from "../popover/popover"; +import GlTooltip, { GlTooltipContent, GlTooltipTrigger } from "../tooltip/tooltip"; +import { + ButtonOwnerContext, + COLLAPSIBLE_NAV_TOGGLE_MARKER, + NavContext, + SubNavContext, + SubNavPanelIdContext, + flattenChildren, + getElementProps, + hasElementType, + invariant, + withoutChildren, + withoutChildrenAndId, + type NavProviderContextValue, + type NavSubNavRenderProps, +} from "./nav-contexts"; +import { + GlNavItem, + useNavButtonInternal, + type GlNavButtonProps, + type GlNavProps, + type GlSubNavProps, +} from "./nav"; + +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; +}; + +const navClass = ["gl", "nav"].join("-"); +const collapsibleNavVariants = cva([navClass, "gl-collapsible-nav"]); +const navListVariants = cva("gl-nav-list"); +const DESKTOP_NAV_QUERY = "(min-width: 1200px)"; +const NavProviderContext = createContext(null); +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +function canReceiveRestoredFocus(element: HTMLElement | null) { + return Boolean( + element?.isConnected + && element !== element.ownerDocument.body + && element !== element.ownerDocument.documentElement + && !element.hasAttribute("disabled") + && element.getAttribute("aria-disabled") !== "true" + && !element.closest("[aria-hidden='true'], [hidden], [inert]"), + ); +} + +function useNavProviderContext(component: string) { + const context = useContext(NavProviderContext); + if(!context) invariant(component, "must be used within GlNavProvider."); + return context; +} + +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 externalToggles = useRef(new Set()); + const hasExplicitFocusReturnTarget = useRef(false); + const focusReturnTarget = useRef(null); + 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); + if(typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + } + + // Older Safari and embedded WebViews only expose the legacy MediaQueryList API. + mediaQuery.addListener(handleChange); + return () => mediaQuery.removeListener(handleChange); + }, [hasAutomaticInitialState]); + + useEffect(() => { + if(resolvedOpen || !isDesktop) setActiveFlyoutId(null); + }, [isDesktop, resolvedOpen]); + + const captureFocusReturnTarget = useCallback((element: HTMLElement | null) => { + if( + hasExplicitFocusReturnTarget.current + && canReceiveRestoredFocus(focusReturnTarget.current) + ) return; + + hasExplicitFocusReturnTarget.current = false; + if(canReceiveRestoredFocus(element)) focusReturnTarget.current = element; + }, []); + + const getFocusReturnTarget = useCallback(() => { + const rememberedTarget = focusReturnTarget.current; + const fallbackTarget = Array.from(externalToggles.current) + .find((element) => canReceiveRestoredFocus(element)); + const target = canReceiveRestoredFocus(rememberedTarget) + ? rememberedTarget + : fallbackTarget; + + hasExplicitFocusReturnTarget.current = false; + focusReturnTarget.current = null; + return target ?? false; + }, []); + + useEffect(() => { + if(!isDesktop) return; + + hasExplicitFocusReturnTarget.current = false; + focusReturnTarget.current = null; + }, [isDesktop]); + + const requestOpen = useCallback(( + nextOpen, + options, + ) => { + if(nextOpen === resolvedOpen) return; + if(nextOpen && !isDesktop && options?.externalOpener) { + hasExplicitFocusReturnTarget.current = true; + focusReturnTarget.current = options.externalOpener; + } + if(!isControlled) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen); + }, [isControlled, isDesktop, onOpenChange, resolvedOpen]); + + const registerExternalToggle = useCallback((element: HTMLElement) => { + externalToggles.current.add(element); + return () => { + externalToggles.current.delete(element); + }; + }, []); + + 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, + captureFocusReturnTarget, + getFocusReturnTarget, + isDesktop, + navId: resolvedNavId, + open: resolvedOpen, + registerExternalToggle, + registerNav, + requestOpen, + setActiveFlyoutId, + viewportReady, + }), [ + activeFlyoutId, + captureFocusReturnTarget, + getFocusReturnTarget, + isDesktop, + registerExternalToggle, + registerNav, + requestOpen, + resolvedNavId, + resolvedOpen, + viewportReady, + ]); + + return {children}; +} + +function InternalCollapsibleNavToggle({ + collapseLabel = "Collapse sidebar", + disabled = false, + 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); + }; + + return useNavButtonInternal("GlCollapsibleNavToggle", { + ...buttonProps, + "aria-controls": provider.navId, + "aria-expanded": provider.open, + "aria-label": label, + children: ( + <> + + {label} + + ), + onClick: handleClick, + type, + } as GlNavButtonProps, forwardedRef, disabled); +} + +function ExternalCollapsibleNavToggle({ + collapseLabel = "Collapse sidebar", + expandLabel = "Expand sidebar", + forwardedRef, + onClick, + type = "button", + ...buttonProps +}: GlCollapsibleNavToggleProps & { forwardedRef: Ref }) { + const provider = useNavProviderContext("GlCollapsibleNavToggle"); + const elementRef = useRef(null); + const buttonRef = useMergedRefs(forwardedRef, elementRef); + const registerExternalToggle = provider.registerExternalToggle; + const label = provider.open ? collapseLabel : expandLabel; + + useEffect(() => { + const element = elementRef.current; + if(element) return registerExternalToggle(element); + }, [registerExternalToggle]); + + const handleClick: MouseEventHandler = (event) => { + (onClick as MouseEventHandler | undefined)?.(event); + if(event.defaultPrevented) return; + provider.requestOpen(!provider.open, { + externalOpener: event.currentTarget, + }); + }; + const button = ( + , "children">} + ref={buttonRef} + 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 = Object.assign( + forwardRef( + function GlCollapsibleNavToggle(props, forwardedRef) { + const owner = useContext(ButtonOwnerContext); + const componentProps = { ...props, forwardedRef }; + return owner?.isToggle + ? + : ; + }, + ), + { [COLLAPSIBLE_NAV_TOGGLE_MARKER]: true as const }, +); + +function CollapsibleSubNav({ + button, + disabled, + flyoutLabel, + forwardedRef, + item, + owner, + subNav, +}: NavSubNavRenderProps) { + const nav = useContext(NavContext); + if(nav?.kind !== "collapsible") { + invariant("GlNavItem", "collapsible SubNav rendering requires GlCollapsibleNav."); + } + + const provider = nav.provider; + const generatedFlyoutId = useId(); + const triggerRef = useRef(null); + const flyoutId = `gl-collapsible-nav-flyout-${generatedFlyoutId}`; + const isFlyout = provider.isDesktop && !provider.open; + const flyoutOpen = provider.activeFlyoutId === flyoutId; + const buttonProps = getElementProps(button); + const subNavProps = getElementProps(subNav); + const onOpenChange = subNavProps.onOpenChange as GlSubNavProps["onOpenChange"]; + const open = typeof subNavProps.open === "boolean" ? subNavProps.open : undefined; + const panelId = typeof subNavProps.id === "string" ? subNavProps.id : undefined; + + if(isFlyout) { + const flyoutOwner = { + ...owner, + flyout: { + close: () => provider.setActiveFlyoutId(null), + open: flyoutOpen, + toggle: () => provider.setActiveFlyoutId(flyoutOpen ? null : flyoutId), + }, + triggerRef, + }; + 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 ( + } + defaultOpen={Boolean(subNavProps.defaultOpen)} + disabled={disabled} + onOpenChange={(nextOpen) => onOpenChange?.(nextOpen)} + open={open} + render={item}> + + + + {buttonProps.children} + + + + + + {subNavProps.children} + + + + ); +} + +function renderCollapsibleSubNav(props: NavSubNavRenderProps) { + return ; +} + +function validateNavChildren(children: ReactNode) { + const nodes = flattenChildren(children); + if(nodes.some((node) => !hasElementType(node, GlNavItem))) { + invariant("GlCollapsibleNav", "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.tabIndex >= 0 + && !element.closest("[aria-hidden='true'], [hidden], [inert]") + )); +} + +export const GlCollapsibleNav = forwardRef( + function GlCollapsibleNav({ + "aria-hidden": ariaHidden, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + children, + className, + inert: consumerInert, + onKeyDown, + tabIndex, + ...elementProps + }, forwardedRef) { + const provider = useNavProviderContext("GlCollapsibleNav"); + const registerNav = provider.registerNav; + const instanceId = useRef(Symbol("GlCollapsibleNav")); + const navElement = useRef(null); + const portalContainer = useRef(null); + const mergedNavRef = useMergedRefs(forwardedRef, navElement); + const captureFocusReturnTarget = provider.captureFocusReturnTarget; + const isMobile = provider.viewportReady && !provider.isDesktop; + const isMobileOpen = isMobile && provider.open; + const isMobileHidden = isMobile && !provider.open; + + validateNavChildren(children); + + useEffect(() => registerNav(instanceId.current), [registerNav]); + + useIsomorphicLayoutEffect(() => { + if(!isMobileOpen || typeof document === "undefined") return; + + const activeElement = document.activeElement; + captureFocusReturnTarget( + activeElement instanceof HTMLElement + && activeElement !== document.body + && activeElement !== document.documentElement + && !navElement.current?.contains(activeElement) + ? activeElement + : null, + ); + }, [captureFocusReturnTarget, isMobileOpen]); + + const nav = ( + + + + ); + + if(!isMobile) return nav; + + const handleInitialFocus = () => { + const element = navElement.current; + if(!element) return false; + + return getFocusableElements(element).at(0) ?? element; + }; + + return ( + <> +
+ provider.requestOpen(nextOpen)} + open={isMobileOpen}> + + {isMobile ? ( + + ) : null} + provider.isDesktop ? false : provider.getFocusReturnTarget()} + initialFocus={handleInitialFocus} + role={isMobile ? "dialog" : "presentation"}> + {nav} + + + + + ); + }, +); diff --git a/packages/ui/src/base/nav/nav-contexts.ts b/packages/ui/src/base/nav/nav-contexts.ts new file mode 100644 index 0000000..eef4807 --- /dev/null +++ b/packages/ui/src/base/nav/nav-contexts.ts @@ -0,0 +1,118 @@ +import { + Children, + Fragment, + createContext, + createElement, + isValidElement, + type ElementType, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; + +export type ButtonOwner = { + disabled: boolean; + flyout?: { + close(): void; + open: boolean; + toggle(): void; + }; + hasSubNav: boolean; + indicatorPosition: "bottom" | "left" | "right"; + isToggle?: boolean; + level: "nav" | "subnav"; + selected: boolean; + triggerRef?: { current: HTMLElement | null }; +}; + +export type NavProviderContextValue = { + activeFlyoutId: string | null; + captureFocusReturnTarget(element: HTMLElement | null): void; + getFocusReturnTarget(): HTMLElement | false; + isDesktop: boolean; + navId: string; + open: boolean; + registerExternalToggle(element: HTMLElement): () => void; + registerNav(id: symbol): () => void; + requestOpen( + open: boolean, + options?: { externalOpener?: HTMLElement | null }, + ): void; + setActiveFlyoutId(id: string | null): void; + viewportReady: boolean; +}; + +export type NavSubNavRenderProps = { + button: ReactElement; + disabled: boolean; + flyoutLabel: string; + forwardedRef: Ref; + item: ReactElement; + owner: ButtonOwner; + subNav: ReactElement; +}; + +export type NavContextValue = + | { + kind: "collapsible"; + provider: NavProviderContextValue; + renderSubNav(props: NavSubNavRenderProps): ReactNode; + } + | { kind: "plain" }; + +export const NavContext = createContext(null); +export const SubNavContext = createContext(false); +export const SubNavPanelIdContext = createContext(undefined); +export const ButtonOwnerContext = createContext(null); +export const AddonContext = createContext(false); + +export const COLLAPSIBLE_NAV_TOGGLE_MARKER = Symbol("GlCollapsibleNavToggle"); + +type CollapsibleNavToggleComponent = ElementType & { + [COLLAPSIBLE_NAV_TOGGLE_MARKER]?: true; +}; + +export function isCollapsibleNavToggleElement(node: ReactNode) { + return isValidElement(node) + && Boolean((node.type as CollapsibleNavToggleComponent)[COLLAPSIBLE_NAV_TOGGLE_MARKER]); +} + +export function invariant(component: string, message: string): never { + throw new Error(`[${component}] ${message}`); +} + +export function flattenChildren(children: ReactNode, result: ReactNode[] = []): ReactNode[] { + Children.forEach(children, (child) => { + if(child === null || child === undefined || typeof child === "boolean") return; + + if(isValidElement(child) && child.type === Fragment) { + flattenChildren((child.props as { children?: ReactNode }).children, result); + return; + } + + result.push(child); + }); + + return result; +} + +export function hasElementType(node: ReactNode, component: React.ElementType) { + return isValidElement(node) && node.type === component; +} + +export function getElementProps(element: ReactElement) { + return element.props as Record & { children?: ReactNode }; +} + +export function withoutChildren(element: ReactElement) { + const props = { ...getElementProps(element) }; + delete props.children; + return createElement(element.type as ElementType, props); +} + +export function withoutChildrenAndId(element: ReactElement) { + const props = { ...getElementProps(element) }; + delete props.children; + delete props.id; + return createElement(element.type as ElementType, props); +} diff --git a/packages/ui/src/base/nav/nav.css b/packages/ui/src/base/nav/nav.css index 04d2903..20c9077 100644 --- a/packages/ui/src/base/nav/nav.css +++ b/packages/ui/src/base/nav/nav.css @@ -169,3 +169,181 @@ @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 200ms ease, transform 200ms ease, visibility 0s; + + .gl-nav-item { + transition: width 200ms ease; + } + + > .gl-nav-list > .gl-nav-list-item > .gl-nav-item-has-start-slot { + padding-inline-start: var(--gl-spacing-scale-1); + } + + .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-portal-host, +.gl-collapsible-nav-portal, +.gl-collapsible-nav-dialog { + display: contents; +} + +.gl-collapsible-nav-toggle-icon:dir(rtl) { + transform: scaleX(-1); +} + +.gl-collapsible-nav-backdrop { + position: fixed; + inset: 0; + z-index: 1029; + background-color: rgb(0 0 0 / 0.5); + opacity: 1; + transition: opacity 250ms ease-out; + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + } +} + +.gl-collapsible-nav-flyout { + min-width: 12rem; + + &.fade[data-ending-style] { + transition-duration: 0s; + } + + .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-gap-0; + width: 1.75rem; + height: 1.75rem; + margin-inline-start: 0; + margin-inline-end: auto; + } + + .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[data-viewport-ready="false"] { + display: none; + } + + .gl-collapsible-nav-dialog { + display: flex; + position: fixed; + inset-block: 0; + inset-inline-start: 0; + z-index: 1030; + width: max-content; + max-width: 100vw; + transform: translateX(0); + transition: transform 200ms ease; + + &[data-closed], + &[data-starting-style], + &[data-ending-style] { + transform: translateX(-100%); + } + + &:dir(rtl)[data-closed], + &:dir(rtl)[data-starting-style], + &:dir(rtl)[data-ending-style] { + transform: translateX(100%); + } + } + + .gl-collapsible-nav { + @apply gl-bg-overlap; + @apply gl-flex; + @apply gl-h-full; + @apply gl-flex-col; + @apply gl-overflow-y-auto; + @apply gl-shadow-lg; + @apply gl-text-base; + @apply gl-leading-normal; + position: static; + width: var(--gl-collapsible-nav-expanded-width); + max-width: calc(100vw - 3rem); + border-start-end-radius: var(--gl-drawer-border-radius); + border-end-end-radius: var(--gl-drawer-border-radius); + + > .gl-nav-list { + @apply gl-grow; + @apply gl-p-5; + padding-inline-start: var(--gl-spacing-scale-3); + } + + &:dir(rtl) .gl-nav-item-indicator-left.selected::before { + left: auto; + right: calc(var(--gl-spacing-scale-3) * -1); + border-top-left-radius: var(--gl-border-radius-default); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: var(--gl-border-radius-default); + } + } +} + +@media (prefers-reduced-motion: reduce) { + .gl-collapsible-nav, + .gl-collapsible-nav .gl-nav-item, + .gl-collapsible-nav .gl-nav-item-label, + .gl-collapsible-nav .gl-nav-item-addon, + .gl-collapsible-nav .gl-nav-item-chevron, + .gl-collapsible-nav-dialog, + .gl-collapsible-nav-backdrop { + transition-duration: 0s; + } +} diff --git a/packages/ui/src/base/nav/nav.stories.tsx b/packages/ui/src/base/nav/nav.stories.tsx index 63bd5f6..d24a7b3 100644 --- a/packages/ui/src/base/nav/nav.stories.tsx +++ b/packages/ui/src/base/nav/nav.stories.tsx @@ -1,9 +1,14 @@ 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 { + GlCollapsibleNav, + GlCollapsibleNavToggle, + GlNavProvider, +} from "./collapsible-nav"; import GlNav, { GlNavButton, GlNavItem, @@ -222,12 +227,15 @@ export const IsParent: Story = { await userEvent.click(trigger); await expect(trigger).toHaveAttribute("aria-expanded", "true"); await expect(canvas.getByRole("link", { name: "Milestones 3" })).toBeVisible(); + await userEvent.keyboard("{Escape}"); await expect(escapeHandler).toHaveBeenCalledTimes(1); + await userEvent.hover(trigger); await expect(pointerOverHandler).toHaveBeenCalled(); await userEvent.unhover(trigger); await expect(pointerLeaveHandler).toHaveBeenCalled(); + await userEvent.keyboard(" "); await expect(trigger).toHaveAttribute("aria-expanded", "false"); }, @@ -443,3 +451,478 @@ 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 expect(nav.closest(".gl-collapsible-nav-dialog")).toBeNull(); + + const expandedIssues = canvas.getByRole("link", { name: "Issues 12" }); + const expandedBounds = expandedIssues.getBoundingClientRect(); + const expandedIconX = within(expandedIssues) + .getByTestId("nav-item-start").getBoundingClientRect().x; + + await userEvent.click(toggle); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + expect(Math.round(within(expandedIssues).getByTestId("nav-item-start") + .getBoundingClientRect().x)).toBe(Math.round(expandedIconX)); + 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 waitFor(() => { + const bounds = issues.getBoundingClientRect(); + expect(Math.round(bounds.width)).toBe(Math.round(bounds.height)); + expect(Math.round(bounds.height)).toBe(Math.round(expandedBounds.height)); + expect(Math.round(bounds.x)).toBe(Math.round(expandedBounds.x)); + expect(Math.round(within(issues).getByTestId("nav-item-start") + .getBoundingClientRect().x)).toBe(Math.round(expandedIconX)); + }); + + await userEvent.hover(issues); + const tooltip = await within(document.body).findByRole("tooltip", { name: "Issues" }); + await waitFor(() => expect(tooltip).toBeVisible()); + await userEvent.unhover(issues); + + const collapsedBounds = issues.getBoundingClientRect(); + await userEvent.click(canvas.getByRole("button", { name: "Expand sidebar" })); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const expandingBounds = issues.getBoundingClientRect(); + expect(expandingBounds.width).toBeGreaterThan(collapsedBounds.width); + expect(expandingBounds.width).toBeLessThan(expandedBounds.width); + expect(Math.round(expandingBounds.height)).toBe(Math.round(expandedBounds.height)); + expect(Math.round(expandingBounds.x)).toBe(Math.round(expandedBounds.x)); + expect(Math.round(within(issues).getByTestId("nav-item-start") + .getBoundingClientRect().x)).toBe(Math.round(expandedIconX)); + }, +}; + +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(); + + parent.click(); + const synthesizedFlyout = await within(document.body).findByRole("link", { name: "Members" }); + await waitFor(() => expect(synthesizedFlyout).toBeVisible()); + parent.click(); + await waitFor(() => expect(within(document.body).queryByRole("dialog")) + .not.toBeInTheDocument()); + + await userEvent.hover(parent); + const members = await within(document.body).findByRole("link", { name: "Members" }); + await waitFor(() => expect(members).toBeVisible()); + + const flyout = within(document.body).getByRole("dialog"); + const flyoutStyleProbe = flyout.cloneNode(false) as HTMLElement; + document.body.append(flyoutStyleProbe); + + flyoutStyleProbe.setAttribute("data-starting-style", ""); + expect(getComputedStyle(flyoutStyleProbe).transitionDuration).toBe("0.15s"); + flyoutStyleProbe.removeAttribute("data-starting-style"); + flyoutStyleProbe.setAttribute("data-ending-style", ""); + expect(getComputedStyle(flyoutStyleProbe).transitionDuration).toBe("0s"); + flyoutStyleProbe.remove(); + + await userEvent.unhover(parent); + await waitFor(() => expect(within(document.body).queryByRole("dialog")) + .not.toBeInTheDocument()); + + 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.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })); + const touchFlyout = await within(document.body).findByRole("link", { name: "Members" }); + await waitFor(() => expect(touchFlyout).toBeVisible()); + await userEvent.keyboard("{Escape}"); + + parent.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "pen" })); + parent.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })); + const penFlyout = await within(document.body).findByRole("link", { name: "Members" }); + await waitFor(() => expect(penFlyout).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(); + }, +}; + +function MobileOverlayExample() { + const [open, setOpen] = useState(false); + + return ( + + setOpen(true)}>Open programmatically + setOpen(false)}>Close programmatically + + + {CollapsibleNavItems({ internalToggle: true })} + + + + Untabbable navigation item + + + + + ); +} + +export const MobileOverlay: Story = { + parameters: { viewport: { defaultViewport: "mobile1" } }, + render: () => , + play: async ({ canvas }) => { + const programmaticOpener = canvas.getByRole("button", { name: "Open programmatically" }); + const programmaticCloser = canvas.getByRole("button", { name: "Close programmatically" }); + const externalToggle = canvas.getByRole("button", { name: "Expand sidebar" }); + + await userEvent.click(programmaticOpener); + + const nav = canvas.getByRole("navigation", { name: "Mobile project navigation" }); + const firstLink = canvas.getByRole("link", { name: "Issues 12" }); + const untabbableLink = canvas.getByRole("link", { name: "Untabbable navigation item" }); + const internalToggle = within(nav).getByRole("button", { name: "Collapse sidebar" }); + const backdrop = within(document.body).getByTestId("collapsible-nav-backdrop"); + const dialog = nav.closest(".gl-collapsible-nav-dialog")!; + await expect(nav).toHaveAttribute("data-open", "true"); + await expect(nav).toHaveAttribute("data-viewport-ready", "true"); + await expect(nav.closest(".gl-collapsible-nav-dialog")).not.toBeNull(); + await expect(backdrop).toHaveAttribute("data-open"); + await expect(document.body.style.overflow).toBe("hidden"); + await expect(untabbableLink).toHaveAttribute("tabindex", "-1"); + await expect(programmaticOpener.closest("[inert]")).toBeNull(); + await waitFor(() => { + expect(programmaticOpener.closest("[aria-hidden='true']")).not.toBeNull(); + }); + + const drawerStyleProbe = document.createElement("aside"); + const drawerBodyStyleProbe = document.createElement("div"); + const drawerContentStyleProbe = document.createElement("div"); + drawerStyleProbe.className = "gl-drawer gl-drawer-default"; + drawerBodyStyleProbe.className = "gl-drawer-body"; + drawerBodyStyleProbe.append(drawerContentStyleProbe); + drawerStyleProbe.append(drawerBodyStyleProbe); + document.body.append(drawerStyleProbe); + + const navStyle = getComputedStyle(nav); + const drawerStyle = getComputedStyle(drawerStyleProbe); + const navListStyle = getComputedStyle(nav.querySelector(":scope > .gl-nav-list")!); + const indicatorStyle = getComputedStyle(firstLink, "::before"); + const drawerContentStyle = getComputedStyle(drawerContentStyleProbe); + const navBounds = nav.getBoundingClientRect(); + const viewportHeight = nav.ownerDocument.defaultView!.innerHeight; + + expect(navBounds.top).toBeCloseTo(0); + expect(navBounds.bottom).toBeCloseTo(viewportHeight); + expect(navStyle.backgroundColor).toBe(drawerStyle.backgroundColor); + expect(navStyle.boxShadow).toBe(drawerStyle.boxShadow); + expect(navStyle.fontSize).toBe(drawerStyle.fontSize); + expect(navStyle.lineHeight).toBe(drawerStyle.lineHeight); + expect(navStyle.borderTopRightRadius).toBe(drawerStyle.borderTopLeftRadius); + expect(navStyle.borderBottomRightRadius).toBe(drawerStyle.borderBottomLeftRadius); + expect(navListStyle.paddingTop).toBe(drawerContentStyle.paddingTop); + expect(navListStyle.paddingRight).toBe(drawerContentStyle.paddingRight); + expect(Number.parseFloat(indicatorStyle.left)) + .toBe(-Number.parseFloat(navListStyle.paddingLeft)); + drawerStyleProbe.remove(); + + 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 waitFor(() => expect(firstLink).toHaveFocus()); + + programmaticCloser.click(); + await waitFor(() => expect(nav).toHaveAttribute("data-open", "false")); + await waitFor(() => expect(dialog).toHaveAttribute("hidden")); + await waitFor(() => expect(programmaticOpener).toHaveFocus()); + await waitFor(() => { + expect(programmaticOpener.closest("[aria-hidden='true']")).toBeNull(); + }); + await expect(document.body.style.overflow).not.toBe("hidden"); + + externalToggle.click(); + await waitFor(() => expect(nav).toHaveAttribute("data-open", "true")); + await waitFor(() => expect(firstLink).toHaveFocus()); + await userEvent.click(backdrop); + await expect(nav).toHaveAttribute("data-open", "false"); + await waitFor(() => expect(externalToggle).toHaveFocus()); + + await userEvent.click(externalToggle); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(externalToggle).toHaveFocus()); + }, +}; + +export const MobileOverlayEmpty: Story = { + parameters: { viewport: { defaultViewport: "mobile1" } }, + render: () => ( + + + + + ), + play: async ({ canvas }) => { + const nav = canvas.getByRole("navigation", { name: "Empty mobile navigation" }); + const externalToggle = canvas.getByTitle("Empty navigation toggle"); + + await waitFor(() => expect(nav).toHaveFocus()); + await expect(nav).toHaveAttribute("tabindex", "-1"); + await expect(externalToggle.closest("[inert]")).toBeNull(); + await waitFor(() => { + expect(externalToggle.closest("[aria-hidden='true']")).not.toBeNull(); + }); + + await userEvent.keyboard("{Escape}"); + await expect(nav).toHaveAttribute("data-open", "false"); + await waitFor(() => { + expect(externalToggle.closest("[aria-hidden='true']")).toBeNull(); + }); + await waitFor(() => expect(externalToggle).toHaveFocus()); + }, +}; + +export const MobileOverlayDefaultOpen: Story = { + parameters: { viewport: { defaultViewport: "mobile1" } }, + render: () => ( + + + + {CollapsibleNavItems({ internalToggle: true })} + + + ), + play: async ({ canvas, canvasElement }) => { + const externalToggle = canvasElement.querySelector( + "[data-gl-collapsible-nav-toggle='external']", + )!; + const firstLink = canvas.getByRole("link", { name: "Issues 12" }); + + await waitFor(() => expect(firstLink).toHaveFocus()); + + await userEvent.keyboard("{Escape}"); + await expect(externalToggle).toHaveAccessibleName("Expand sidebar"); + await waitFor(() => expect(externalToggle).toHaveFocus()); + }, +}; + +export const MobileOverlayRtl: Story = { + name: "Collapsible / Mobile Overlay / RTL", + parameters: { + docs: { + description: { + story: "Use either toggle to inspect the mobile drawer animation from the RTL logical start edge.", + }, + }, + viewport: { defaultViewport: "mobile1" }, + }, + render: () => ( +
+ + + + {CollapsibleNavItems({ internalToggle: true })} + + +
+ ), + play: async ({ canvas }) => { + const nav = canvas.getByRole("navigation", { name: "RTL mobile project navigation" }); + const dialog = nav.closest(".gl-collapsible-nav-dialog")!; + const firstLink = within(nav).getByRole("link", { name: "Issues 12" }); + const internalToggle = within(nav).getByRole("button", { name: "Collapse sidebar" }); + + await expect(getComputedStyle(nav).direction).toBe("rtl"); + + const navListStyle = getComputedStyle(nav.querySelector(":scope > .gl-nav-list")!); + const indicatorStyle = getComputedStyle(firstLink, "::before"); + expect(Number.parseFloat(navListStyle.paddingRight)) + .toBeLessThan(Number.parseFloat(navListStyle.paddingLeft)); + expect(Number.parseFloat(indicatorStyle.right)) + .toBe(-Number.parseFloat(navListStyle.paddingRight)); + + await userEvent.click(internalToggle); + await expect(nav).toHaveAttribute("data-open", "false"); + await waitFor(() => { + const closedTransform = new DOMMatrixReadOnly(getComputedStyle(dialog).transform); + expect(closedTransform.m41).toBeGreaterThan(0); + }); + + await userEvent.click(canvas.getByRole("button", { name: "Expand sidebar" })); + await expect(nav).toHaveAttribute("data-open", "true"); + }, +}; diff --git a/packages/ui/src/base/nav/nav.test.tsx b/packages/ui/src/base/nav/nav.test.tsx index 87ba64d..9585fe2 100644 --- a/packages/ui/src/base/nav/nav.test.tsx +++ b/packages/ui/src/base/nav/nav.test.tsx @@ -11,6 +11,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import GlAvatar from "../avatar/avatar"; import GlIcon from "../icon/icon"; +import { + GlCollapsibleNav, + GlCollapsibleNavToggle, + GlNavProvider, +} from "./collapsible-nav"; import GlNav, { GlNavButton, GlNavItem, @@ -283,7 +288,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 +417,171 @@ 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).toMatch(/]*data-viewport-ready="false"/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("preserves consumer-provided aria-hidden state", () => { + const renderNav = (ariaHidden: boolean) => renderToStaticMarkup( + + + + Issues + + + , + ); + + expect(renderNav(true)).toContain("aria-hidden=\"true\""); + expect(renderNav(false)).toContain("aria-hidden=\"false\""); + }); + + it("preserves consumer-provided inert state", () => { + const markup = renderToStaticMarkup( + + + + Issues + + + , + ); + + expect(markup).toMatch(/]*inert=""/u); + }); + + 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("honors disabled passed directly to an internal toggle", () => { + const markup = renderToStaticMarkup( + + + + + + + , + ); + + expect(markup).toMatch(/]*disabled=""/u); + expect(markup).toContain("aria-controls=\"disabled-toggle-sidebar\""); + expect(markup).toContain("data-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 +689,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..20efc49 100644 --- a/packages/ui/src/base/nav/nav.tsx +++ b/packages/ui/src/base/nav/nav.tsx @@ -13,19 +13,17 @@ */ import { - Children, - Fragment, cloneElement, - createContext, - createElement, forwardRef, isValidElement, useContext, + useRef, type ButtonHTMLAttributes, - type ElementType, type HTMLAttributes, type KeyboardEventHandler, type LiHTMLAttributes, + type MouseEventHandler, + type PointerEventHandler, type ReactElement, type ReactNode, type Ref, @@ -33,9 +31,26 @@ import { 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 { useMergedRefs } from "../../internal/utils/merge-refs"; import GlAvatar from "../avatar/avatar"; import GlIcon from "../icon/icon"; import GlLink, { type GlLinkProps } from "../link/link"; +import GlTooltip, { GlTooltipContent, GlTooltipTrigger } from "../tooltip/tooltip"; +import { + AddonContext, + ButtonOwnerContext, + NavContext, + SubNavContext, + SubNavPanelIdContext, + flattenChildren, + getElementProps, + hasElementType, + invariant, + isCollapsibleNavToggleElement, + withoutChildren, + withoutChildrenAndId, + type ButtonOwner, +} from "./nav-contexts"; export type GlNavItemIndicatorPosition = "bottom" | "left" | "right"; @@ -135,20 +150,6 @@ export type GlNavItemAddonProps = Omit< children?: ReactNode; }; -type ButtonOwner = { - disabled: boolean; - hasSubNav: boolean; - indicatorPosition: GlNavItemIndicatorPosition; - level: "nav" | "subnav"; - selected: boolean; -}; - -const NavContext = createContext(false); -const SubNavContext = createContext(false); -const SubNavPanelIdContext = createContext(undefined); -const ButtonOwnerContext = createContext(null); -const AddonContext = createContext(false); - // Keep these component selectors assembled at runtime. The Tailwind 3 prefix // compatibility scanner otherwise mistakes them for the `nav` and `nav-item` // utilities and rewrites Bootstrap compatibility selectors used by GlTabs. @@ -185,46 +186,6 @@ const subNavVariants = cva("gl-sub-nav"); const subNavListItemVariants = cva("gl-sub-nav-item"); const navSlotVariants = cva("gl-nav-item-slot"); -function invariant(component: string, message: string): never { - throw new Error(`[${component}] ${message}`); -} - -function flattenChildren(children: ReactNode, result: ReactNode[] = []): ReactNode[] { - Children.forEach(children, (child) => { - if(child === null || child === undefined || typeof child === "boolean") return; - - if(isValidElement(child) && child.type === Fragment) { - flattenChildren((child.props as { children?: ReactNode }).children, result); - return; - } - - result.push(child); - }); - - return result; -} - -function hasElementType(node: ReactNode, component: React.ElementType) { - return isValidElement(node) && node.type === component; -} - -function getElementProps(element: ReactElement) { - return element.props as Record & { children?: ReactNode }; -} - -function withoutChildren(element: ReactElement) { - const props = { ...getElementProps(element) }; - delete props.children; - return createElement(element.type as ElementType, props); -} - -function withoutChildrenAndId(element: ReactElement) { - const props = { ...getElementProps(element) }; - delete props.children; - delete props.id; - return createElement(element.type as ElementType, props); -} - function isLeading(node: ReactNode) { return hasElementType(node, GlIcon) || hasElementType(node, GlAvatar); } @@ -275,6 +236,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", +export function useNavButtonInternal( + component: "GlCollapsibleNavToggle" | "GlNavButton" | "GlSubNavButton", props: GlNavButtonProps, forwardedRef: Ref, + componentDisabled = false, ) { 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 +312,7 @@ function useNavButton( onClick, onEscape, onKeyDown, + onPointerDown, onPointerLeave, onPointerOver, ping, @@ -351,8 +324,13 @@ 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 disabled = owner.disabled || componentDisabled; const resolvedAriaControls = subNavPanelId && (ariaExpanded === true || ariaExpanded === "true") ? subNavPanelId @@ -361,7 +339,7 @@ function useNavButton( className, hasEndSlot, hasStartSlot: Boolean(leading), - iconOnly: isIconOnly, + iconOnly: effectiveIconOnly, indicatorPosition: owner.indicatorPosition, level: owner.level, selected: owner.selected, @@ -371,11 +349,52 @@ 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 = useMergedRefs(forwardedRef, owner.triggerRef); + const handlePointerDown: PointerEventHandler = (event) => { + pointerType.current = event.pointerType; + onPointerDown?.(event); + }; + const handleClick: MouseEventHandler = (event) => { + const isNonMousePointerClick = pointerType.current !== "" && pointerType.current !== "mouse"; + const isSynthesizedClick = event.detail === 0; + pointerType.current = ""; + onClick?.(event); + if( + event.defaultPrevented + || !owner.flyout + || (!isNonMousePointerClick && !isSynthesizedClick) + ) 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,24 +413,26 @@ 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} + disabled={disabled} download={download} href={href || undefined} 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 +444,40 @@ function useNavButton( {content} ); + } else { + button = ( + + {content} + + ); } + const tooltipLabel = ariaLabel ?? derivedLabel; + const hasCollapsibleTooltip = isCollapsibleTopLevel && !owner.hasSubNav && tooltipLabel; + if(!hasCollapsibleTooltip) return button; + return ( - - {content} - + + {button} + + {tooltipLabel} + + ); } @@ -450,12 +485,12 @@ export const GlNavButton = forwardRef(function Gl props, forwardedRef, ) { - return useNavButton("GlNavButton", props, forwardedRef); + return useNavButtonInternal("GlNavButton", props, forwardedRef); }); export const GlSubNavButton = forwardRef( function GlSubNavButton(props, forwardedRef) { - return useNavButton("GlSubNavButton", props, forwardedRef); + return useNavButtonInternal("GlSubNavButton", props, forwardedRef); }, ); @@ -472,7 +507,7 @@ export const GlNavItemAddon = forwardRef( {children} @@ -482,17 +517,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,25 +552,56 @@ 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); + if(!nav) invariant("GlNavItem", "must be a direct child of GlNav or GlCollapsibleNav."); - const { button, nodes } = requireSingleButton(children, "GlNavItem"); + const nodes = flattenChildren(children); + const buttons = nodes.filter((node) => hasElementType(node, GlNavButton)); + const toggles = nodes.filter(isCollapsibleNavToggleElement); + 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 = (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) + && !isCollapsibleNavToggleElement(node) + && !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 owner: ButtonOwner = { disabled, hasSubNav: Boolean(subNav), indicatorPosition, + isToggle, level: "nav", selected, }; @@ -562,6 +624,23 @@ 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(nav.kind === "collapsible") { + const resolvedContent = resolveButtonContent(buttonProps.children, "GlNavButton"); + const flyoutLabel = typeof buttonProps["aria-label"] === "string" + ? buttonProps["aria-label"] + : simpleTextLabel(resolvedContent.label) ?? "Navigation"; + + return nav.renderSubNav({ + button, + disabled, + flyoutLabel, + forwardedRef, + item, + owner, + subNav, + }); + } + return ( } @@ -640,18 +719,22 @@ export const GlSubNavItem = forwardRef( }, ); +function validateNavChildren(children: ReactNode) { + const nodes = flattenChildren(children); + if(nodes.some((node) => !hasElementType(node, GlNavItem))) { + invariant("GlNav", "only accepts GlNavItem children."); + } +} + const GlNav = forwardRef(function GlNav({ children, className, ...elementProps }, forwardedRef) { - const nodes = flattenChildren(children); - if(nodes.some((node) => !hasElementType(node, GlNavItem))) { - invariant("GlNav", "only accepts GlNavItem children."); - } + validateNavChildren(children); return ( - + diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0f1ed48..8e36b1c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -303,6 +303,16 @@ export type { GlSubNavItemProps, GlSubNavProps, } from "./base/nav/nav"; +export { + GlCollapsibleNav, + GlCollapsibleNavToggle, + GlNavProvider, +} from "./base/nav/collapsible-nav"; +export type { + GlCollapsibleNavProps, + GlCollapsibleNavToggleProps, + GlNavProviderProps, +} from "./base/nav/collapsible-nav"; export { default as GlMarkdown } from "./base/markdown/markdown"; export type { GlMarkdownProps,