UNPKG

@atlaskit/navigation-system

Version:

The latest navigation system for Atlassian apps.

1,086 lines (1,055 loc) • 46.6 kB
/* side-nav.tsx generated by @compiled/babel-plugin v3.0.2 */ import _extends from "@babel/runtime/helpers/extends"; import "./side-nav.compiled.css"; import { ax, ix } from "@compiled/react/runtime"; import React, { startTransition, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { bind } from 'bind-event-listener'; import { useAnalyticsEvents } from '@atlaskit/analytics-next/useAnalyticsEvents'; import mergeRefs from '@atlaskit/ds-lib/merge-refs'; import useStableRef from '@atlaskit/ds-lib/use-stable-ref'; import { OpenLayerObserverNamespaceProvider } from '@atlaskit/layering/open-layer-observer-namespace-provider'; import { useOpenLayerObserver } from '@atlaskit/layering/use-open-layer-observer'; import { fg } from '@atlaskit/platform-feature-flags/fg'; import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/adapter/element-adapter'; // eslint-disable-next-line @atlaskit/design-system/no-emotion-primitives -- TODO: migrate to @atlaskit/primitives/compiled import { hasOpenDialogOrPopover } from '@atlaskit/top-layer/page-surface/has-open-dialog-or-popover'; import { useSkipLinkInternal } from '../../../context/skip-links/use-skip-link-internal'; import { TopNavStartElement } from '../../../context/top-nav-start/top-nav-start-element'; import { UNSAFE_sideNavLayoutVar, defaultMainMinWidth, mainMinimumWidthVar, openLayerObserverSideNavNamespace, sideNavLiveWidthVar, sideNavPanelSplitterId, sideNavVar } from '../constants'; import { DangerouslyHoistCssVarToDocumentRoot } from '../dangerously-hoist-css-var-to-document-root'; import { DangerouslyHoistSlotSizes } from '../hoist-slot-sizes-context'; import { convertResizeBoundToPixels } from '../panel-splitter/convert-resize-bound-to-pixels'; import { PanelSplitterProvider } from '../panel-splitter/provider'; import { resolveLayoutWidth } from '../resolve-layout-width'; import { gridRootChatFitId, gridRootId } from '../root'; import { useLayoutAreaSizing } from '../use-layout-area-sizing'; import { useLayoutId } from '../use-layout-id'; import { useResizingWidthCssVarOnRootElement } from '../use-resizing-width-css-var-on-root-element'; import { useSafeDefaultWidth } from '../use-safe-default-width'; import { bindOpenLayerChange } from './bind-open-layer-change'; import { sideNavFlyoutCloseDelayMs } from './flyout-close-delay-ms'; import { SetSideNavVisibilityState } from './set-side-nav-visibility-state'; import { SideNavToggleButtonElement } from './side-nav-toggle-button-element'; import { sideNavToggleTooltipKeyboardShortcut } from './side-nav-toggle-tooltip-keyboard-shortcut'; import { SideNavVisibilityState } from './side-nav-visibility-state'; import { useExpandSideNav } from './use-expand-side-nav'; import { useIsSideNavShortcutEnabled } from './use-is-side-nav-shortcut-enabled'; import { useSideNavRef } from './use-side-nav-ref'; import { useSideNavToggleKeyboardShortcut } from './use-side-nav-toggle-keyboard-shortcut'; import { useSideNavVisibility } from './use-side-nav-visibility'; import { useSideNavVisibilityCallbacks } from './use-side-nav-visibility-callbacks'; import { useToggleSideNav } from './use-toggle-side-nav'; const panelSplitterResizingVar = '--n_snvRsz'; const fallbackMinWidth = '240px'; function getPixelCustomProperty(element, propertyName, fallback) { const raw = getComputedStyle(element).getPropertyValue(propertyName).trim(); const value = Number.parseFloat(raw); return Number.isFinite(value) ? resolveLayoutWidth(raw.endsWith('vw') ? `${value}vw` : value, window.innerWidth) : fallback; } /** * We are using JS to detect Firefox and disable animations, instead of using CSS, as Compiled currently does not merge duplicate * CSS at-rules when at-rules are nested: https://github.com/atlassian-labs/compiled/blob/e04a325915e1d13010205089e4915de0e53bc2d4/packages/css/src/plugins/merge-duplicate-at-rules.ts#L5 * Avoiding nesting the `@supports` at-rule inside of `@media` means Compiled can remove duplicate styles from the generated CSS. */ const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.toLowerCase().indexOf('firefox') > -1; const textEntryInputTypes = new Set(['text', 'search', 'email', 'url', 'tel', 'password', 'number']); function isEditableContent({ element }) { if (element instanceof HTMLElement && typeof element.isContentEditable === 'boolean') { return element.isContentEditable; } // Fallback for DOM implementations without `isContentEditable`. const editingHost = element.closest('[contenteditable]'); return editingHost !== null && editingHost.getAttribute('contenteditable') !== 'false'; } /** * Returns `true` for an element that the user can type text into. */ function isTextEntryElement({ element }) { if (element instanceof HTMLInputElement) { return textEntryInputTypes.has(element.type) && !element.readOnly && !element.disabled; } if (element instanceof HTMLTextAreaElement) { return !element.readOnly && !element.disabled; } return isEditableContent({ element }); } /** * Used when the `platform-dst-top-layer` gate is on. * * The flyout stays open while a layer opened from the side nav is open, or while the user * is typing in a text field inside the side nav (for example while renaming an item inline). */ function isFlyoutHeldOpen({ nav }) { if (hasOpenDialogOrPopover({ within: nav })) { return true; } const activeElement = nav.ownerDocument.activeElement; return activeElement !== null && nav.contains(activeElement) && isTextEntryElement({ element: activeElement }); } const styles = { root: "_191wglyw _t51zglyw _bfhk1bhr _16qs130s _vchhusvi _4t3ieqxy _152timx3 _kqsw1if8 _1bsb1ego _1pbycs5v _14b5hc79 _qilnk0mc _p5clia51 _4ap3vuon _scbpglyw _89fi1b6c", chatPanelSizing: "_1bsbhc79", managedOverlay: "_cpcf1gzg _6kttglyw _w8v11bhr _1jug130s", flyoutOpen: "_qiln1gzg _p5clglyw _4ap31bhr _scbp130s _139f8iot _1tpvfnf5 _sylc1a5r _oyeijq3t", flyoutAnimateClosed: "_1e0cglyw _qiln1gzg _139f8iot _1tpvfnf5 _sylc1a5r _1uwsjq3t", flexContainer: "_4t3i1osq _1e0c1txw _2lx21bp4 _1bah1yb4", hiddenMobileAndDesktop: "_1e0cglyw", hiddenMobileOnly: "_1e0cglyw _dm2518uv", hiddenDesktopOnly: "_dm25glyw" }; /** * Styles for the side nav enter/exit animations when `platform-dst-motion-uplift-sidenav` is enabled. * * These use the `motion.sidenav.*` tokens (keyframe animations) instead of the `transform` transitions * with `@starting-style` used by `styles`. * * ⚠️ The `transition-duration` values below need to stay in sync with the duration baked into the tokens * (`motion.sidenav.enter.*` is 250ms, `motion.sidenav.exit.*` is 200ms). The `display` property is * transitioned (with `allow-discrete`) so the element stays rendered for the length of the exit animation — * if the transition is shorter than the animation, `display: none` applies early and the exit animation * never runs. */ const motionUpliftStyles = { animationRTLSupport: "_cjprp6j8 _1p2s1vfh _1jq41dy6 _2qe0jwwr", animationBaseStyles: "_10t81t0s _1vrh1a5r _xrrpi9qs", expandAnimationMobile: "_k1af10ed", collapseAnimationMobile: "_1lh81gzg _k1affm5y", flyoutOpen: "_qiln1gzg _p5clglyw _4ap31bhr _scbp130s _4xci10ed _1tpv1mzy _sylc1a5r", flyoutAnimateClosed: "_1e0cglyw _qiln1gzg _4xcifm5y _1tpvi9qs _sylc1a5r", expandAnimationDesktop: "_1sg8k0mc _n2in10ed _1xpz1gzg _17ly1q8v _hjoi147i _1wxo4so3", collapseAnimationDesktop: "_1sg81gzg _n2infm5y _17ly1wzp _hjoi1wsm _1wxo1cio" }; const fallbackDefaultWidth = 320; export const onPeekStartDelayMs = 500; /** * We need an additional component layer so we can wrap the side nav in a `OpenLayerObserver` and have access to the * context value. */ function SideNavInternal({ children, defaultCollapsed, defaultWidth: defaultWidthProp = fallbackDefaultWidth, minWidth = fallbackMinWidth, maxWidth, testId, label = 'Sidebar', skipLinkLabel = label, onExpand, onCollapse, onPeekStart, onPeekEnd, id: providedId, canToggleWithShortcut }) { var _managedSizing$mode, _managedSizing$width, _sideNavState$lastTri; const isChatPanelLayoutEnabled = fg('platform-dst-chat-panel-layout'); const id = useLayoutId({ providedId }); const expandAndFocusSideNav = useExpandSideNav({ trigger: 'skip-link' }); /** * Called after clicking on the side nav skip link, and ensures the side nav is expanded so that it is focusable. * * We need to update the DOM synchronously because `.focus()` is called synchronously after this state update. * * Only used when `platform_dst_nav4_skip_link_a11y_1` is OFF; can be removed on gate cleanup. */ const synchronouslyExpandSideNav = useCallback(() => { flushSync(() => { /** * Calling this unconditionally and relying on it to avoid no-op renders. * * We _could_ call it conditionally, but we'd be duplicating the screen size checks `expandAndFocusSideNav` makes. */ expandAndFocusSideNav(); }); }, [expandAndFocusSideNav]); useSkipLinkInternal({ id, label: skipLinkLabel, /** * `navigate` is the gate-on contract: it owns expanding the side nav AND moving * focus to the first nav item, atomically (via `useExpandSideNav`'s `flushSync`). * * `onBeforeNavigate` is the legacy contract used only when the gate is OFF. * On gate cleanup, drop `onBeforeNavigate` and `synchronouslyExpandSideNav` here. */ navigate: fg('platform_dst_nav4_skip_link_a11y_1') ? expandAndFocusSideNav : undefined, onBeforeNavigate: fg('platform_dst_nav4_skip_link_a11y_1') ? undefined : synchronouslyExpandSideNav }); const sideNavState = useContext(SideNavVisibilityState); const setSideNavState = useContext(SetSideNavVisibilityState); const { isExpandedOnDesktop, isExpandedOnMobile } = useSideNavVisibility({ defaultCollapsed }); // We are placing `defaultCollapsed` into a state container so we can have a stable reference to the initial value. // This is so we can use it in an effect _that only runs once_, after the initial render on the client, // to sync the side nav context (provided in `<Root>`) with the `defaultCollapsed` prop provided to `<SideNav>`. const [initialDefaultCollapsed] = useState(defaultCollapsed); const { createAnalyticsEvent } = useAnalyticsEvents(); const [initialIsExpandedOnDesktop] = useState(isExpandedOnDesktop); /** * Captures the initial collapsed/expanded state of the side nav. * * Only firing on desktop because the nav is never open by default on mobile. */ useEffect(() => { if (initialIsExpandedOnDesktop) { const isDesktop = window.matchMedia('(min-width: 64rem)').matches; if (isDesktop) { const navigationAnalyticsEvent = createAnalyticsEvent({ source: 'topNav', actionSubject: 'sideNav', action: 'viewedOnLoad', actionSubjectId: 'sideNavMenu', attributes: { screen: 'desktop' } }); navigationAnalyticsEvent.fire('navigation'); } } }, [createAnalyticsEvent, initialIsExpandedOnDesktop]); const defaultWidth = useSafeDefaultWidth({ defaultWidthProp, fallbackDefaultWidth, slotName: 'SideNav' }); const [inlineWidth, setInlineWidth] = useState(defaultWidth); const [overlayWidth, setOverlayWidth] = useState(defaultWidth); const [isInline, setIsInline] = useState(false); useEffect(() => { if (!isChatPanelLayoutEnabled) { return; } const mediaQuery = window.matchMedia('(min-width: 64rem)'); setIsInline(mediaQuery.matches); const onChange = event => setIsInline(event.matches); return bind(mediaQuery, { type: 'change', listener: onChange }); }, [isChatPanelLayoutEnabled]); const toggleVisibilityForChatPanel = useToggleSideNav({ trigger: 'programmatic' }); const closeOverlayForChatPanel = useCallback(() => { // Allocation can overlay an expanded desktop nav when other slots consume // its space. Dismiss the visibility state selected by the viewport, not the mode. const isDesktop = window.matchMedia('(min-width: 64rem)').matches; if (isDesktop ? isExpandedOnDesktop : isExpandedOnMobile) { toggleVisibilityForChatPanel(); } }, [isExpandedOnDesktop, isExpandedOnMobile, toggleVisibilityForChatPanel]); const { state: managedSizing, isManaged, prepareResize, getResizeBounds: getManagedResizeBounds, startResize, resize, completeResize } = useLayoutAreaSizing({ area: 'side-nav', config: { defaultWidth, minWidth, maxWidth, isOpen: isInline ? isExpandedOnDesktop : isExpandedOnMobile, onRequestClose: closeOverlayForChatPanel } }); const mode = (_managedSizing$mode = managedSizing === null || managedSizing === void 0 ? void 0 : managedSizing.mode) !== null && _managedSizing$mode !== void 0 ? _managedSizing$mode : isInline ? 'inline' : 'overlay'; const renderedWidth = isChatPanelLayoutEnabled ? (_managedSizing$width = managedSizing === null || managedSizing === void 0 ? void 0 : managedSizing.width) !== null && _managedSizing$width !== void 0 ? _managedSizing$width : mode === 'inline' ? inlineWidth : overlayWidth : inlineWidth; const configuredMaxWidth = maxWidth !== null && maxWidth !== void 0 ? maxWidth : '100vw'; const clampedWidth = !isChatPanelLayoutEnabled ? `clamp(240px, ${inlineWidth}px, 50vw)` : managedSizing ? `${renderedWidth}px` : `clamp(${minWidth}, ${renderedWidth}px, ${configuredMaxWidth})`; const dangerouslyHoistSlotSizes = useContext(DangerouslyHoistSlotSizes); const navRef = useRef(null); const getResizeBounds = useCallback(() => { if (!isChatPanelLayoutEnabled) { return { min: '240px', max: '50vw' }; } const managedBounds = getManagedResizeBounds(); if (managedBounds) { return managedBounds; } const sideNav = navRef.current; const layoutRoot = sideNav === null || sideNav === void 0 ? void 0 : sideNav.parentElement; const main = layoutRoot === null || layoutRoot === void 0 ? void 0 : layoutRoot.querySelector(':scope > [data-layout-slot][role="main"]'); const fallbackMaxWidth = maxWidth !== null && maxWidth !== void 0 ? maxWidth : mode === 'inline' ? '100vw' : '90vw'; if (!sideNav || mode === 'overlay') { const chatPanel = layoutRoot === null || layoutRoot === void 0 ? void 0 : layoutRoot.querySelector(':scope > [data-layout-chat-panel]'); const inlineChatPanelWidth = chatPanel && getComputedStyle(chatPanel).gridArea === 'chat-panel' ? chatPanel.getBoundingClientRect().width : 0; const overlayMaxWidth = Math.floor((window.innerWidth - inlineChatPanelWidth) * 0.9); const constrainedMaxWidth = maxWidth ? Math.min(convertResizeBoundToPixels(maxWidth), overlayMaxWidth) : overlayMaxWidth; return { min: minWidth, max: `${Math.max(convertResizeBoundToPixels(minWidth), constrainedMaxWidth)}px` }; } if (!main) { return { min: minWidth, max: fallbackMaxWidth }; } const sideNavWidth = sideNav.getBoundingClientRect().width; const mainWidth = main.getBoundingClientRect().width; // JSDOM does not perform layout, so retain the static fallback in unit tests and other // environments where used widths cannot be measured. if (sideNavWidth === 0 || mainWidth === 0) { return { min: minWidth, max: fallbackMaxWidth }; } const localPanel = main.querySelector('[data-layout-with-panel-slot]'); const isLocalPanelInline = localPanel && getComputedStyle(localPanel).gridArea === 'panel'; const localPanelWidth = isLocalPanelInline ? localPanel.getBoundingClientRect().width : 0; const mainContentWidth = mainWidth - localPanelWidth; const mainMinimumWidth = getPixelCustomProperty(main, mainMinimumWidthVar, defaultMainMinWidth); const availableWidth = Math.max(0, mainContentWidth - mainMinimumWidth); const availableInlineWidth = Math.floor(sideNavWidth + availableWidth); const constrainedMaxWidth = maxWidth ? Math.min(convertResizeBoundToPixels(maxWidth), availableInlineWidth) : availableInlineWidth; return { min: minWidth, max: `${Math.max(convertResizeBoundToPixels(minWidth), constrainedMaxWidth)}px` }; }, [getManagedResizeBounds, isChatPanelLayoutEnabled, maxWidth, minWidth, mode]); /** * Used to share the side nav element with the `Panel`, * which observes the side nav to determine its maximum width. */ const sharedRef = useSideNavRef(); const mergedRef = mergeRefs([navRef, sharedRef]); const onCompleteResize = useCallback(finalWidth => { if (!isChatPanelLayoutEnabled) { setInlineWidth(finalWidth); return; } if (mode === 'inline') { setInlineWidth(finalWidth); } else { setOverlayWidth(finalWidth); } completeResize(mode, finalWidth); }, [completeResize, isChatPanelLayoutEnabled, mode]); const onResizeStartInternal = useCallback(() => { startResize(mode); }, [mode, startResize]); const onResizeInternal = useCallback(width => resize(mode, width), [mode, resize]); const toggleButtonElement = useContext(SideNavToggleButtonElement); const topNavStartElement = useContext(TopNavStartElement); const devTimeOnlyAttributes = {}; const openLayerObserver = useOpenLayerObserver({ isEnabled: !fg('platform-dst-top-layer') }); const flyoutStateRef = useRef({ type: 'not-active' }); const isFlyoutVisible = (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.flyout) === 'open'; // Flyouts, including their close animation, already have overlay placement. // The CSS-first inline allocation must not override it with an ID selector. const shouldApplyUnmanagedSizing = !managedSizing && (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.flyout) !== 'open' && (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.flyout) !== 'triggered-animate-close'; const isExpandedOnDesktopRef = useStableRef(isExpandedOnDesktop); const hasPeekStartedRef = useRef(false); const onPeekStartRef = useStableRef(onPeekStart); const onPeekEndRef = useStableRef(onPeekEnd); const onPeekStartTimeoutIdRef = useRef(undefined); useEffect(() => { return () => { clearTimeout(onPeekStartTimeoutIdRef.current); }; }, []); const updateFlyoutState = useMemo(() => { function tryAbortPendingClose() { if (flyoutStateRef.current.type === 'waiting-for-close') { flyoutStateRef.current.abort(); } } function open() { const prevFlyoutState = flyoutStateRef.current; tryAbortPendingClose(); flyoutStateRef.current = { type: 'open' }; setSideNavState(currentState => { if ((currentState === null || currentState === void 0 ? void 0 : currentState.desktop) === 'collapsed' && (currentState === null || currentState === void 0 ? void 0 : currentState.flyout) !== 'open') { return { ...currentState, flyout: 'open' }; } return currentState; }); // Avoid redundant calls to `onPeekStart()` if (prevFlyoutState.type === 'not-active') { clearTimeout(onPeekStartTimeoutIdRef.current); onPeekStartTimeoutIdRef.current = setTimeout(() => { var _onPeekStartRef$curre; // If the flyout isn't still open after ~500ms then we won't count the peek // As we want to track user intention rather than all hovers if (isExpandedOnDesktopRef.current || flyoutStateRef.current.type !== 'open') { return; } hasPeekStartedRef.current = true; (_onPeekStartRef$curre = onPeekStartRef.current) === null || _onPeekStartRef$curre === void 0 ? void 0 : _onPeekStartRef$curre.call(onPeekStartRef); }, onPeekStartDelayMs); } } function close() { const prevFlyoutState = flyoutStateRef.current; tryAbortPendingClose(); flyoutStateRef.current = { type: 'not-active' }; setSideNavState(currentState => { if ((currentState === null || currentState === void 0 ? void 0 : currentState.desktop) === 'collapsed' && (currentState === null || currentState === void 0 ? void 0 : currentState.flyout) === 'open') { return { ...currentState, flyout: 'triggered-animate-close' }; } return currentState; }); // Avoid redundant calls to `onPeekEnd()` if (prevFlyoutState.type !== 'not-active' && hasPeekStartedRef.current) { var _onPeekEndRef$current; hasPeekStartedRef.current = false; (_onPeekEndRef$current = onPeekEndRef.current) === null || _onPeekEndRef$current === void 0 ? void 0 : _onPeekEndRef$current.call(onPeekEndRef, { trigger: isExpandedOnDesktopRef.current ? 'side-nav-expand' : 'mouse-leave' }); } } function canClose() { if (!fg('platform-dst-top-layer')) { return (openLayerObserver === null || openLayerObserver === void 0 ? void 0 : openLayerObserver.getCount({ namespace: openLayerObserverSideNavNamespace })) === 0; } const nav = navRef.current; return !(nav && isFlyoutHeldOpen({ nav })); } return function onAction(action) { if (action === 'drag-from-flyout-started') { open(); flyoutStateRef.current = { type: 'is-dragging-from-flyout' }; return; } if (action === 'drag-from-flyout-finished') { open(); return; } // ignoring all actions until the drag is finished if (flyoutStateRef.current.type === 'is-dragging-from-flyout') { return; } if (action === 'open') { open(); return; } if (action === 'waiting-for-close') { if (flyoutStateRef.current.type === 'waiting-for-close') { return; } // A timeout is used to close the flyout after a delay when the user mouses out of the flyout area, and to allow // us to cancel the close if the user mouses back in. const timeout = setTimeout(() => { updateFlyoutState('ready-to-close'); }, sideNavFlyoutCloseDelayMs); flyoutStateRef.current = { type: 'waiting-for-close', abort() { clearTimeout(timeout); } }; return; } if (action === 'ready-to-close') { // If there are no open layers, we can close the flyout. if (canClose()) { close(); return; } flyoutStateRef.current = { type: 'ready-to-close' }; return; } if (action === 'force-close') { close(); return; } }; }, [isExpandedOnDesktopRef, onPeekEndRef, onPeekStartRef, openLayerObserver, setSideNavState]); const toggleVisibilityByScreenResize = useToggleSideNav({ trigger: 'screen-resize' }); const toggleVisibilityByClickOutsideOnMobile = useToggleSideNav({ trigger: 'click-outside-on-mobile' }); // Using `useLayoutEffect` so that there's no visual shift in non-SSR environments when using legacy API // For SSR the new API is still necessary useLayoutEffect(() => { if (sideNavState !== null) { // Only need to do an initial sync if it hasn't been initialized from Root return; } // Sync the visibility in context (provided in `<Root>`) with the local `defaultCollapsed` prop provided to `SideNav` // after SSR hydration. This should only run once, after the initial render on the client. startTransition(() => { setSideNavState({ desktop: initialDefaultCollapsed ? 'collapsed' : 'expanded', mobile: 'collapsed', flyout: 'closed', lastTrigger: null }); }); }, [initialDefaultCollapsed, setSideNavState, sideNavState]); const handleExpand = useCallback(({ screen, trigger }) => { onExpand === null || onExpand === void 0 ? void 0 : onExpand({ screen, trigger }); const navigationAnalyticsEvent = createAnalyticsEvent({ source: 'topNav', actionSubject: 'sideNav', action: 'expanded', actionSubjectId: 'sideNavMenu', attributes: { trigger } }); navigationAnalyticsEvent.fire('navigation'); // When the side nav gets expanded, we close the flyout to reset it. // This prevents the flyout from staying open and ensures we are respecting the user's intent to expand. updateFlyoutState('force-close'); }, [onExpand, updateFlyoutState, createAnalyticsEvent]); const handleCollapse = useCallback(({ screen, trigger }) => { onCollapse === null || onCollapse === void 0 ? void 0 : onCollapse({ screen, trigger }); const navigationAnalyticsEvent = createAnalyticsEvent({ source: 'topNav', actionSubject: 'sideNav', action: 'collapsed', actionSubjectId: 'sideNavMenu', attributes: { trigger } }); navigationAnalyticsEvent.fire('navigation'); // When the side nav gets collapsed, we close the flyout to reset it. // This prevents the flyout from staying open and ensures we are respecting the user's intent to collapse. updateFlyoutState('force-close'); }, [onCollapse, updateFlyoutState, createAnalyticsEvent]); useSideNavVisibilityCallbacks({ onExpand: handleExpand, onCollapse: handleCollapse, isExpandedOnDesktop, isExpandedOnMobile, lastTrigger: (_sideNavState$lastTri = sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.lastTrigger) !== null && _sideNavState$lastTri !== void 0 ? _sideNavState$lastTri : null }); useEffect(() => { const mediaQueryList = window.matchMedia('(min-width: 64rem)'); return bind(mediaQueryList, { type: 'change', listener() { if (mediaQueryList.matches) { // We're transitioning from tablet to desktop viewport size. // We forcibly show the side nav if it was shown on mobile. if (isExpandedOnMobile && !isExpandedOnDesktop) { toggleVisibilityByScreenResize(); } } } }); }, [toggleVisibilityByScreenResize, isExpandedOnDesktop, isExpandedOnMobile]); /** * Close the mobile side nav if there is a click outside. * * Binding/unbinding on changes to `isExpandedOnMobile` instead of window size changes for simplicity. * If we listened to window size changes we'd need nested `bind()` calls. */ useEffect(() => { if (!isExpandedOnMobile) { return; } /** * The expected URL fragment used by the side nav skip link. */ const skipLinkUrlFragment = `#${id}`; return bind(window, { type: 'click', listener(event) { if (window.matchMedia('(min-width: 64rem)').matches) { // Clicks on desktop should do nothing return; } const sideNav = navRef.current; if (!sideNav || !(event.target instanceof Node)) { // Ignore if our element references are invalid return; } if (!event.target.isConnected) { /** * If the element that was clicked is no longer in the document, we should not collapse the side nav. * This can happen when the user clicks on a dropdown menu item (such as one used in a `...` More menu), * as the dropdown menu will close after being clicked. By the time this event listener runs, the clicked * element will no longer be in the document, so the check below for `sideNav.contains(event.target)` will fail. */ return; } if (sideNav.contains(event.target)) { // Clicks inside the side nav should not close it return; } if (toggleButtonElement !== null && toggleButtonElement !== void 0 && toggleButtonElement.contains(event.target)) { // Clicks on the toggle button will already toggle the side nav, // so we need to ignore it or we end up doing a double toggle return; } if (event.target instanceof HTMLAnchorElement && /** * Intentionally using `.getAttribute()` for simplicity to match exactly what we expect * for skip links. * * Alternatives considered: * * - Using `.href` returns a full URL, which would then require extra parsing. * - Using `.hash` could incorrectly capture `<a href="/another-page#skip-link-id>` so we'd need extra checks. * * This approach has an edge case of `<a href="/same-page#skip-link-id>` not being captured, * but we are okay with that trade-off. */ event.target.getAttribute('href') === skipLinkUrlFragment) { // This was a click on a link to the side nav, so we shouldn't try to collapse the SideNav. // It was most likely the skip link, although anyone can render a link with the ID. // In either case keeping the SideNav expanded makes sense. return; } toggleVisibilityByClickOutsideOnMobile(); } }); }, [id, isExpandedOnMobile, toggleButtonElement, toggleVisibilityByClickOutsideOnMobile]); useEffect(() => { if (!toggleButtonElement) { return; } return bind(toggleButtonElement, { type: 'mouseenter', listener() { // Only flyout the side nav if the user hovered while the side nav was collapsed if (isExpandedOnDesktop) { return; } // Only flyout the side nav on desktop viewports const { matches } = window.matchMedia('(min-width: 64rem)'); if (matches) { updateFlyoutState('open'); } } }); }, [updateFlyoutState, toggleButtonElement, isExpandedOnDesktop]); useEffect(() => { if (!toggleButtonElement) { return; } return bind(toggleButtonElement, { type: 'mouseleave', listener() { // If the side nav is not in flyout mode, we don't need to do anything if (!isFlyoutVisible) { return; } updateFlyoutState('waiting-for-close'); } }); }, [isFlyoutVisible, toggleButtonElement, updateFlyoutState]); useEffect(() => { if (!navRef.current) { return; } return bind(navRef.current, { type: 'mouseenter', listener() { // If the side nav is not in flyout mode, we don't need to do anything if (isExpandedOnDesktop || !isFlyoutVisible) { return; } // The user mouses has moused back over the side nav updateFlyoutState('open'); } }); }, [isFlyoutVisible, updateFlyoutState, isExpandedOnDesktop]); useEffect(() => { if (!navRef.current) { return; } return bind(navRef.current, { type: 'mouseleave', listener() { // If the side nav is not in flyout mode, we don't need to do anything if (!isFlyoutVisible) { return; } updateFlyoutState('waiting-for-close'); } }); }, [isFlyoutVisible, updateFlyoutState]); useEffect(() => { const nav = navRef.current; if (!nav) { return; } if (!isFlyoutVisible) { return; } // Using a monitor rather than a drop target. Rationale: // - We are not creating a drop target for the users to drop on, // we are just interested in listening to events within an element // - We do not want to impact `location.{*}.dropTargets` in events return monitorForElements({ canMonitor({ source }) { return nav.contains(source.element); }, onGenerateDragPreview() { updateFlyoutState('drag-from-flyout-started'); }, onDrop({ location }) { // Always marking the drag and done updateFlyoutState('drag-from-flyout-finished'); // If the user dropped outside of the flyout, we will close the flyout const underUsersPointer = document.elementFromPoint(location.current.input.clientX, location.current.input.clientY); if (!nav.contains(underUsersPointer)) { updateFlyoutState('waiting-for-close'); } } }); }, [isFlyoutVisible, updateFlyoutState]); useEffect(() => { if (!topNavStartElement || !toggleButtonElement) { return; } return bind(topNavStartElement, { type: 'mouseover', listener(event) { // If the side nav is not in flyout mode, we don't need to do anything if (isExpandedOnDesktop || !isFlyoutVisible) { return; } if (event.target === topNavStartElement) { // The mouse is directly over the TopNavStart element, so cancel any pending flyout closes. updateFlyoutState('open'); return; } if (event.target instanceof Element && toggleButtonElement.contains(event.target)) { // The mouse is over the toggle button or any of its children, so we don't want to close the flyout. // We also don't need to cancel any pending closes, as we have separate event listeners for the toggle button mouse events. return; } // The user has moused over a child element of the TopNavStart element that isn't the toggle button, e.g. the app switcher or nav logo, // so we should close the flyout (with a delay). updateFlyoutState('waiting-for-close'); } }); }, [topNavStartElement, isFlyoutVisible, toggleButtonElement, isExpandedOnDesktop, updateFlyoutState]); useEffect(() => { if (!topNavStartElement) { return; } return bind(topNavStartElement, { type: 'mouseleave', listener() { // If the side nav is not in flyout mode, we don't need to do anything if (!isFlyoutVisible) { return; } // The mouse has left the TopNavStart element, so we should close the flyout with a delay. updateFlyoutState('waiting-for-close'); } }); }, [topNavStartElement, isFlyoutVisible, updateFlyoutState]); useEffect(() => { // Close the flyout if there are no more layers open and the user is not mousing over the // flyout areas (side nav, TopNavStart element) return openLayerObserver === null || openLayerObserver === void 0 ? void 0 : openLayerObserver.onChange(({ count }) => { if (flyoutStateRef.current.type === 'ready-to-close' && count === 0) { updateFlyoutState('force-close'); } }, { namespace: openLayerObserverSideNavNamespace }); }, [openLayerObserver, updateFlyoutState]); const focusOutTimeoutIdRef = useRef(undefined); useEffect(() => { // The same as the open layer observer `onChange` above, for when the `platform-dst-top-layer` // gate is on. Top-layer surfaces opened from the side nav are DOM descendants of it. const nav = navRef.current; if (!nav || !fg('platform-dst-top-layer')) { return; } function closeIfReady() { if (flyoutStateRef.current.type !== 'ready-to-close' || !nav) { return; } if (isFlyoutHeldOpen({ nav })) { return; } updateFlyoutState('force-close'); } const unbindLayerChange = bindOpenLayerChange({ within: nav, onChange: closeIfReady }); const unbindFocusOut = bind(nav, { type: 'focusout', listener() { // Wait for focus to move before checking where it went. clearTimeout(focusOutTimeoutIdRef.current); focusOutTimeoutIdRef.current = setTimeout(closeIfReady, 0); } }); // Removing the focused text field (for example when an inline rename ends) fires no // `focusout` in some browsers, so also check again when content inside the side nav changes. const removalObserver = new MutationObserver(closeIfReady); removalObserver.observe(nav, { childList: true, subtree: true }); return function cleanup() { unbindLayerChange(); unbindFocusOut(); removalObserver.disconnect(); clearTimeout(focusOutTimeoutIdRef.current); }; }, [updateFlyoutState]); useEffect(() => { // Clear flyout close timer if being unmounted return function cleanupPendingFlyoutClose() { if (flyoutStateRef.current.type === 'waiting-for-close') { flyoutStateRef.current.abort(); } }; }, []); if (process.env.NODE_ENV !== 'production') { const visible = []; if (isExpandedOnMobile) { visible.push('small'); } if (isExpandedOnDesktop) { visible.push('large'); } if (isFlyoutVisible) { visible.push('flyout'); } devTimeOnlyAttributes['data-visible'] = visible.length ? visible.join(',') : 'false'; } useSideNavToggleKeyboardShortcut({ canToggleWithShortcut }); // Used to conditionally display the keyboard shortcut in the SideNavPanelSplitter tooltip. const isShortcutEnabled = useIsSideNavShortcutEnabled(); useResizingWidthCssVarOnRootElement({ isEnabled: !isChatPanelLayoutEnabled || !managedSizing, cssVar: panelSplitterResizingVar, panelId: sideNavPanelSplitterId }); const isExpandedStateDifferentFromInitial = isExpandedOnMobile || isExpandedOnDesktop !== initialIsExpandedOnDesktop; /** * Used to track (between renders) if the expanded state of the side nav has ever changed since mount. * * Cannot rely solely on `isExpandedStateDifferentFromInitial` because the expanded state can go back to the initial state. */ const hasExpandedStateChangedRef = useRef(false); useEffect(() => { if (hasExpandedStateChangedRef.current) { return; } if (isExpandedStateDifferentFromInitial) { hasExpandedStateChangedRef.current = true; } }, [isExpandedStateDifferentFromInitial]); /** * If the expanded state of the side nav has ever changed since mount. * * Used to prevent sidebar expand animations from running on the initial load. * Otherwise, the `@starting-style` rule will cause the sidebar to slide in initially. */ const hasExpandedStateChanged = isExpandedStateDifferentFromInitial || hasExpandedStateChangedRef.current; const isMotionUpliftEnabled = fg('platform-dst-motion-uplift-sidenav'); // This is only used for the regular expand and collapse animations, not the flyout animations. const shouldShowSidebarToggleAnimation = isMotionUpliftEnabled && // We do not apply the animation styles on the initial render, as the `@starting-style` rule will cause the sidebar to // slide in initially. hasExpandedStateChanged && // We also do not apply the animation styles if the side nav is in flyout mode to make sure we don't override flyout styles. // If we instead try to unset the `transform` property in the flyout styles using `transform: initial`, it results in the entire // flyout animation being disabled. !isFlyoutVisible && /** * Disabling animations for Firefox, as it doesn't support animating the `display` property: * https://caniuse.com/mdn-css_properties_display_is_transitionable * * As of October 2025, Firefox supports the expand animation, but not the collapse animation (it instantly changes to `display: none`). * Because of this, we are disabling all sidebar animations for Firefox so it doesn't look like a bug. * * Unfortunately we cannot use `@supports` to target the support of `transition-behavior: allow-discrete` specifically * for the `display` property. And `@supports at-rule(@starting-style)` is also not ready for browser use yet. * * We are using JS to detect Firefox and disable animations, instead of using CSS, as Compiled currently does not merge duplicate * CSS at-rules when at-rules are nested: https://github.com/atlassian-labs/compiled/blob/e04a325915e1d13010205089e4915de0e53bc2d4/packages/css/src/plugins/merge-duplicate-at-rules.ts#L5 * Avoiding nesting the `@supports` at-rule inside of `@media` means Compiled can remove duplicate styles from the generated CSS. */ !isFirefox; return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("nav", _extends({ id: id }, devTimeOnlyAttributes, { "data-layout-slot": true, "aria-label": label, style: { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/enforce-style-prop [sideNavVar]: clampedWidth }, ref: mergedRef, "data-testid": testId, className: ax([styles.root, isChatPanelLayoutEnabled && styles.chatPanelSizing, isChatPanelLayoutEnabled && (managedSizing === null || managedSizing === void 0 ? void 0 : managedSizing.mode) === 'overlay' && styles.managedOverlay, isExpandedOnDesktop && !isExpandedOnMobile && !isFlyoutVisible && styles.hiddenMobileOnly, !isExpandedOnDesktop && isExpandedOnMobile && !isFlyoutVisible && styles.hiddenDesktopOnly, !isExpandedOnDesktop && !isExpandedOnMobile && !isFlyoutVisible && styles.hiddenMobileAndDesktop, isMotionUpliftEnabled && motionUpliftStyles.animationRTLSupport, shouldShowSidebarToggleAnimation && isMotionUpliftEnabled && motionUpliftStyles.animationBaseStyles, isExpandedOnMobile && shouldShowSidebarToggleAnimation && isMotionUpliftEnabled && motionUpliftStyles.expandAnimationMobile, !isExpandedOnMobile && shouldShowSidebarToggleAnimation && isMotionUpliftEnabled && motionUpliftStyles.collapseAnimationMobile, isExpandedOnDesktop && shouldShowSidebarToggleAnimation && isMotionUpliftEnabled && motionUpliftStyles.expandAnimationDesktop, !isExpandedOnDesktop && shouldShowSidebarToggleAnimation && isMotionUpliftEnabled && motionUpliftStyles.collapseAnimationDesktop, (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.flyout) === 'open' && isMotionUpliftEnabled && motionUpliftStyles.flyoutOpen, (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.flyout) === 'open' && !isMotionUpliftEnabled && styles.flyoutOpen, (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.flyout) === 'triggered-animate-close' && isMotionUpliftEnabled && motionUpliftStyles.flyoutAnimateClosed, (sideNavState === null || sideNavState === void 0 ? void 0 : sideNavState.flyout) === 'triggered-animate-close' && !isMotionUpliftEnabled && styles.flyoutAnimateClosed]) }), isChatPanelLayoutEnabled && /*#__PURE__*/ // CSS reserves only the nav minimum for chat's fit decision, before effects or lazy loading. // eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles React.createElement("style", null, ` #${gridRootChatFitId} { --n_fitNav: 0px; } ${shouldApplyUnmanagedSizing ? `#${gridRootId} nav[data-layout-slot] { grid-area: main / aside / aside / aside; width: min(90%, ${clampedWidth}); }` : ''} @media (min-width: 64rem) { @container navigation-main-space (min-width: ${minWidth}) { #${gridRootChatFitId} { --n_fitNav: ${isExpandedOnDesktop ? minWidth : '0px'}; } ${shouldApplyUnmanagedSizing ? `#${gridRootId} nav[data-layout-slot] { grid-area: side-nav; width: min(${clampedWidth}, calc(100cqw + var(--n_fitNav) - var(--n_chatTrack, 0px))); }` : ''} } } `), /*#__PURE__*/React.createElement(DangerouslyHoistCssVarToDocumentRoot, { variableName: sideNavLiveWidthVar, value: "0px", mediaQuery: '@media (min-width: 64rem)', responsiveValue: isExpandedOnDesktop ? `var(${panelSplitterResizingVar}, ${clampedWidth})` : 0 }), dangerouslyHoistSlotSizes && /*#__PURE__*/ // ------ START UNSAFE STYLES ------ // These styles are only needed for the UNSAFE legacy use case for Jira + Confluence. // When they aren't needed anymore we can delete them wholesale. React.createElement(DangerouslyHoistCssVarToDocumentRoot, { variableName: UNSAFE_sideNavLayoutVar, value: `var(${sideNavLiveWidthVar})` }) // ------ END UNSAFE STYLES ------ , /*#__PURE__*/React.createElement(PanelSplitterProvider, { panelId: sideNavPanelSplitterId, panelRef: navRef, panelWidth: renderedWidth, onCompleteResize: onCompleteResize, onResizeStartInternal: isChatPanelLayoutEnabled && isManaged ? onResizeStartInternal : undefined, onResizeInternal: isChatPanelLayoutEnabled && isManaged ? onResizeInternal : undefined, prepareResize: isChatPanelLayoutEnabled ? prepareResize : undefined, getResizeBounds: getResizeBounds, resizingCssVar: panelSplitterResizingVar // Not resizable when in peek (flyout) mode. , isEnabled: !isFlyoutVisible, shortcut: isShortcutEnabled ? sideNavToggleTooltipKeyboardShortcut : undefined }, /*#__PURE__*/React.createElement("div", { className: ax([styles.flexContainer]) }, children)))); } /** * The side navigation layout area. It will show on the left (inline start) of the screen. * * Use the side nav area components (`SideNavHeader`, `SideNavBody`, `SideNavFooter`) to position * content within areas of the side nav. * * You can optionally render a `PanelSplitter` as a child to make the side navigation slot resizable. */ export function SideNav({ children, defaultCollapsed, defaultWidth = 320, minWidth, maxWidth, testId, label, // Default value is defined in `SideNavInternal` skipLinkLabel = label, // Default value is defined in `SideNavInternal` onExpand, onCollapse, onPeekStart, onPeekEnd, canToggleWithShortcut, id }) { const sideNav = /*#__PURE__*/React.createElement(SideNavInternal, { defaultCollapsed: defaultCollapsed, defaultWidth: defaultWidth, minWidth: minWidth, maxWidth: maxWidth, testId: testId, label: label, skipLinkLabel: skipLinkLabel, onExpand: onExpand, onCollapse: onCollapse, onPeekStart: onPeekStart, onPeekEnd: onPeekEnd, id: id, canToggleWithShortcut: canToggleWithShortcut }, children); // Top-layer surfaces are found through the DOM, so the namespace is only needed when the gate is off. if (fg('platform-dst-top-layer')) { return sideNav; } return /*#__PURE__*/React.createElement(OpenLayerObserverNamespaceProvider, { namespace: openLayerObserverSideNavNamespace }, sideNav); }