@awsui/components-react
Version:
On July 19th, 2022, we launched [Cloudscape Design System](https://cloudscape.design). Cloudscape is an evolution of AWS-UI. It consists of user interface guidelines, front-end components, design resources, and development tools for building intuitive, en
460 lines • 24.7 kB
JavaScript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useMergeRefs, useStableCallback, useUniqueId } from '@awsui/component-toolkit/internal';
import { fireNonCancelableEvent } from '../../../internal/events';
import { useControllable } from '../../../internal/hooks/use-controllable';
import { useIntersectionObserver } from '../../../internal/hooks/use-intersection-observer';
import { useMobile } from '../../../internal/hooks/use-mobile';
import { metrics } from '../../../internal/metrics';
import { useGetGlobalBreadcrumbs } from '../../../internal/plugins/helpers/use-global-breadcrumbs';
import globalVars from '../../../internal/styles/global-vars';
import { getSplitPanelDefaultSize } from '../../../split-panel/utils/size-utils';
import { MIN_DRAWER_SIZE, useDrawers } from '../../utils/use-drawers';
import { useAsyncFocusControl, useMultipleFocusControl } from '../../utils/use-focus-control';
import { useGlobalScrollPadding } from '../../utils/use-global-scroll-padding';
import { useSplitPanelFocusControl } from '../../utils/use-split-panel-focus-control';
import { computeHorizontalLayout, computeSplitPanelOffsets, computeVerticalLayout, CONTENT_PADDING, } from '../compute-layout';
import { useAiDrawer } from './use-ai-drawer';
import { useBottomDrawers } from './use-bottom-drawers';
import { useWidgetMessages } from './use-widget-messages';
export const useAppLayout = (hasToolbar, { ariaLabels, navigationOpen, navigationWidth, navigation, navigationHide, onNavigationChange, tools, toolsOpen: controlledToolsOpen, onToolsChange, toolsHide, toolsWidth, headerVariant, breadcrumbs, stickyNotifications, splitPanelPreferences: controlledSplitPanelPreferences, splitPanelOpen: controlledSplitPanelOpen, splitPanel, splitPanelSize: controlledSplitPanelSize, onSplitPanelToggle, onSplitPanelResize, onSplitPanelPreferencesChange, minContentWidth, placement, navigationTriggerHide, ...rest }, forwardRef) => {
var _a, _b, _c;
const isMobile = useMobile();
const splitPanelControlId = useUniqueId('split-panel');
const [toolbarState, setToolbarState] = useState('show');
const [toolbarHeight, setToolbarHeight] = useState(0);
const [notificationsHeight, setNotificationsHeight] = useState(0);
const [navigationAnimationDisabled, setNavigationAnimationDisabled] = useState(true);
const [splitPanelAnimationDisabled, setSplitPanelAnimationDisabled] = useState(true);
const [isNested, setIsNested] = useState(false);
const [expandedDrawerId, setInternalExpandedDrawerId] = useState(null);
const rootRefInternal = useRef(null);
// This workaround ensures the ref is defined before checking if the app layout is nested.
// On initial render, the ref might be undefined because this component loads asynchronously via the widget API.
const onMountRootRef = useCallback(node => {
setIsNested(getIsNestedInAppLayout(node));
}, []);
const [toolsOpen = false, setToolsOpen] = useControllable(controlledToolsOpen, onToolsChange, false, {
componentName: 'AppLayout',
controlledProp: 'toolsOpen',
changeHandler: 'onToolsChange',
});
const onToolsToggle = (open) => {
setToolsOpen(open);
drawersFocusControl.setFocus();
fireNonCancelableEvent(onToolsChange, { open });
};
const setExpandedDrawerId = (value) => {
setInternalExpandedDrawerId(value);
if ((aiDrawer === null || aiDrawer === void 0 ? void 0 : aiDrawer.onToggleFocusMode) && (value === (aiDrawer === null || aiDrawer === void 0 ? void 0 : aiDrawer.id) || value === null)) {
fireNonCancelableEvent(aiDrawer.onToggleFocusMode, {
isExpanded: !!value,
});
}
};
const onGlobalDrawerFocus = (drawerId, open) => {
globalDrawersFocusControl.setFocus({ force: true, drawerId, open });
};
const onAddNewActiveDrawer = (drawerId) => {
var _a, _b;
// If either a local drawer or a bottom drawer is already open, and we attempt to open a new one,
// it will replace the existing one instead of opening an additional drawer,
// since only one local drawer is supported. Therefore, layout calculations are not necessary.
if ((activeDrawer && (drawers === null || drawers === void 0 ? void 0 : drawers.find(drawer => drawer.id === drawerId))) || activeGlobalBottomDrawerId === drawerId) {
return;
}
// get the size of drawerId. it could be either local or global drawer
const combinedDrawers = [...(drawers || []), ...globalDrawers];
const newDrawer = combinedDrawers.find(drawer => drawer.id === drawerId);
if (!newDrawer) {
return;
}
const newDrawerSize = Math.min((_b = (_a = newDrawer.defaultSize) !== null && _a !== void 0 ? _a : drawerSizes[drawerId]) !== null && _b !== void 0 ? _b : MIN_DRAWER_SIZE, MIN_DRAWER_SIZE);
// check if the active drawers could be resized to fit the new drawers
// to do this, we need to take all active drawers, sum up their min sizes, truncate it from resizableSpaceAvailable
// and compare a given number with the new drawer id min size
// the total size of all global drawers resized to their min size
const availableSpaceForNewDrawer = resizableSpaceAvailable - totalActiveDrawersMinSize;
if (availableSpaceForNewDrawer >= newDrawerSize) {
return;
}
// now we made sure we cannot accommodate the new drawer with existing ones
closeFirstDrawer();
};
const onGlobalBottomDrawerFocus = () => {
bottomDrawersFocusControl.setFocus();
};
const { drawers, activeDrawer, minDrawerSize, minGlobalDrawersSizes, activeDrawerSize, ariaLabelsWithDrawers, globalDrawers, activeGlobalDrawers, activeGlobalDrawersIds, activeGlobalDrawersSizes, drawerSizes, drawersOpenQueue, onActiveDrawerChange, onActiveDrawerResize, onActiveGlobalDrawersChange, } = useDrawers({
...rest,
onGlobalDrawerFocus,
onAddNewActiveDrawer,
expandedDrawerId,
setExpandedDrawerId,
}, ariaLabels, {
ariaLabels,
toolsHide,
toolsOpen,
tools,
toolsWidth,
onToolsToggle,
});
const { aiDrawer, aiDrawerMessageHandler, onActiveAiDrawerChange, activeAiDrawer, activeAiDrawerId, activeAiDrawerSize, minAiDrawerSize, onActiveAiDrawerResize, } = useAiDrawer({
getMaxAiDrawerSize: () => maxAiDrawerSize,
onAiDrawerFocus: () => aiDrawerFocusControl.setFocus(),
expandedDrawerId,
setExpandedDrawerId,
});
const aiDrawerFocusControl = useAsyncFocusControl(!!(activeAiDrawer === null || activeAiDrawer === void 0 ? void 0 : activeAiDrawer.id), true, activeAiDrawer === null || activeAiDrawer === void 0 ? void 0 : activeAiDrawer.id);
const { bottomDrawers, activeBottomDrawer, onActiveBottomDrawerChange: onActiveGlobalBottomDrawerChange, activeBottomDrawerSize: activeGlobalBottomDrawerSize, minBottomDrawerSize: minGlobalBottomDrawerSize, onActiveBottomDrawerResize, bottomDrawersMessageHandler, } = useBottomDrawers({
onBottomDrawerFocus: onGlobalBottomDrawerFocus,
expandedDrawerId,
setExpandedDrawerId,
drawersOpenQueue,
});
const activeGlobalBottomDrawerId = (_a = activeBottomDrawer === null || activeBottomDrawer === void 0 ? void 0 : activeBottomDrawer.id) !== null && _a !== void 0 ? _a : null;
const checkAIDrawerIdExists = (id) => {
return (aiDrawer === null || aiDrawer === void 0 ? void 0 : aiDrawer.id) === id;
};
const checkBottomDrawerIdExists = (id) => {
return !!bottomDrawers.find(drawer => drawer.id === id);
};
const checkDrawerIdExists = (id) => {
return checkAIDrawerIdExists(id) || checkBottomDrawerIdExists(id);
};
const drawerGenericMessageHandler = (message) => {
var _a;
switch (message.type) {
case 'expandDrawer':
if (!checkDrawerIdExists(message.payload.id)) {
metrics.sendOpsMetricObject('awsui-widget-drawer-incorrect-id', {
id: message.payload.id,
type: message.type,
bottomDrawers: bottomDrawers.map(drawer => drawer.id).join(','),
aiDrawer: (_a = aiDrawer === null || aiDrawer === void 0 ? void 0 : aiDrawer.id) !== null && _a !== void 0 ? _a : '',
});
}
setExpandedDrawerId(message.payload.id);
break;
case 'exitExpandedMode':
setExpandedDrawerId(null);
break;
}
};
useWidgetMessages(hasToolbar, message => {
var _a, _b;
if (message.type === 'expandDrawer' || message.type === 'exitExpandedMode') {
drawerGenericMessageHandler(message);
return;
}
if (!('payload' in message && 'id' in message.payload)) {
metrics.sendOpsMetricObject('awsui-widget-drawer-incorrect-payload', {
type: message.type,
bottomDrawers: bottomDrawers.map(drawer => drawer.id).join(','),
aiDrawer: (_a = aiDrawer === null || aiDrawer === void 0 ? void 0 : aiDrawer.id) !== null && _a !== void 0 ? _a : '',
});
return;
}
const { id } = message.payload;
if (checkAIDrawerIdExists(id) || message.type === 'registerLeftDrawer') {
aiDrawerMessageHandler(message);
return;
}
if (checkBottomDrawerIdExists(id) || message.type === 'registerBottomDrawer') {
bottomDrawersMessageHandler(message);
return;
}
metrics.sendOpsMetricObject('awsui-widget-drawer-incorrect-id', {
id,
type: message.type,
bottomDrawers: bottomDrawers.map(drawer => drawer.id).join(','),
aiDrawer: (_b = aiDrawer === null || aiDrawer === void 0 ? void 0 : aiDrawer.id) !== null && _b !== void 0 ? _b : '',
});
});
const onActiveDrawerChangeHandler = (drawerId, params = { initiatedByUserAction: true }) => {
onActiveDrawerChange(drawerId, params);
drawersFocusControl.setFocus();
};
const [splitPanelOpen = false, setSplitPanelOpen] = useControllable(controlledSplitPanelOpen, onSplitPanelToggle, false, {
componentName: 'AppLayout',
controlledProp: 'splitPanelOpen',
changeHandler: 'onSplitPanelToggle',
});
const onSplitPanelToggleHandler = () => {
setSplitPanelAnimationDisabled(false);
setSplitPanelOpen(!splitPanelOpen);
splitPanelFocusControl.setLastInteraction({ type: splitPanelOpen ? 'close' : 'open' });
fireNonCancelableEvent(onSplitPanelToggle, { open: !splitPanelOpen });
};
const [splitPanelPreferences, setSplitPanelPreferences] = useControllable(controlledSplitPanelPreferences, onSplitPanelPreferencesChange, undefined, {
componentName: 'AppLayout',
controlledProp: 'splitPanelPreferences',
changeHandler: 'onSplitPanelPreferencesChange',
});
const onSplitPanelPreferencesChangeHandler = (detail) => {
setSplitPanelPreferences(detail);
splitPanelFocusControl.setLastInteraction({ type: 'position' });
fireNonCancelableEvent(onSplitPanelPreferencesChange, detail);
};
const [splitPanelSize = 0, setSplitPanelSize] = useControllable(controlledSplitPanelSize, onSplitPanelResize, getSplitPanelDefaultSize((_b = splitPanelPreferences === null || splitPanelPreferences === void 0 ? void 0 : splitPanelPreferences.position) !== null && _b !== void 0 ? _b : 'bottom'), { componentName: 'AppLayout', controlledProp: 'splitPanelSize', changeHandler: 'onSplitPanelResize' });
const [splitPanelReportedSize, setSplitPanelReportedSize] = useState(0);
const [splitPanelHeaderBlockSize, setSplitPanelHeaderBlockSize] = useState(0);
const onSplitPanelResizeHandler = (size) => {
setSplitPanelSize(size);
fireNonCancelableEvent(onSplitPanelResize, { size });
};
const [splitPanelToggleConfig, setSplitPanelToggleConfig] = useState({
ariaLabel: undefined,
displayed: false,
});
const [bottomDrawerReportedSize, setBottomDrawerReportedSize] = useState(0);
const globalDrawersFocusControl = useMultipleFocusControl(true, activeGlobalDrawersIds);
const drawersFocusControl = useAsyncFocusControl(!!(activeDrawer === null || activeDrawer === void 0 ? void 0 : activeDrawer.id), true, activeDrawer === null || activeDrawer === void 0 ? void 0 : activeDrawer.id);
const bottomDrawersFocusControl = useAsyncFocusControl(!!activeGlobalBottomDrawerId, true, activeGlobalBottomDrawerId);
const navigationFocusControl = useAsyncFocusControl(navigationOpen, navigationTriggerHide);
const splitPanelFocusControl = useSplitPanelFocusControl([splitPanelPreferences, splitPanelOpen]);
const onNavigationToggle = useStableCallback(({ isOpen, autoFocus }) => {
setNavigationAnimationDisabled(false);
navigationFocusControl.setFocus({ force: false, autoFocus });
fireNonCancelableEvent(onNavigationChange, { open: isOpen });
});
useImperativeHandle(forwardRef, () => ({
closeNavigationIfNecessary: () => isMobile && onNavigationToggle({ isOpen: false, autoFocus: true }),
openTools: () => onToolsToggle(true),
focusToolsClose: () => drawersFocusControl.setFocus({ force: true }),
focusActiveDrawer: () => drawersFocusControl.setFocus({ force: true }),
focusSplitPanel: () => splitPanelFocusControl.setLastInteraction({ type: 'open' }),
focusNavigation: () => navigationFocusControl.setFocus({ force: true }),
}));
const resolvedStickyNotifications = !!stickyNotifications && !isMobile;
//navigation must be null if hidden so toolbar knows to hide the toggle button
const resolvedNavigation = navigationHide ? null : navigation || React.createElement(React.Fragment, null);
//navigation must not be open if navigationHide is true
const resolvedNavigationOpen = !!resolvedNavigation && navigationOpen;
const { maxDrawerSize, maxSplitPanelSize, splitPanelForcedPosition, splitPanelPosition, maxGlobalDrawersSizes, resizableSpaceAvailable, maxAiDrawerSize, } = computeHorizontalLayout({
activeDrawerSize: activeDrawer ? activeDrawerSize : 0,
splitPanelSize,
minContentWidth,
navigationOpen: resolvedNavigationOpen,
navigationWidth,
placement,
splitPanelOpen,
splitPanelPosition: splitPanelPreferences === null || splitPanelPreferences === void 0 ? void 0 : splitPanelPreferences.position,
isMobile,
activeGlobalDrawersSizes,
activeAiDrawerSize,
});
const verticalOffsets = computeVerticalLayout({
topOffset: placement.insetBlockStart,
hasVisibleToolbar: hasToolbar && toolbarState !== 'hide',
notificationsHeight: notificationsHeight !== null && notificationsHeight !== void 0 ? notificationsHeight : 0,
toolbarHeight: toolbarHeight !== null && toolbarHeight !== void 0 ? toolbarHeight : 0,
stickyNotifications: resolvedStickyNotifications,
});
const { ref: intersectionObserverRef, isIntersecting } = useIntersectionObserver({ initialState: true });
const rootRef = useMergeRefs(rootRefInternal, intersectionObserverRef, onMountRootRef);
const discoveredBreadcrumbs = useGetGlobalBreadcrumbs(hasToolbar && !breadcrumbs);
useGlobalScrollPadding((_c = verticalOffsets.header) !== null && _c !== void 0 ? _c : 0);
const getMaxGlobalBottomDrawerHeight = useCallback(() => {
const splitPanelSize = splitPanelOpen && splitPanelPosition === 'bottom' ? splitPanelReportedSize : 0;
const availableHeight = document.documentElement.clientHeight - placement.insetBlockStart - placement.insetBlockEnd - splitPanelSize;
// skip reading sizes in JSDOM
if (availableHeight === 0) {
return Infinity;
}
// If the page is likely zoomed in at 200%, allow the split panel to fill the content area.
return availableHeight < 400 ? availableHeight - 40 : availableHeight - 250;
}, [splitPanelOpen, splitPanelPosition, splitPanelReportedSize, placement.insetBlockStart, placement.insetBlockEnd]);
const appLayoutInternals = {
ariaLabels: ariaLabelsWithDrawers,
headerVariant,
isMobile,
breadcrumbs,
discoveredBreadcrumbs,
stickyNotifications: resolvedStickyNotifications,
navigationOpen: resolvedNavigationOpen,
navigation: resolvedNavigation,
navigationFocusControl,
activeDrawer,
activeDrawerSize,
minDrawerSize,
maxDrawerSize,
minGlobalDrawersSizes,
maxGlobalDrawersSizes,
drawers: drawers,
globalDrawers,
activeGlobalDrawers,
activeGlobalDrawersIds,
activeGlobalDrawersSizes,
onActiveGlobalDrawersChange,
drawersFocusControl,
globalDrawersFocusControl,
splitPanelPosition,
splitPanelToggleConfig,
splitPanelOpen,
splitPanelControlId,
splitPanelFocusControl,
placement,
toolbarState,
setToolbarState,
verticalOffsets,
drawersOpenQueue: drawersOpenQueue.current,
setToolbarHeight,
setNotificationsHeight,
onSplitPanelToggle: onSplitPanelToggleHandler,
onNavigationToggle: isOpen => onNavigationToggle({ isOpen, autoFocus: true }),
onActiveDrawerChange: onActiveDrawerChangeHandler,
onActiveDrawerResize,
splitPanelAnimationDisabled,
expandedDrawerId,
setExpandedDrawerId,
aiDrawer,
onActiveAiDrawerChange,
activeAiDrawer,
activeAiDrawerId,
activeAiDrawerSize,
minAiDrawerSize,
maxAiDrawerSize,
aiDrawerFocusControl,
onActiveAiDrawerResize,
};
const splitPanelInternals = {
bottomOffset: 0,
getMaxHeight: useCallback(() => {
const bottomDrawerHeight = activeGlobalBottomDrawerId ? bottomDrawerReportedSize : 0;
const availableHeight = document.documentElement.clientHeight -
placement.insetBlockStart -
placement.insetBlockEnd -
bottomDrawerHeight;
// If the page is likely zoomed in at 200%, allow the split panel to fill the content area.
return availableHeight < 400 ? availableHeight - 40 : availableHeight - 250;
}, [activeGlobalBottomDrawerId, bottomDrawerReportedSize, placement.insetBlockEnd, placement.insetBlockStart]),
maxWidth: maxSplitPanelSize,
isForcedPosition: splitPanelForcedPosition,
isOpen: splitPanelOpen,
leftOffset: 0,
onPreferencesChange: onSplitPanelPreferencesChangeHandler,
onResize: onSplitPanelResizeHandler,
onToggle: onSplitPanelToggleHandler,
position: splitPanelPosition,
reportSize: useStableCallback(size => setSplitPanelReportedSize(size)),
reportHeaderHeight: useStableCallback(size => setSplitPanelHeaderBlockSize(size)),
headerHeight: splitPanelHeaderBlockSize,
rightOffset: 0,
size: splitPanelSize,
topOffset: 0,
setSplitPanelToggle: setSplitPanelToggleConfig,
refs: splitPanelFocusControl.refs,
};
const closeFirstDrawer = useStableCallback(() => {
let drawerToClose = drawersOpenQueue.current[drawersOpenQueue.current.length - 1];
if (drawerToClose === (activeBottomDrawer === null || activeBottomDrawer === void 0 ? void 0 : activeBottomDrawer.id)) {
drawerToClose = drawersOpenQueue.current[drawersOpenQueue.current.length - 2];
}
if (activeDrawer && (activeDrawer === null || activeDrawer === void 0 ? void 0 : activeDrawer.id) === drawerToClose) {
onActiveDrawerChange(null, { initiatedByUserAction: true });
}
else if (activeGlobalDrawersIds.includes(drawerToClose)) {
onActiveGlobalDrawersChange(drawerToClose, { initiatedByUserAction: true });
}
});
useEffect(() => {
// Close navigation drawer on mobile so that the main content is visible
if (isMobile) {
onNavigationToggle({ isOpen: false, autoFocus: false });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile]);
const getTotalActiveDrawersMinSize = () => {
var _a, _b;
const combinedDrawers = [...(drawers || []), ...globalDrawers];
let result = activeGlobalDrawersIds
.map(activeDrawerId => {
var _a, _b;
return Math.min((_b = (_a = combinedDrawers.find(drawer => drawer.id === activeDrawerId)) === null || _a === void 0 ? void 0 : _a.defaultSize) !== null && _b !== void 0 ? _b : MIN_DRAWER_SIZE, MIN_DRAWER_SIZE);
})
.reduce((acc, curr) => acc + curr, 0);
if (activeDrawer) {
result += Math.min((_a = activeDrawer === null || activeDrawer === void 0 ? void 0 : activeDrawer.defaultSize) !== null && _a !== void 0 ? _a : MIN_DRAWER_SIZE, MIN_DRAWER_SIZE);
}
if (activeAiDrawer) {
result += Math.min((_b = activeAiDrawer === null || activeAiDrawer === void 0 ? void 0 : activeAiDrawer.defaultSize) !== null && _b !== void 0 ? _b : MIN_DRAWER_SIZE, MIN_DRAWER_SIZE);
}
return result;
};
const totalActiveDrawersMinSize = getTotalActiveDrawersMinSize();
useEffect(() => {
if (isMobile) {
return;
}
const activeNavigationWidth = !navigationHide && navigationOpen ? navigationWidth : 0;
const scrollWidth = activeNavigationWidth + CONTENT_PADDING + totalActiveDrawersMinSize;
const hasHorizontalScroll = scrollWidth > placement.inlineSize;
if (hasHorizontalScroll) {
if (!navigationHide && navigationOpen) {
onNavigationToggle({ isOpen: false, autoFocus: false });
return;
}
closeFirstDrawer();
}
}, [
totalActiveDrawersMinSize,
closeFirstDrawer,
isMobile,
navigationHide,
navigationOpen,
navigationWidth,
onNavigationToggle,
placement.inlineSize,
]);
/**
* Returns true if the AppLayout is nested
* Does not apply to iframe
*/
const getIsNestedInAppLayout = (element) => {
var _a;
let currentElement = (_a = element === null || element === void 0 ? void 0 : element.parentElement) !== null && _a !== void 0 ? _a : null;
// this traverse is needed only for JSDOM
// in real browsers the globalVar will be propagated to all descendants and this loops exits after initial iteration
while (currentElement) {
if (getComputedStyle(currentElement).getPropertyValue(globalVars.stickyVerticalTopOffset)) {
return true;
}
currentElement = currentElement.parentElement;
}
return false;
};
const splitPanelOffsets = computeSplitPanelOffsets({
placement,
hasSplitPanel: !!splitPanel,
splitPanelOpen,
splitPanelPosition,
splitPanelFullHeight: splitPanelReportedSize,
splitPanelHeaderHeight: splitPanelHeaderBlockSize,
});
return {
rootRef,
isIntersecting,
appLayoutInternals,
splitPanelInternals,
widgetizedState: {
...appLayoutInternals,
aiDrawerExpandedMode: expandedDrawerId === (activeAiDrawer === null || activeAiDrawer === void 0 ? void 0 : activeAiDrawer.id),
isNested,
navigationAnimationDisabled,
verticalOffsets,
splitPanelOffsets,
activeGlobalBottomDrawerId,
onActiveGlobalBottomDrawerChange,
activeGlobalBottomDrawerSize,
bottomDrawerReportedSize,
minGlobalBottomDrawerSize,
getMaxGlobalBottomDrawerHeight,
reportBottomDrawerSize: useStableCallback(size => setBottomDrawerReportedSize(size)),
onActiveBottomDrawerResize,
bottomDrawers,
bottomDrawersFocusControl,
},
};
};
//# sourceMappingURL=use-app-layout.js.map