UNPKG

@atlaskit/navigation-system

Version:

The latest navigation system for Atlassian apps.

420 lines (418 loc) • 18.8 kB
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { bind } from 'bind-event-listener'; import { calculateLayoutAreaStates } from './calculate-layout-area-states'; import { defaultMainMinWidth } from './constants'; import { LayoutAreaSizingContext } from './layout-area-sizing-context'; import { layoutAreaStateContexts } from './layout-area-state-contexts'; import { resolveLayoutWidth } from './resolve-layout-width'; import { useOccupiedLayoutWidth } from './use-occupied-layout-width'; function useStableAreaState(state) { const { mode, width, minWidth } = state !== null && state !== void 0 ? state : {}; return useMemo(() => mode !== undefined && width !== undefined && minWidth !== undefined ? { mode, width, minWidth } : undefined, [mode, width, minWidth]); } export function LayoutAreaSizingRuntime({ children, layoutRef, onReady, onUpdate }) { const occupiedWidth = useOccupiedLayoutWidth(layoutRef); const [dimensions, setDimensions] = useState(null); const [mainMinWidth, setMainMinWidth] = useState(defaultMainMinWidth); const [areas, setAreas] = useState({}); const [resizeSession, setResizeSession] = useState(null); const areaStatesRef = React.useRef({}); const configsRef = React.useRef({}); const [hasInteraction, setHasInteraction] = useState(false); const adoptedConstraints = React.useRef(null); // Preserve the CSS handoff only while its available space is unchanged. const isPreservingInitialLayout = !hasInteraction && (!adoptedConstraints.current || adoptedConstraints.current.occupiedWidth === occupiedWidth && adoptedConstraints.current.mainMinWidth === mainMinWidth); const [initialStates, setInitialStates] = useState({}); const inputsRef = React.useRef({ dimensions, occupiedWidth, mainMinWidth, areas, resizeSession }); useEffect(() => { inputsRef.current = { dimensions, occupiedWidth, mainMinWidth, areas, resizeSession }; }, [dimensions, occupiedWidth, mainMinWidth, areas, resizeSession]); useEffect(() => { if (adoptedConstraints.current && (adoptedConstraints.current.occupiedWidth !== occupiedWidth || adoptedConstraints.current.mainMinWidth !== mainMinWidth)) { setHasInteraction(true); } setResizeSession(null); }, [mainMinWidth, occupiedWidth]); useEffect(() => { const root = layoutRef === null || layoutRef === void 0 ? void 0 : layoutRef.current; let previous = null; // Match the actual rem-based grid breakpoint, including browser font settings. const inlineChatQuery = window.matchMedia('(min-width: 40rem)'); const inlineSideNavQuery = window.matchMedia('(min-width: 64rem)'); const updateDimensions = contentWidth => { var _style$width, _style$paddingLeft, _style$paddingRight, _style$borderLeftWidt, _style$borderRightWid, _previous; const style = root && contentWidth === undefined ? getComputedStyle(root) : null; const computedWidth = Number.parseFloat((_style$width = style === null || style === void 0 ? void 0 : style.width) !== null && _style$width !== void 0 ? _style$width : ''); const padding = (parseFloat((_style$paddingLeft = style === null || style === void 0 ? void 0 : style.paddingLeft) !== null && _style$paddingLeft !== void 0 ? _style$paddingLeft : '') || 0) + (parseFloat((_style$paddingRight = style === null || style === void 0 ? void 0 : style.paddingRight) !== null && _style$paddingRight !== void 0 ? _style$paddingRight : '') || 0); const border = (parseFloat((_style$borderLeftWidt = style === null || style === void 0 ? void 0 : style.borderLeftWidth) !== null && _style$borderLeftWidt !== void 0 ? _style$borderLeftWidt : '') || 0) + (parseFloat((_style$borderRightWid = style === null || style === void 0 ? void 0 : style.borderRightWidth) !== null && _style$borderRightWid !== void 0 ? _style$borderRightWid : '') || 0); const layoutWidth = contentWidth !== null && contentWidth !== void 0 ? contentWidth : root ? // Keep fractional CSS pixels, just as ResizeObserver does. Otherwise // its initial delivery could be mistaken for a viewport resize. Math.max(0, Number.isFinite(computedWidth) ? computedWidth - ((style === null || style === void 0 ? void 0 : style.boxSizing) === 'border-box' ? padding + border : 0) : root.clientWidth - padding) : window.innerWidth; const next = { viewportWidth: window.innerWidth, layoutWidth, isChatPanelOverlay: !inlineChatQuery.matches, isSideNavOverlay: !inlineSideNavQuery.matches }; if (((_previous = previous) === null || _previous === void 0 ? void 0 : _previous.viewportWidth) === next.viewportWidth && previous.layoutWidth === next.layoutWidth && previous.isChatPanelOverlay === next.isChatPanelOverlay && previous.isSideNavOverlay === next.isSideNavOverlay) { return; } if (previous) { setHasInteraction(true); } previous = next; setDimensions(next); setResizeSession(null); }; updateDimensions(); const observer = new ResizeObserver(entries => { const entry = entries.find(entry => entry.target === root); if (entry) { updateDimensions(entry.contentRect.width); } }); if (root) { observer.observe(root); } const unbindResize = bind(window, { type: 'resize', listener: () => updateDimensions() }); const unbindQuery = bind(inlineChatQuery, { type: 'change', listener: () => updateDimensions() }); const unbindSideNavQuery = bind(inlineSideNavQuery, { type: 'change', listener: () => updateDimensions() }); return () => { observer.disconnect(); unbindResize(); unbindQuery(); unbindSideNavQuery(); }; }, [layoutRef]); const registerArea = useCallback((area, config) => { const previous = configsRef.current[area]; if (adoptedConstraints.current && (!previous || previous.isOpen !== config.isOpen || previous.requestedWidth !== config.requestedWidth || previous.minWidth !== config.minWidth || previous.maxWidth !== config.maxWidth || previous.defaultWidth !== config.defaultWidth || previous.compactDefaultWidth !== config.compactDefaultWidth)) { setHasInteraction(true); } configsRef.current[area] = config; setResizeSession(current => { // A changed layout/configuration needs a fresh allocation. A controlled // acknowledgement for the resized area keeps its siblings fixed. return previous && (previous.isOpen === config.isOpen || area === 'side-nav' && !config.isOpen && (current === null || current === void 0 ? void 0 : current.area) !== 'side-nav') && previous.minWidth === config.minWidth && previous.maxWidth === config.maxWidth && previous.defaultWidth === config.defaultWidth && previous.compactDefaultWidth === config.compactDefaultWidth && (previous.requestedWidth === config.requestedWidth || (current === null || current === void 0 ? void 0 : current.area) === area && current.completedWidth === config.requestedWidth || area === 'side-nav' && !config.isOpen && (current === null || current === void 0 ? void 0 : current.area) !== 'side-nav') ? current : null; }); setAreas(current => { const existing = current[area]; return { ...current, [area]: { ...existing, ...config, liveResize: config.isOpen ? existing === null || existing === void 0 ? void 0 : existing.liveResize : undefined } }; }); }, []); const unregisterArea = useCallback(area => { if (adoptedConstraints.current) { setHasInteraction(true); } delete configsRef.current[area]; setResizeSession(null); setAreas(current => { if (!current[area]) { return current; } // Closing uses isOpen: false and retains preferences. Unregistering means // the owning component unmounted; a replacement must start with its own defaults. const next = { ...current }; delete next[area]; return next; }); }, []); const captureSiblingWidths = useCallback((area, mode = 'inline') => { const baselineWidths = {}; const overlayModes = {}; for (const areaName of ['side-nav', 'panel', 'chat-panel']) { const state = areaStatesRef.current[areaName]; if (state) { overlayModes[areaName] = state.mode; } if ((state === null || state === void 0 ? void 0 : state.mode) === 'inline') { baselineWidths[areaName] = state.width; } } return { area, baselineWidths, overlayModes: mode === 'overlay' ? overlayModes : undefined }; }, []); const startResize = useCallback((area, mode) => { setHasInteraction(true); setResizeSession(captureSiblingWidths(area, mode)); // Start from the effective width, which may already be compressed below the preferred width. // Reapplying the preferred width here would move the splitter away from the pointer. const renderedState = areaStatesRef.current[area]; if ((renderedState === null || renderedState === void 0 ? void 0 : renderedState.mode) === mode) { setAreas(current => { const existing = current[area]; return existing ? { ...current, [area]: { ...existing, liveResize: { mode, width: renderedState.width } } } : current; }); } }, [captureSiblingWidths]); const resize = useCallback((area, mode, width) => { setAreas(current => { var _existing$liveResize; const existing = current[area]; if ((existing === null || existing === void 0 ? void 0 : (_existing$liveResize = existing.liveResize) === null || _existing$liveResize === void 0 ? void 0 : _existing$liveResize.mode) === mode && existing.liveResize.width === width) { return current; } return existing ? { ...current, [area]: { ...existing, liveResize: { mode, width } } } : current; }); }, []); const completeResize = useCallback((area, mode, width) => { setHasInteraction(true); if (mode === 'overlay') { const session = inputsRef.current.resizeSession; setResizeSession({ ...((session === null || session === void 0 ? void 0 : session.area) === area && session.overlayModes ? session : captureSiblingWidths(area, mode)), completedWidth: width }); } if (mode === 'inline') { var _inputs$resizeSession, _inputs$areas$area; const inputs = inputsRef.current; const session = ((_inputs$resizeSession = inputs.resizeSession) === null || _inputs$resizeSession === void 0 ? void 0 : _inputs$resizeSession.area) === area && (_inputs$areas$area = inputs.areas[area]) !== null && _inputs$areas$area !== void 0 && _inputs$areas$area.liveResize ? inputs.resizeSession : captureSiblingWidths(area); const existing = inputs.areas[area]; if (inputs.dimensions && existing) { // Keyboard completion has no preview; calculate it using the accepted // request, not the parent's previous controlled width. Pointer completion // keeps below-minimum cascade results even though its payload is clamped. const completed = existing.liveResize ? areaStatesRef.current : calculateLayoutAreaStates({ ...inputs.dimensions, occupiedWidth: inputs.occupiedWidth, mainMinWidth: inputs.mainMinWidth, areas: { ...inputs.areas, [area]: { ...existing, liveResize: { mode, width } } }, resizeSession: session }); const completedWidths = {}; for (const name of ['side-nav', 'panel', 'chat-panel']) { var _completed$name; if (((_completed$name = completed[name]) === null || _completed$name === void 0 ? void 0 : _completed$name.mode) === 'inline') { completedWidths[name] = completed[name].width; } } setResizeSession({ ...session, completedWidth: width, completedWidths }); } } setAreas(current => { const existing = current[area]; if (!existing) { return current; } if (mode === 'overlay' && area !== 'panel') { return { ...current, [area]: { ...existing, liveResize: undefined, preferredOverlayWidth: width } }; } return { ...current, [area]: { ...existing, liveResize: undefined, // Local panels have one preference, regardless of the resized mode. preferredWidth: width } }; }); }, [captureSiblingWidths]); useEffect(() => { // Loading the interaction engine is not a layout event. Adopt the CSS layout // without changing widths or modes; subsequent changes to configuration or // available space may redistribute it. Content stays mounted during the handoff. const root = layoutRef === null || layoutRef === void 0 ? void 0 : layoutRef.current; if (onUpdate && root && dimensions && isPreservingInitialLayout) { const measured = {}; const selectors = { 'side-nav': ':scope > nav[data-layout-slot]', panel: '[data-layout-with-panel-slot]', 'chat-panel': ':scope > [data-layout-chat-panel]' }; for (const name of ['side-nav', 'panel', 'chat-panel']) { const element = root.querySelector(selectors[name]); const config = areas[name]; if (!element || !(config !== null && config !== void 0 && config.isOpen)) { continue; } const style = getComputedStyle(element); const mode = style.gridArea.split('/')[0].trim() === name ? 'inline' : 'overlay'; measured[name] = { mode, width: element.getBoundingClientRect().width, minWidth: resolveLayoutWidth(config.minWidth, dimensions.viewportWidth) }; } setInitialStates(measured); } }, [areas, dimensions, isPreservingInitialLayout, layoutRef, onUpdate]); const areaStates = useMemo(() => { var _initialStates$sideN, _initialStates$panel, _initialStates$chatP; if (!dimensions) { return {}; } const allocated = calculateLayoutAreaStates({ ...dimensions, occupiedWidth, mainMinWidth, areas, resizeSession, initialModes: onUpdate && layoutRef !== null && layoutRef !== void 0 && layoutRef.current && isPreservingInitialLayout ? { 'side-nav': (_initialStates$sideN = initialStates['side-nav']) === null || _initialStates$sideN === void 0 ? void 0 : _initialStates$sideN.mode, panel: (_initialStates$panel = initialStates.panel) === null || _initialStates$panel === void 0 ? void 0 : _initialStates$panel.mode, 'chat-panel': (_initialStates$chatP = initialStates['chat-panel']) === null || _initialStates$chatP === void 0 ? void 0 : _initialStates$chatP.mode } : undefined }); if (onUpdate && layoutRef !== null && layoutRef !== void 0 && layoutRef.current && isPreservingInitialLayout) { for (const name of ['side-nav', 'panel', 'chat-panel']) { if (initialStates[name] && allocated[name]) { allocated[name] = { ...allocated[name], ...initialStates[name] }; } else { delete allocated[name]; } } } return allocated; }, [areas, mainMinWidth, resizeSession, dimensions, occupiedWidth, isPreservingInitialLayout, initialStates, layoutRef, onUpdate]); useEffect(() => { var _areas$sideNav; if (hasInteraction && (resizeSession === null || resizeSession === void 0 ? void 0 : resizeSession.area) !== 'side-nav' && (_areas$sideNav = areas['side-nav']) !== null && _areas$sideNav !== void 0 && _areas$sideNav.isOpen && !areaStates['side-nav'] && dimensions) { var _areas$sideNav$onReq, _areas$sideNav2; (_areas$sideNav$onReq = (_areas$sideNav2 = areas['side-nav']).onRequestClose) === null || _areas$sideNav$onReq === void 0 ? void 0 : _areas$sideNav$onReq.call(_areas$sideNav2); } }, [areaStates, areas, dimensions, hasInteraction, resizeSession]); useEffect(() => { areaStatesRef.current = areaStates; }, [areaStates]); // Read bounds lazily: changing a sibling's capacity must not publish new render // state or recreate drag listeners for a region whose own width did not change. const getResizeBounds = useCallback(area => { var _areaStatesRef$curren; const bounds = (_areaStatesRef$curren = areaStatesRef.current[area]) === null || _areaStatesRef$curren === void 0 ? void 0 : _areaStatesRef$curren.resizeBounds; return bounds ? { min: `${bounds.min}px`, max: `${bounds.max}px` } : undefined; }, []); const value = useMemo(() => ({ getResizeBounds, registerArea, unregisterArea, startResize, resize, completeResize, setMainMinWidth }), [completeResize, getResizeBounds, registerArea, resize, startResize, unregisterArea]); const sideNavState = useStableAreaState(areaStates['side-nav']); const panelState = useStableAreaState(areaStates.panel); const chatPanelState = useStableAreaState(areaStates['chat-panel']); const SideNavStateProvider = layoutAreaStateContexts['side-nav'].Provider; const PanelStateProvider = layoutAreaStateContexts.panel.Provider; const ChatPanelStateProvider = layoutAreaStateContexts['chat-panel'].Provider; useEffect(() => { onReady === null || onReady === void 0 ? void 0 : onReady(value); return () => onReady === null || onReady === void 0 ? void 0 : onReady(null); }, [onReady, value]); useEffect(() => { if (!adoptedConstraints.current && (sideNavState || panelState || chatPanelState)) { adoptedConstraints.current = { occupiedWidth, mainMinWidth }; } onUpdate === null || onUpdate === void 0 ? void 0 : onUpdate({ 'side-nav': sideNavState, panel: panelState, 'chat-panel': chatPanelState }); }, [onUpdate, sideNavState, panelState, chatPanelState, occupiedWidth, mainMinWidth]); return /*#__PURE__*/React.createElement(LayoutAreaSizingContext.Provider, { value: value }, /*#__PURE__*/React.createElement(SideNavStateProvider, { value: sideNavState }, /*#__PURE__*/React.createElement(PanelStateProvider, { value: panelState }, /*#__PURE__*/React.createElement(ChatPanelStateProvider, { value: chatPanelState }, children)))); }