@atlaskit/navigation-system
Version:
The latest navigation system for Atlassian apps.
1,086 lines (1,055 loc) • 46.6 kB
JavaScript
/* 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);
}