@atlaskit/navigation-system
Version:
The latest navigation system for Atlassian apps.
420 lines (418 loc) • 18.8 kB
JavaScript
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))));
}