From 1c5d6a23bb6d47f95ebeadcf92fd73b8f085d5fc Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Tue, 1 Apr 2025 16:06:16 +0200 Subject: [PATCH 01/24] feat: Widgetise app layout skeleton --- .../visual-refresh-toolbar/index.tsx | 467 +---------------- .../visual-refresh-toolbar/internal.tsx | 2 + .../visual-refresh-toolbar/skeleton/index.tsx | 189 ++++--- .../visual-refresh-toolbar/use-app-layout.tsx | 477 ++++++++++++++++++ 4 files changed, 598 insertions(+), 537 deletions(-) create mode 100644 src/app-layout/visual-refresh-toolbar/use-app-layout.tsx diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index 0bf8253873..6e07ec45d5 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -1,488 +1,73 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; - -import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; +import React from 'react'; import ScreenreaderOnly from '../../internal/components/screenreader-only'; -import { SplitPanelSideToggleProps } from '../../internal/context/split-panel-context'; -import { fireNonCancelableEvent } from '../../internal/events'; -import { useControllable } from '../../internal/hooks/use-controllable'; -import { useIntersectionObserver } from '../../internal/hooks/use-intersection-observer'; import { useMergeRefs } from '../../internal/hooks/use-merge-refs'; -import { useMobile } from '../../internal/hooks/use-mobile'; -import { useUniqueId } from '../../internal/hooks/use-unique-id'; -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 { AppLayoutProps } from '../interfaces'; -import { SplitPanelProviderProps } from '../split-panel'; -import { MIN_DRAWER_SIZE, OnChangeParams, useDrawers } from '../utils/use-drawers'; -import { useFocusControl, useMultipleFocusControl } from '../utils/use-focus-control'; -import { useSplitPanelFocusControl } from '../utils/use-split-panel-focus-control'; import { ActiveDrawersContext } from '../utils/visibility-context'; -import { - computeHorizontalLayout, - computeSplitPanelOffsets, - computeVerticalLayout, - CONTENT_PADDING, -} from './compute-layout'; import { AppLayoutVisibilityContext } from './contexts'; -import { AppLayoutInternalProps, AppLayoutInternals } from './interfaces'; +import { AppLayoutInternalProps } from './interfaces'; import { AppLayoutDrawer, AppLayoutGlobalDrawers, AppLayoutNavigation, AppLayoutNotifications, + AppLayoutSkeletonLayout, AppLayoutSplitPanelBottom, AppLayoutSplitPanelSide, AppLayoutToolbar, } from './internal'; -import { useMultiAppLayout } from './multi-layout'; -import { SkeletonLayout } from './skeleton'; +import { useAppLayout } from './use-app-layout'; const AppLayoutVisualRefreshToolbar = React.forwardRef( - ( - { - ariaLabels, + (props, forwardRef) => { + const { contentHeader, content, - navigationOpen, navigationWidth, - navigation, - navigationHide, - onNavigationChange, - tools, - toolsOpen: controlledToolsOpen, - onToolsChange, - toolsHide, - toolsWidth, contentType, headerVariant, breadcrumbs, notifications, - stickyNotifications, - splitPanelPreferences: controlledSplitPanelPreferences, - splitPanelOpen: controlledSplitPanelOpen, splitPanel, - splitPanelSize: controlledSplitPanelSize, - onSplitPanelToggle, - onSplitPanelResize, - onSplitPanelPreferencesChange, disableContentPaddings, minContentWidth, maxContentWidth, placement, - navigationTriggerHide, - ...rest - }, - forwardRef - ) => { - const isMobile = useMobile(); - const { __embeddedViewMode: embeddedViewMode, __forceDeduplicationType: forceDeduplicationType } = rest as any; - const splitPanelControlId = useUniqueId('split-panel'); - const [toolbarState, setToolbarState] = useState<'show' | 'hide'>('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 rootRef = useRef(null); - - const [toolsOpen = false, setToolsOpen] = useControllable(controlledToolsOpen, onToolsChange, false, { - componentName: 'AppLayout', - controlledProp: 'toolsOpen', - changeHandler: 'onToolsChange', - }); - const onToolsToggle = (open: boolean) => { - setToolsOpen(open); - drawersFocusControl.setFocus(); - fireNonCancelableEvent(onToolsChange, { open }); - }; - - const onGlobalDrawerFocus = (drawerId: string, open: boolean) => { - globalDrawersFocusControl.setFocus({ force: true, drawerId, open }); - }; - - const onAddNewActiveDrawer = (drawerId: string) => { - // If a local 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?.find(drawer => drawer.id === 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( - newDrawer.defaultSize ?? drawerSizes[drawerId] ?? 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(); - }; + } = props; const { + navigationAnimationDisabled, + isNested, + isIntersecting, + hasToolbar, + intersectionObserverRef, + rootRef, + splitPanelOffsets, + verticalOffsets, + isMobile, + appLayoutInternals, + toolbarProps, + registered, + resolvedNavigation, + resolvedNavigationOpen, drawers, - activeDrawer, - minDrawerSize, - minGlobalDrawersSizes, - activeDrawerSize, - ariaLabelsWithDrawers, - globalDrawers, - activeGlobalDrawers, activeGlobalDrawersIds, - activeGlobalDrawersSizes, - drawerSizes, - drawersOpenQueue, - onActiveDrawerChange, - onActiveDrawerResize, - onActiveGlobalDrawersChange, - } = useDrawers({ ...rest, onGlobalDrawerFocus, onAddNewActiveDrawer }, ariaLabels, { - ariaLabels, - toolsHide, - toolsOpen, - tools, - toolsWidth, - onToolsToggle, - }); - - const onActiveDrawerChangeHandler = ( - drawerId: string | null, - params: OnChangeParams = { 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: AppLayoutProps.SplitPanelPreferences) => { - setSplitPanelPreferences(detail); - splitPanelFocusControl.setLastInteraction({ type: 'position' }); - fireNonCancelableEvent(onSplitPanelPreferencesChange, detail); - }; - - const [splitPanelSize = 0, setSplitPanelSize] = useControllable( - controlledSplitPanelSize, - onSplitPanelResize, - getSplitPanelDefaultSize(splitPanelPreferences?.position ?? 'bottom'), - { componentName: 'AppLayout', controlledProp: 'splitPanelSize', changeHandler: 'onSplitPanelResize' } - ); - - const [splitPanelReportedSize, setSplitPanelReportedSize] = useState(0); - const [splitPanelHeaderBlockSize, setSplitPanelHeaderBlockSize] = useState(0); - - const onSplitPanelResizeHandler = (size: number) => { - setSplitPanelSize(size); - fireNonCancelableEvent(onSplitPanelResize, { size }); - }; - - const [splitPanelToggleConfig, setSplitPanelToggleConfig] = useState({ - ariaLabel: undefined, - displayed: false, - }); - - const globalDrawersFocusControl = useMultipleFocusControl(true, activeGlobalDrawersIds); - const drawersFocusControl = useFocusControl(!!activeDrawer?.id, true, activeDrawer?.id); - const navigationFocusControl = useFocusControl(navigationOpen, navigationTriggerHide); - const splitPanelFocusControl = useSplitPanelFocusControl([splitPanelPreferences, splitPanelOpen]); - - const onNavigationToggle = useStableCallback((open: boolean) => { - setNavigationAnimationDisabled(false); - navigationFocusControl.setFocus(); - fireNonCancelableEvent(onNavigationChange, { open }); - }); - - useImperativeHandle(forwardRef, () => ({ - closeNavigationIfNecessary: () => isMobile && onNavigationToggle(false), - openTools: () => onToolsToggle(true), - focusToolsClose: () => drawersFocusControl.setFocus(true), - focusActiveDrawer: () => drawersFocusControl.setFocus(true), - focusSplitPanel: () => splitPanelFocusControl.refs.slider.current?.focus(), - focusNavigation: () => navigationFocusControl.setFocus(true), - })); - - const resolvedStickyNotifications = !!stickyNotifications && !isMobile; - //navigation must be null if hidden so toolbar knows to hide the toggle button - const resolvedNavigation = navigationHide ? null : navigation || <>; - //navigation must not be open if navigationHide is true - const resolvedNavigationOpen = !!resolvedNavigation && navigationOpen; - const { - maxDrawerSize, - maxSplitPanelSize, - splitPanelForcedPosition, - splitPanelPosition, - maxGlobalDrawersSizes, - resizableSpaceAvailable, - } = computeHorizontalLayout({ - activeDrawerSize: activeDrawer ? activeDrawerSize : 0, - splitPanelSize, - minContentWidth, - navigationOpen: resolvedNavigationOpen, - navigationWidth, - placement, - splitPanelOpen, - splitPanelPosition: splitPanelPreferences?.position, - isMobile, - activeGlobalDrawersSizes, - }); - - const { ref: intersectionObserverRef, isIntersecting } = useIntersectionObserver({ initialState: true }); - const { registered, toolbarProps } = useMultiAppLayout( - { - forceDeduplicationType, - ariaLabels: ariaLabelsWithDrawers, - navigation: resolvedNavigation && !navigationTriggerHide, - navigationOpen: resolvedNavigationOpen, - onNavigationToggle, - navigationFocusRef: navigationFocusControl.refs.toggle, - breadcrumbs, - activeDrawerId: activeDrawer?.id ?? null, - // only pass it down if there are non-empty drawers or tools - drawers: drawers?.length || !toolsHide ? drawers : undefined, - globalDrawersFocusControl, - globalDrawers: globalDrawers?.length ? globalDrawers : undefined, - activeGlobalDrawersIds, - onActiveGlobalDrawersChange, - onActiveDrawerChange: onActiveDrawerChangeHandler, - drawersFocusRef: drawersFocusControl.refs.toggle, - splitPanel, - splitPanelToggleProps: { - ...splitPanelToggleConfig, - active: splitPanelOpen, - controlId: splitPanelControlId, - position: splitPanelPosition, - }, - splitPanelFocusRef: splitPanelFocusControl.refs.toggle, - onSplitPanelToggle: onSplitPanelToggleHandler, - }, - isIntersecting - ); - - const hasToolbar = !embeddedViewMode && !!toolbarProps; - const discoveredBreadcrumbs = useGetGlobalBreadcrumbs(hasToolbar && !breadcrumbs); - - const verticalOffsets = computeVerticalLayout({ - topOffset: placement.insetBlockStart, - hasVisibleToolbar: hasToolbar && toolbarState !== 'hide', - notificationsHeight: notificationsHeight ?? 0, - toolbarHeight: toolbarHeight ?? 0, - stickyNotifications: resolvedStickyNotifications, - }); - - const appLayoutInternals: 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, - setToolbarHeight, - setNotificationsHeight, - onSplitPanelToggle: onSplitPanelToggleHandler, - onNavigationToggle, - onActiveDrawerChange: onActiveDrawerChangeHandler, - onActiveDrawerResize, - splitPanelAnimationDisabled, - }; - - const splitPanelInternals: SplitPanelProviderProps = { - bottomOffset: 0, - getMaxHeight: () => { - const availableHeight = - document.documentElement.clientHeight - placement.insetBlockStart - placement.insetBlockEnd; - // 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; - }, - maxWidth: maxSplitPanelSize, - isForcedPosition: splitPanelForcedPosition, - isOpen: splitPanelOpen, - leftOffset: 0, - onPreferencesChange: onSplitPanelPreferencesChangeHandler, - onResize: onSplitPanelResizeHandler, - onToggle: onSplitPanelToggleHandler, - position: splitPanelPosition, - reportSize: size => setSplitPanelReportedSize(size), - reportHeaderHeight: size => setSplitPanelHeaderBlockSize(size), - headerHeight: splitPanelHeaderBlockSize, - rightOffset: 0, - size: splitPanelSize, - topOffset: 0, - setSplitPanelToggle: setSplitPanelToggleConfig, - refs: splitPanelFocusControl.refs, - }; - - const closeFirstDrawer = useStableCallback(() => { - const drawerToClose = drawersOpenQueue[drawersOpenQueue.length - 1]; - if (activeDrawer && 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(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isMobile]); - - const getTotalActiveDrawersMinSize = () => { - const combinedDrawers = [...(drawers || []), ...globalDrawers]; - let result = activeGlobalDrawersIds - .map(activeDrawerId => - Math.min( - combinedDrawers.find(drawer => drawer.id === activeDrawerId)?.defaultSize ?? MIN_DRAWER_SIZE, - MIN_DRAWER_SIZE - ) - ) - .reduce((acc, curr) => acc + curr, 0); - if (activeDrawer) { - result += Math.min(activeDrawer?.defaultSize ?? 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(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: HTMLElement | null): boolean => { - let currentElement: Element | null = element?.parentElement ?? 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; - }; - - useLayoutEffect(() => { - if (!hasToolbar) { - setIsNested(getIsNestedInAppLayout(rootRef.current)); - } - }, [hasToolbar]); - - const splitPanelOffsets = computeSplitPanelOffsets({ - placement, - hasSplitPanel: !!splitPanel, splitPanelOpen, - splitPanelPosition, - splitPanelFullHeight: splitPanelReportedSize, - splitPanelHeaderHeight: splitPanelHeaderBlockSize, - }); + splitPanelInternals, + } = useAppLayout(props, forwardRef); return ( {/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */} {!hasToolbar && breadcrumbs ? {breadcrumbs} : null} - + hasToolbar && } notifications={ notifications && ( diff --git a/src/app-layout/visual-refresh-toolbar/internal.tsx b/src/app-layout/visual-refresh-toolbar/internal.tsx index 0f7e01ece4..38dc5f0368 100644 --- a/src/app-layout/visual-refresh-toolbar/internal.tsx +++ b/src/app-layout/visual-refresh-toolbar/internal.tsx @@ -3,6 +3,7 @@ import { createWidgetizedAppLayoutDrawer, createWidgetizedAppLayoutGlobalDrawers } from './drawer'; import { createWidgetizedAppLayoutNavigation } from './navigation'; import { createWidgetizedAppLayoutNotifications } from './notifications'; +import { createWidgetizedSkeletonLayout } from './skeleton'; import { createWidgetizedAppLayoutSplitPanelDrawerBottom, createWidgetizedAppLayoutSplitPanelDrawerSide, @@ -16,3 +17,4 @@ export const AppLayoutNotifications = createWidgetizedAppLayoutNotifications(); export const AppLayoutToolbar = createWidgetizedAppLayoutToolbar(); export const AppLayoutSplitPanelBottom = createWidgetizedAppLayoutSplitPanelDrawerBottom(); export const AppLayoutSplitPanelSide = createWidgetizedAppLayoutSplitPanelDrawerSide(); +export const AppLayoutSkeletonLayout = createWidgetizedSkeletonLayout(); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index 05deec3818..59933202bd 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx'; import customCssProps from '../../../internal/generated/custom-css-properties'; import { useMobile } from '../../../internal/hooks/use-mobile'; import { highContrastHeaderClassName } from '../../../internal/utils/content-header-utils'; +import { createWidgetizedComponent } from '../../../internal/widgets'; import { AppLayoutPropsWithDefaults } from '../../interfaces'; import sharedStyles from '../../resize/styles.css.js'; @@ -14,7 +15,7 @@ import styles from './styles.css.js'; const contentTypeCustomWidths: Array = ['dashboard', 'cards', 'table']; -interface SkeletonLayoutProps +export interface SkeletonLayoutProps extends Pick< AppLayoutPropsWithDefaults, | 'notifications' @@ -41,108 +42,104 @@ interface SkeletonLayoutProps globalToolsOpen?: boolean; navigationAnimationDisabled?: boolean; isNested?: boolean; + rootRef?: React.Ref; } -export const SkeletonLayout = React.forwardRef( - ( - { - style, - notifications, - headerVariant, - contentHeader, - content, - navigation, - navigationOpen, - navigationWidth, - tools, - globalTools, - toolsOpen, - toolsWidth, - toolbar, - sideSplitPanel, - bottomSplitPanel, - splitPanelOpen, - placement, - contentType, - maxContentWidth, - disableContentPaddings, - globalToolsOpen, - navigationAnimationDisabled, - isNested, - }, - ref - ) => { - const isMobile = useMobile(); - const isMaxWidth = maxContentWidth === Number.MAX_VALUE || maxContentWidth === Number.MAX_SAFE_INTEGER; - const anyPanelOpen = navigationOpen || toolsOpen; - return ( -
- {toolbar} - {navigation && ( +export const SkeletonLayoutImplementation = ({ + style, + notifications, + headerVariant, + contentHeader, + content, + navigation, + navigationOpen, + navigationWidth, + tools, + globalTools, + toolsOpen, + toolsWidth, + toolbar, + sideSplitPanel, + bottomSplitPanel, + splitPanelOpen, + placement, + contentType, + maxContentWidth, + disableContentPaddings, + globalToolsOpen, + navigationAnimationDisabled, + isNested, + rootRef, +}: SkeletonLayoutProps) => { + const isMobile = useMobile(); + const isMaxWidth = maxContentWidth === Number.MAX_VALUE || maxContentWidth === Number.MAX_SAFE_INTEGER; + const anyPanelOpen = navigationOpen || toolsOpen; + return ( +
+ {toolbar} + {navigation && ( +
+ {navigation} +
+ )} +
+ {notifications && (
- {navigation} -
+ >
)} -
- {notifications && ( -
- )} - {notifications} -
- {contentHeader &&
{contentHeader}
} -
{content}
-
- {bottomSplitPanel && ( -
- {bottomSplitPanel} -
- )} -
- {sideSplitPanel && ( -
- {sideSplitPanel} + {notifications} +
+ {contentHeader &&
{contentHeader}
} +
{content}
+
+ {bottomSplitPanel && ( +
+ {bottomSplitPanel}
)} -
- {tools} + + {sideSplitPanel && ( +
+ {sideSplitPanel}
-
{globalTools}
+ )} +
+ {tools}
- ); - } -); +
{globalTools}
+
+ ); +}; + +export const createWidgetizedSkeletonLayout = createWidgetizedComponent(SkeletonLayoutImplementation); diff --git a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx new file mode 100644 index 0000000000..7f860a91ce --- /dev/null +++ b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx @@ -0,0 +1,477 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { ForwardedRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; + +import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; + +import { SplitPanelSideToggleProps } from '../../internal/context/split-panel-context'; +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 { useUniqueId } from '../../internal/hooks/use-unique-id'; +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 { AppLayoutProps } from '../interfaces'; +import { SplitPanelProviderProps } from '../split-panel'; +import { MIN_DRAWER_SIZE, OnChangeParams, useDrawers } from '../utils/use-drawers'; +import { useFocusControl, useMultipleFocusControl } from '../utils/use-focus-control'; +import { useSplitPanelFocusControl } from '../utils/use-split-panel-focus-control'; +import { + computeHorizontalLayout, + computeSplitPanelOffsets, + computeVerticalLayout, + CONTENT_PADDING, +} from './compute-layout'; +import { AppLayoutInternalProps, AppLayoutInternals } from './interfaces'; +import { useMultiAppLayout } from './multi-layout'; + +export const useAppLayout = (props: AppLayoutInternalProps, forwardRef: ForwardedRef) => { + const { + 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 + } = props; + const isMobile = useMobile(); + const { __embeddedViewMode: embeddedViewMode, __forceDeduplicationType: forceDeduplicationType } = rest as any; + const splitPanelControlId = useUniqueId('split-panel'); + const [toolbarState, setToolbarState] = useState<'show' | 'hide'>('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 rootRef = useRef(null); + + const [toolsOpen = false, setToolsOpen] = useControllable(controlledToolsOpen, onToolsChange, false, { + componentName: 'AppLayout', + controlledProp: 'toolsOpen', + changeHandler: 'onToolsChange', + }); + const onToolsToggle = (open: boolean) => { + setToolsOpen(open); + drawersFocusControl.setFocus(); + fireNonCancelableEvent(onToolsChange, { open }); + }; + + const onGlobalDrawerFocus = (drawerId: string, open: boolean) => { + globalDrawersFocusControl.setFocus({ force: true, drawerId, open }); + }; + + const onAddNewActiveDrawer = (drawerId: string) => { + // If a local 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?.find(drawer => drawer.id === 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(newDrawer.defaultSize ?? drawerSizes[drawerId] ?? 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 { + drawers, + activeDrawer, + minDrawerSize, + minGlobalDrawersSizes, + activeDrawerSize, + ariaLabelsWithDrawers, + globalDrawers, + activeGlobalDrawers, + activeGlobalDrawersIds, + activeGlobalDrawersSizes, + drawerSizes, + drawersOpenQueue, + onActiveDrawerChange, + onActiveDrawerResize, + onActiveGlobalDrawersChange, + } = useDrawers({ ...rest, onGlobalDrawerFocus, onAddNewActiveDrawer }, ariaLabels, { + ariaLabels, + toolsHide, + toolsOpen, + tools, + toolsWidth, + onToolsToggle, + }); + + const onActiveDrawerChangeHandler = ( + drawerId: string | null, + params: OnChangeParams = { 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: AppLayoutProps.SplitPanelPreferences) => { + setSplitPanelPreferences(detail); + splitPanelFocusControl.setLastInteraction({ type: 'position' }); + fireNonCancelableEvent(onSplitPanelPreferencesChange, detail); + }; + + const [splitPanelSize = 0, setSplitPanelSize] = useControllable( + controlledSplitPanelSize, + onSplitPanelResize, + getSplitPanelDefaultSize(splitPanelPreferences?.position ?? 'bottom'), + { componentName: 'AppLayout', controlledProp: 'splitPanelSize', changeHandler: 'onSplitPanelResize' } + ); + + const [splitPanelReportedSize, setSplitPanelReportedSize] = useState(0); + const [splitPanelHeaderBlockSize, setSplitPanelHeaderBlockSize] = useState(0); + + const onSplitPanelResizeHandler = (size: number) => { + setSplitPanelSize(size); + fireNonCancelableEvent(onSplitPanelResize, { size }); + }; + + const [splitPanelToggleConfig, setSplitPanelToggleConfig] = useState({ + ariaLabel: undefined, + displayed: false, + }); + + const globalDrawersFocusControl = useMultipleFocusControl(true, activeGlobalDrawersIds); + const drawersFocusControl = useFocusControl(!!activeDrawer?.id, true, activeDrawer?.id); + const navigationFocusControl = useFocusControl(navigationOpen, navigationTriggerHide); + const splitPanelFocusControl = useSplitPanelFocusControl([splitPanelPreferences, splitPanelOpen]); + + const onNavigationToggle = useStableCallback((open: boolean) => { + setNavigationAnimationDisabled(false); + navigationFocusControl.setFocus(); + fireNonCancelableEvent(onNavigationChange, { open }); + }); + + useImperativeHandle(forwardRef, () => ({ + closeNavigationIfNecessary: () => isMobile && onNavigationToggle(false), + openTools: () => onToolsToggle(true), + focusToolsClose: () => drawersFocusControl.setFocus(true), + focusActiveDrawer: () => drawersFocusControl.setFocus(true), + focusSplitPanel: () => splitPanelFocusControl.refs.slider.current?.focus(), + focusNavigation: () => navigationFocusControl.setFocus(true), + })); + + const resolvedStickyNotifications = !!stickyNotifications && !isMobile; + //navigation must be null if hidden so toolbar knows to hide the toggle button + const resolvedNavigation = navigationHide ? null : navigation || <>; + //navigation must not be open if navigationHide is true + const resolvedNavigationOpen = !!resolvedNavigation && navigationOpen; + const { + maxDrawerSize, + maxSplitPanelSize, + splitPanelForcedPosition, + splitPanelPosition, + maxGlobalDrawersSizes, + resizableSpaceAvailable, + } = computeHorizontalLayout({ + activeDrawerSize: activeDrawer ? activeDrawerSize : 0, + splitPanelSize, + minContentWidth, + navigationOpen: resolvedNavigationOpen, + navigationWidth, + placement, + splitPanelOpen, + splitPanelPosition: splitPanelPreferences?.position, + isMobile, + activeGlobalDrawersSizes, + }); + + const { ref: intersectionObserverRef, isIntersecting } = useIntersectionObserver({ initialState: true }); + const { registered, toolbarProps } = useMultiAppLayout( + { + forceDeduplicationType, + ariaLabels: ariaLabelsWithDrawers, + navigation: resolvedNavigation && !navigationTriggerHide, + navigationOpen: resolvedNavigationOpen, + onNavigationToggle, + navigationFocusRef: navigationFocusControl.refs.toggle, + breadcrumbs, + activeDrawerId: activeDrawer?.id ?? null, + // only pass it down if there are non-empty drawers or tools + drawers: drawers?.length || !toolsHide ? drawers : undefined, + globalDrawersFocusControl, + globalDrawers: globalDrawers?.length ? globalDrawers : undefined, + activeGlobalDrawersIds, + onActiveGlobalDrawersChange, + onActiveDrawerChange: onActiveDrawerChangeHandler, + drawersFocusRef: drawersFocusControl.refs.toggle, + splitPanel, + splitPanelToggleProps: { + ...splitPanelToggleConfig, + active: splitPanelOpen, + controlId: splitPanelControlId, + position: splitPanelPosition, + }, + splitPanelFocusRef: splitPanelFocusControl.refs.toggle, + onSplitPanelToggle: onSplitPanelToggleHandler, + }, + isIntersecting + ); + + const hasToolbar = !embeddedViewMode && !!toolbarProps; + const discoveredBreadcrumbs = useGetGlobalBreadcrumbs(hasToolbar && !breadcrumbs); + + const verticalOffsets = computeVerticalLayout({ + topOffset: placement.insetBlockStart, + hasVisibleToolbar: hasToolbar && toolbarState !== 'hide', + notificationsHeight: notificationsHeight ?? 0, + toolbarHeight: toolbarHeight ?? 0, + stickyNotifications: resolvedStickyNotifications, + }); + + const appLayoutInternals: 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, + setToolbarHeight, + setNotificationsHeight, + onSplitPanelToggle: onSplitPanelToggleHandler, + onNavigationToggle, + onActiveDrawerChange: onActiveDrawerChangeHandler, + onActiveDrawerResize, + splitPanelAnimationDisabled, + }; + + const splitPanelInternals: SplitPanelProviderProps = { + bottomOffset: 0, + getMaxHeight: () => { + const availableHeight = + document.documentElement.clientHeight - placement.insetBlockStart - placement.insetBlockEnd; + // 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; + }, + maxWidth: maxSplitPanelSize, + isForcedPosition: splitPanelForcedPosition, + isOpen: splitPanelOpen, + leftOffset: 0, + onPreferencesChange: onSplitPanelPreferencesChangeHandler, + onResize: onSplitPanelResizeHandler, + onToggle: onSplitPanelToggleHandler, + position: splitPanelPosition, + reportSize: size => setSplitPanelReportedSize(size), + reportHeaderHeight: size => setSplitPanelHeaderBlockSize(size), + headerHeight: splitPanelHeaderBlockSize, + rightOffset: 0, + size: splitPanelSize, + topOffset: 0, + setSplitPanelToggle: setSplitPanelToggleConfig, + refs: splitPanelFocusControl.refs, + }; + + const closeFirstDrawer = useStableCallback(() => { + const drawerToClose = drawersOpenQueue[drawersOpenQueue.length - 1]; + if (activeDrawer && 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(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isMobile]); + + const getTotalActiveDrawersMinSize = () => { + const combinedDrawers = [...(drawers || []), ...globalDrawers]; + let result = activeGlobalDrawersIds + .map(activeDrawerId => + Math.min( + combinedDrawers.find(drawer => drawer.id === activeDrawerId)?.defaultSize ?? MIN_DRAWER_SIZE, + MIN_DRAWER_SIZE + ) + ) + .reduce((acc, curr) => acc + curr, 0); + if (activeDrawer) { + result += Math.min(activeDrawer?.defaultSize ?? 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(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: HTMLElement | null): boolean => { + let currentElement: Element | null = element?.parentElement ?? 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; + }; + + useLayoutEffect(() => { + if (!hasToolbar) { + setIsNested(getIsNestedInAppLayout(rootRef.current)); + } + }, [hasToolbar]); + + const splitPanelOffsets = computeSplitPanelOffsets({ + placement, + hasSplitPanel: !!splitPanel, + splitPanelOpen, + splitPanelPosition, + splitPanelFullHeight: splitPanelReportedSize, + splitPanelHeaderHeight: splitPanelHeaderBlockSize, + }); + + return { + navigationAnimationDisabled, + isNested, + isIntersecting, + hasToolbar, + intersectionObserverRef, + rootRef, + splitPanelOffsets, + verticalOffsets, + isMobile, + appLayoutInternals, + toolbarProps, + registered, + resolvedNavigation, + resolvedNavigationOpen, + drawers, + activeGlobalDrawersIds, + activeDrawer, + activeDrawerSize, + splitPanelPosition, + splitPanelOpen, + splitPanelInternals, + }; +}; From 12dfb0ad8cbe325cb2f02e2bdbf1c501c9861866 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Tue, 15 Apr 2025 13:40:30 +0200 Subject: [PATCH 02/24] chore: Move skeleton elements attributes to a widgetized hook --- .../visual-refresh-toolbar/internal.tsx | 3 + .../visual-refresh-toolbar/skeleton/index.tsx | 84 ++++++++----------- .../skeleton/widget-slots/index.tsx | 74 ++++++++++++++++ src/internal/widgets/index.tsx | 8 ++ 4 files changed, 118 insertions(+), 51 deletions(-) create mode 100644 src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx diff --git a/src/app-layout/visual-refresh-toolbar/internal.tsx b/src/app-layout/visual-refresh-toolbar/internal.tsx index 38dc5f0368..787850c3ec 100644 --- a/src/app-layout/visual-refresh-toolbar/internal.tsx +++ b/src/app-layout/visual-refresh-toolbar/internal.tsx @@ -4,6 +4,7 @@ import { createWidgetizedAppLayoutDrawer, createWidgetizedAppLayoutGlobalDrawers import { createWidgetizedAppLayoutNavigation } from './navigation'; import { createWidgetizedAppLayoutNotifications } from './notifications'; import { createWidgetizedSkeletonLayout } from './skeleton'; +import { createWidgetizedUseSkeletonSlotsAttrributes } from './skeleton/widget-slots'; import { createWidgetizedAppLayoutSplitPanelDrawerBottom, createWidgetizedAppLayoutSplitPanelDrawerSide, @@ -18,3 +19,5 @@ export const AppLayoutToolbar = createWidgetizedAppLayoutToolbar(); export const AppLayoutSplitPanelBottom = createWidgetizedAppLayoutSplitPanelDrawerBottom(); export const AppLayoutSplitPanelSide = createWidgetizedAppLayoutSplitPanelDrawerSide(); export const AppLayoutSkeletonLayout = createWidgetizedSkeletonLayout(); + +export const useSkeletonSlotsAttributes = createWidgetizedUseSkeletonSlotsAttrributes(); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index 59933202bd..02bdd70a8f 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -3,18 +3,14 @@ import React from 'react'; import clsx from 'clsx'; -import customCssProps from '../../../internal/generated/custom-css-properties'; -import { useMobile } from '../../../internal/hooks/use-mobile'; import { highContrastHeaderClassName } from '../../../internal/utils/content-header-utils'; import { createWidgetizedComponent } from '../../../internal/widgets'; import { AppLayoutPropsWithDefaults } from '../../interfaces'; +import { useSkeletonSlotsAttributes } from '../internal'; import sharedStyles from '../../resize/styles.css.js'; -import testutilStyles from '../../test-classes/styles.css.js'; import styles from './styles.css.js'; -const contentTypeCustomWidths: Array = ['dashboard', 'cards', 'table']; - export interface SkeletonLayoutProps extends Pick< AppLayoutPropsWithDefaults, @@ -45,49 +41,35 @@ export interface SkeletonLayoutProps rootRef?: React.Ref; } -export const SkeletonLayoutImplementation = ({ - style, - notifications, - headerVariant, - contentHeader, - content, - navigation, - navigationOpen, - navigationWidth, - tools, - globalTools, - toolsOpen, - toolsWidth, - toolbar, - sideSplitPanel, - bottomSplitPanel, - splitPanelOpen, - placement, - contentType, - maxContentWidth, - disableContentPaddings, - globalToolsOpen, - navigationAnimationDisabled, - isNested, - rootRef, -}: SkeletonLayoutProps) => { - const isMobile = useMobile(); - const isMaxWidth = maxContentWidth === Number.MAX_VALUE || maxContentWidth === Number.MAX_SAFE_INTEGER; - const anyPanelOpen = navigationOpen || toolsOpen; +export const SkeletonLayoutImplementation = (props: SkeletonLayoutProps) => { + const { + notifications, + headerVariant, + contentHeader, + content, + navigation, + navigationOpen, + tools, + globalTools, + toolsOpen, + toolbar, + sideSplitPanel, + bottomSplitPanel, + splitPanelOpen, + placement, + globalToolsOpen, + navigationAnimationDisabled, + } = props; + const { + wrapperElAttributes, + mainElAttributes, + contentWrapperElAttributes, + contentHeaderElAttributes, + contentElAttributes, + } = useSkeletonSlotsAttributes(props) ?? {}; + return ( -
+
{toolbar} {navigation && (
)} -
+
{notifications && (
)} {notifications} -
- {contentHeader &&
{contentHeader}
} -
{content}
+
+ {contentHeader &&
{contentHeader}
} +
{content}
{bottomSplitPanel && (
diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx new file mode 100644 index 0000000000..0e8e8cea54 --- /dev/null +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import clsx from 'clsx'; + +import customCssProps from '../../../../internal/generated/custom-css-properties'; +import { useMobile } from '../../../../internal/hooks/use-mobile'; +import { createWidgetizedFunction } from '../../../../internal/widgets'; +import { SkeletonLayoutProps } from '../index'; + +import testutilStyles from '../../../test-classes/styles.css.js'; +import styles from '../styles.css.js'; + +const contentTypeCustomWidths: Array = ['dashboard', 'cards', 'table']; + +const useSkeletonSlotsAttributes = (props: SkeletonLayoutProps) => { + const { + rootRef, + contentType, + isNested, + placement, + maxContentWidth, + navigationWidth, + toolsWidth, + disableContentPaddings, + style, + navigationOpen, + toolsOpen, + } = props; + const isMobile = useMobile(); + const anyPanelOpen = navigationOpen || toolsOpen; + const isMaxWidth = maxContentWidth === Number.MAX_VALUE || maxContentWidth === Number.MAX_SAFE_INTEGER; + const wrapperElAttributes = { + ref: rootRef, + className: clsx(styles.root, testutilStyles.root, { + [styles['has-adaptive-widths-default']]: !contentTypeCustomWidths.includes(contentType), + [styles['has-adaptive-widths-dashboard']]: contentType === 'dashboard', + }), + style: { + minBlockSize: isNested ? '100%' : `calc(100vh - ${placement.insetBlockStart + placement.insetBlockEnd}px)`, + [customCssProps.maxContentWidth]: isMaxWidth ? '100%' : maxContentWidth ? `${maxContentWidth}px` : '', + [customCssProps.navigationWidth]: `${navigationWidth}px`, + [customCssProps.toolsWidth]: `${toolsWidth}px`, + }, + }; + + const mainElAttributes = { + className: clsx(styles['main-landmark'], isMobile && anyPanelOpen && styles['unfocusable-mobile']), + }; + + const contentWrapperElAttributes = { + className: clsx(styles.main, { [styles['main-disable-paddings']]: disableContentPaddings }), + style: style, + }; + + const contentHeaderElAttributes = { + className: styles['content-header'], + }; + + const contentElAttributes = { + className: clsx(styles.content, testutilStyles.content), + }; + + return { + wrapperElAttributes, + mainElAttributes, + contentWrapperElAttributes, + contentHeaderElAttributes, + contentElAttributes, + }; +}; + +export const createWidgetizedUseSkeletonSlotsAttrributes = createWidgetizedFunction(useSkeletonSlotsAttributes); diff --git a/src/internal/widgets/index.tsx b/src/internal/widgets/index.tsx index e5e6a1e3e3..5050d08115 100644 --- a/src/internal/widgets/index.tsx +++ b/src/internal/widgets/index.tsx @@ -26,3 +26,11 @@ export function createWidgetizedComponent any>(fn: F): () => F { + return (): F => { + return ((props?: Parameters[0]): ReturnType => { + return fn(props); + }) as F; + }; +} From 1b33698339c51f8983aecc5b07cc0ce24d35e160 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Tue, 15 Apr 2025 14:18:33 +0200 Subject: [PATCH 03/24] chore: Move skeleton slots to widgets --- .../visual-refresh-toolbar/index.tsx | 4 +- .../visual-refresh-toolbar/internal.tsx | 10 ++- .../visual-refresh-toolbar/skeleton/index.tsx | 85 +++---------------- .../widget-slots/bottom-page-content-slot.tsx | 23 +++++ .../skeleton/widget-slots/side-page-slot.tsx | 37 ++++++++ .../widget-slots/top-page-content-slot.tsx | 29 +++++++ .../skeleton/widget-slots/top-page-slot.tsx | 33 +++++++ 7 files changed, 145 insertions(+), 76 deletions(-) create mode 100644 src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx create mode 100644 src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx create mode 100644 src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx create mode 100644 src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index 6e07ec45d5..f8b0ed6e99 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -14,11 +14,11 @@ import { AppLayoutGlobalDrawers, AppLayoutNavigation, AppLayoutNotifications, - AppLayoutSkeletonLayout, AppLayoutSplitPanelBottom, AppLayoutSplitPanelSide, AppLayoutToolbar, } from './internal'; +import { SkeletonLayout } from './skeleton'; import { useAppLayout } from './use-app-layout'; const AppLayoutVisualRefreshToolbar = React.forwardRef( @@ -66,7 +66,7 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef {/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */} {!hasToolbar && breadcrumbs ? {breadcrumbs} : null} - ; } -export const SkeletonLayoutImplementation = (props: SkeletonLayoutProps) => { - const { - notifications, - headerVariant, - contentHeader, - content, - navigation, - navigationOpen, - tools, - globalTools, - toolsOpen, - toolbar, - sideSplitPanel, - bottomSplitPanel, - splitPanelOpen, - placement, - globalToolsOpen, - navigationAnimationDisabled, - } = props; +export const SkeletonLayout = (props: SkeletonLayoutProps) => { + const { contentHeader, content } = props; const { wrapperElAttributes, mainElAttributes, @@ -70,58 +53,16 @@ export const SkeletonLayoutImplementation = (props: SkeletonLayoutProps) => { return (
- {toolbar} - {navigation && ( -
- {navigation} -
- )} +
- {notifications && ( -
- )} - {notifications} +
{contentHeader &&
{contentHeader}
}
{content}
- {bottomSplitPanel && ( -
- {bottomSplitPanel} -
- )} +
- {sideSplitPanel && ( -
- {sideSplitPanel} -
- )} -
- {tools} -
-
{globalTools}
+
); }; - -export const createWidgetizedSkeletonLayout = createWidgetizedComponent(SkeletonLayoutImplementation); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx new file mode 100644 index 0000000000..142d736dc5 --- /dev/null +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { SkeletonLayoutProps } from '../index'; + +import styles from '../styles.css.js'; + +const BottomPageContentSlot = (props: SkeletonLayoutProps) => { + const { bottomSplitPanel, placement } = props; + return ( + <> + {bottomSplitPanel && ( +
+ {bottomSplitPanel} +
+ )} + + ); +}; + +export const createWidgetizedAppLayoutBottomPageContentSlot = createWidgetizedComponent(BottomPageContentSlot); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx new file mode 100644 index 0000000000..cb35acf048 --- /dev/null +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { SkeletonLayoutProps } from '../index'; + +import sharedStyles from '../../../resize/styles.css.js'; +import styles from '../styles.css.js'; + +const SidePageSlot = (props: SkeletonLayoutProps) => { + const { sideSplitPanel, splitPanelOpen, toolsOpen, navigationOpen, tools, globalToolsOpen, globalTools } = props; + return ( + <> + {sideSplitPanel && ( +
+ {sideSplitPanel} +
+ )} +
+ {tools} +
+
{globalTools}
+ + ); +}; + +export const createWidgetizedAppLayoutSidePageSlot = createWidgetizedComponent(SidePageSlot); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx new file mode 100644 index 0000000000..a954ebbe45 --- /dev/null +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { highContrastHeaderClassName } from '../../../../internal/utils/content-header-utils'; +import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { SkeletonLayoutProps } from '../index'; + +import styles from '../styles.css.js'; + +const TopPageContentSlot = (props: SkeletonLayoutProps) => { + const { notifications, headerVariant } = props; + return ( + <> + {notifications && ( +
+ )} + {notifications} + + ); +}; + +export const createWidgetizedAppLayoutTopPageContentSlot = createWidgetizedComponent(TopPageContentSlot); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx new file mode 100644 index 0000000000..56a782bc93 --- /dev/null +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { SkeletonLayoutProps } from '../index'; + +import sharedStyles from '../../../resize/styles.css.js'; +import styles from '../styles.css.js'; + +const TopPageSlot = (props: SkeletonLayoutProps) => { + const { toolbar, navigation, navigationOpen, toolsOpen, navigationAnimationDisabled } = props; + return ( + <> + {toolbar} + {navigation && ( +
+ {navigation} +
+ )} + + ); +}; + +export const createWidgetizedAppLayoutTopPageSlot = createWidgetizedComponent(TopPageSlot); From 282a4130263a15cc5cd0cd6d4f03e5a4bdcdc127 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Tue, 15 Apr 2025 18:03:43 +0200 Subject: [PATCH 04/24] chore: Widgetize all remaining components --- .../visual-refresh-toolbar/index.tsx | 119 +----------------- .../visual-refresh-toolbar/internal.tsx | 2 + .../visual-refresh-toolbar/skeleton/index.tsx | 41 ++---- .../widget-slots/bottom-page-content-slot.tsx | 12 +- .../skeleton/widget-slots/index.tsx | 43 ++++--- .../skeleton/widget-slots/side-page-slot.tsx | 38 +++++- .../widget-slots/top-page-content-slot.tsx | 10 +- .../skeleton/widget-slots/top-page-slot.tsx | 23 +++- .../visual-refresh-toolbar/use-app-layout.tsx | 3 + src/internal/widgets/index.tsx | 6 +- 10 files changed, 118 insertions(+), 179 deletions(-) diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index f8b0ed6e99..432f9693cd 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -3,133 +3,24 @@ import React from 'react'; import ScreenreaderOnly from '../../internal/components/screenreader-only'; -import { useMergeRefs } from '../../internal/hooks/use-merge-refs'; -import globalVars from '../../internal/styles/global-vars'; import { AppLayoutProps } from '../interfaces'; -import { ActiveDrawersContext } from '../utils/visibility-context'; import { AppLayoutVisibilityContext } from './contexts'; import { AppLayoutInternalProps } from './interfaces'; -import { - AppLayoutDrawer, - AppLayoutGlobalDrawers, - AppLayoutNavigation, - AppLayoutNotifications, - AppLayoutSplitPanelBottom, - AppLayoutSplitPanelSide, - AppLayoutToolbar, -} from './internal'; +import { useAppLayout } from './internal'; import { SkeletonLayout } from './skeleton'; -import { useAppLayout } from './use-app-layout'; const AppLayoutVisualRefreshToolbar = React.forwardRef( (props, forwardRef) => { - const { - contentHeader, - content, - navigationWidth, - contentType, - headerVariant, - breadcrumbs, - notifications, - splitPanel, - disableContentPaddings, - minContentWidth, - maxContentWidth, - placement, - } = props; + const { breadcrumbs } = props; - const { - navigationAnimationDisabled, - isNested, - isIntersecting, - hasToolbar, - intersectionObserverRef, - rootRef, - splitPanelOffsets, - verticalOffsets, - isMobile, - appLayoutInternals, - toolbarProps, - registered, - resolvedNavigation, - resolvedNavigationOpen, - drawers, - activeGlobalDrawersIds, - activeDrawer, - activeDrawerSize, - splitPanelPosition, - splitPanelOpen, - splitPanelInternals, - } = useAppLayout(props, forwardRef); + const appLayoutState = useAppLayout(props, forwardRef); + const { isIntersecting, hasToolbar } = appLayoutState; return ( {/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */} {!hasToolbar && breadcrumbs ? {breadcrumbs} : null} - - } - notifications={ - notifications && ( - {notifications} - ) - } - headerVariant={headerVariant} - contentHeader={contentHeader} - // delay rendering the content until registration of this instance is complete - content={registered ? content : null} - navigation={resolvedNavigation && } - navigationOpen={resolvedNavigationOpen} - navigationWidth={navigationWidth} - navigationAnimationDisabled={navigationAnimationDisabled} - tools={drawers && drawers.length > 0 && } - globalTools={ - - - - } - globalToolsOpen={!!activeGlobalDrawersIds.length} - toolsOpen={!!activeDrawer} - toolsWidth={activeDrawerSize} - sideSplitPanel={ - splitPanelPosition === 'side' && ( - - {splitPanel} - - ) - } - bottomSplitPanel={ - splitPanelPosition === 'bottom' && ( - - {splitPanel} - - ) - } - splitPanelOpen={splitPanelOpen} - placement={placement} - contentType={contentType} - maxContentWidth={maxContentWidth} - disableContentPaddings={disableContentPaddings} - /> + ); } diff --git a/src/app-layout/visual-refresh-toolbar/internal.tsx b/src/app-layout/visual-refresh-toolbar/internal.tsx index c995dccac0..0e5f6d43a8 100644 --- a/src/app-layout/visual-refresh-toolbar/internal.tsx +++ b/src/app-layout/visual-refresh-toolbar/internal.tsx @@ -13,6 +13,7 @@ import { createWidgetizedAppLayoutSplitPanelDrawerSide, } from './split-panel'; import { createWidgetizedAppLayoutToolbar } from './toolbar'; +import { createWidgetizedUseAppLayout } from './use-app-layout'; export const AppLayoutNavigation = createWidgetizedAppLayoutNavigation(); export const AppLayoutDrawer = createWidgetizedAppLayoutDrawer(); @@ -27,3 +28,4 @@ export const AppLayoutSkeletonTopContentSlot = createWidgetizedAppLayoutTopPageC export const AppLayoutSkeletonBottomContentSlot = createWidgetizedAppLayoutBottomPageContentSlot(); export const useSkeletonSlotsAttributes = createWidgetizedUseSkeletonSlotsAttrributes(); +export const useAppLayout = createWidgetizedUseAppLayout(); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index 9704d2cd64..456fe0a1b4 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { AppLayoutPropsWithDefaults } from '../../interfaces'; +import { AppLayoutInternalProps } from '../interfaces'; import { AppLayoutSkeletonBottomContentSlot, AppLayoutSkeletonSideSlot, @@ -10,39 +10,17 @@ import { AppLayoutSkeletonTopSlot, useSkeletonSlotsAttributes, } from '../internal'; +import { useAppLayout } from '../use-app-layout'; -export interface SkeletonLayoutProps - extends Pick< - AppLayoutPropsWithDefaults, - | 'notifications' - | 'headerVariant' - | 'contentHeader' - | 'content' - | 'contentType' - | 'maxContentWidth' - | 'disableContentPaddings' - | 'navigation' - | 'navigationOpen' - | 'navigationWidth' - | 'tools' - | 'toolsOpen' - | 'toolsWidth' - | 'placement' - > { - style?: React.CSSProperties; - toolbar?: React.ReactNode; - splitPanelOpen?: boolean; - sideSplitPanel?: React.ReactNode; - bottomSplitPanel?: React.ReactNode; - globalTools?: React.ReactNode; - globalToolsOpen?: boolean; - navigationAnimationDisabled?: boolean; - isNested?: boolean; - rootRef?: React.Ref; +export interface SkeletonLayoutProps { + appLayoutProps: AppLayoutInternalProps; + appLayoutState: ReturnType; } export const SkeletonLayout = (props: SkeletonLayoutProps) => { - const { contentHeader, content } = props; + const { appLayoutProps, appLayoutState } = props; + const { registered } = appLayoutState; + const { contentHeader, content } = appLayoutProps; const { wrapperElAttributes, mainElAttributes, @@ -58,7 +36,8 @@ export const SkeletonLayout = (props: SkeletonLayoutProps) => {
{contentHeader &&
{contentHeader}
} -
{content}
+ {/*delay rendering the content until registration of this instance is complete*/} +
{registered ? content : null}
diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx index 142d736dc5..384c990bc8 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/bottom-page-content-slot.tsx @@ -3,17 +3,23 @@ import React from 'react'; import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { AppLayoutSplitPanelDrawerBottomImplementation as AppLayoutSplitPanelBottom } from '../../split-panel'; import { SkeletonLayoutProps } from '../index'; import styles from '../styles.css.js'; const BottomPageContentSlot = (props: SkeletonLayoutProps) => { - const { bottomSplitPanel, placement } = props; + const { + appLayoutState: { splitPanelPosition, appLayoutInternals, splitPanelInternals }, + appLayoutProps: { placement, splitPanel }, + } = props; return ( <> - {bottomSplitPanel && ( + {splitPanelPosition === 'bottom' && (
- {bottomSplitPanel} + + {splitPanel} +
)} diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx index 0e8e8cea54..2ccd5638ae 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/index.tsx @@ -5,7 +5,9 @@ import clsx from 'clsx'; import customCssProps from '../../../../internal/generated/custom-css-properties'; +import { useMergeRefs } from '../../../../internal/hooks/use-merge-refs'; import { useMobile } from '../../../../internal/hooks/use-mobile'; +import globalVars from '../../../../internal/styles/global-vars'; import { createWidgetizedFunction } from '../../../../internal/widgets'; import { SkeletonLayoutProps } from '../index'; @@ -14,25 +16,27 @@ import styles from '../styles.css.js'; const contentTypeCustomWidths: Array = ['dashboard', 'cards', 'table']; -const useSkeletonSlotsAttributes = (props: SkeletonLayoutProps) => { +const useSkeletonSlotsAttributes = ({ appLayoutProps, appLayoutState }: SkeletonLayoutProps) => { const { + intersectionObserverRef, rootRef, - contentType, isNested, - placement, - maxContentWidth, - navigationWidth, - toolsWidth, - disableContentPaddings, - style, - navigationOpen, - toolsOpen, - } = props; + activeDrawerSize, + resolvedNavigationOpen, + splitPanelOffsets, + hasToolbar, + verticalOffsets, + activeDrawer, + } = appLayoutState; + const { contentType, placement, maxContentWidth, navigationWidth, minContentWidth, disableContentPaddings } = + appLayoutProps; + const ref = useMergeRefs(intersectionObserverRef, rootRef); const isMobile = useMobile(); - const anyPanelOpen = navigationOpen || toolsOpen; + const toolsOpen = !!activeDrawer; + const anyPanelOpen = resolvedNavigationOpen || toolsOpen; const isMaxWidth = maxContentWidth === Number.MAX_VALUE || maxContentWidth === Number.MAX_SAFE_INTEGER; const wrapperElAttributes = { - ref: rootRef, + ref, className: clsx(styles.root, testutilStyles.root, { [styles['has-adaptive-widths-default']]: !contentTypeCustomWidths.includes(contentType), [styles['has-adaptive-widths-dashboard']]: contentType === 'dashboard', @@ -41,7 +45,7 @@ const useSkeletonSlotsAttributes = (props: SkeletonLayoutProps) => { minBlockSize: isNested ? '100%' : `calc(100vh - ${placement.insetBlockStart + placement.insetBlockEnd}px)`, [customCssProps.maxContentWidth]: isMaxWidth ? '100%' : maxContentWidth ? `${maxContentWidth}px` : '', [customCssProps.navigationWidth]: `${navigationWidth}px`, - [customCssProps.toolsWidth]: `${toolsWidth}px`, + [customCssProps.toolsWidth]: `${activeDrawerSize}px`, }, }; @@ -51,7 +55,16 @@ const useSkeletonSlotsAttributes = (props: SkeletonLayoutProps) => { const contentWrapperElAttributes = { className: clsx(styles.main, { [styles['main-disable-paddings']]: disableContentPaddings }), - style: style, + style: { + paddingBlockEnd: splitPanelOffsets.mainContentPaddingBlockEnd, + ...(hasToolbar || !isNested + ? { + [globalVars.stickyVerticalTopOffset]: `${verticalOffsets.header}px`, + [globalVars.stickyVerticalBottomOffset]: `${splitPanelOffsets.stickyVerticalBottomOffset}px`, + } + : {}), + ...(!isMobile ? { minWidth: `${minContentWidth}px` } : {}), + }, }; const contentHeaderElAttributes = { diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx index cb35acf048..8672bcd8ea 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/side-page-slot.tsx @@ -4,18 +4,40 @@ import React from 'react'; import clsx from 'clsx'; import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { ActiveDrawersContext } from '../../../utils/visibility-context'; +import { + AppLayoutDrawerImplementation as AppLayoutDrawer, + AppLayoutGlobalDrawersImplementation as AppLayoutGlobalDrawers, +} from '../../drawer'; +import { AppLayoutSplitPanelDrawerSideImplementation as AppLayoutSplitPanelSide } from '../../split-panel'; import { SkeletonLayoutProps } from '../index'; import sharedStyles from '../../../resize/styles.css.js'; import styles from '../styles.css.js'; const SidePageSlot = (props: SkeletonLayoutProps) => { - const { sideSplitPanel, splitPanelOpen, toolsOpen, navigationOpen, tools, globalToolsOpen, globalTools } = props; + const { + appLayoutProps: { splitPanel }, + appLayoutState: { + resolvedNavigationOpen, + activeGlobalDrawersIds, + activeDrawer, + splitPanelOpen, + drawers, + appLayoutInternals, + splitPanelInternals, + splitPanelPosition, + }, + } = props; + const toolsOpen = !!activeDrawer; + const globalToolsOpen = !!activeGlobalDrawersIds.length; return ( <> - {sideSplitPanel && ( + {splitPanelPosition === 'side' && (
- {sideSplitPanel} + + {splitPanel} +
)}
{ styles.tools, !toolsOpen && styles['panel-hidden'], sharedStyles['with-motion-horizontal'], - navigationOpen && !toolsOpen && styles['unfocusable-mobile'], + resolvedNavigationOpen && !toolsOpen && styles['unfocusable-mobile'], toolsOpen && styles['tools-open'] )} > - {tools} + {drawers && drawers.length > 0 && } +
+
+ + +
-
{globalTools}
); }; diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx index a954ebbe45..8029ca2d42 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-content-slot.tsx @@ -5,12 +5,16 @@ import clsx from 'clsx'; import { highContrastHeaderClassName } from '../../../../internal/utils/content-header-utils'; import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { AppLayoutNotificationsImplementation as AppLayoutNotifications } from '../../notifications'; import { SkeletonLayoutProps } from '../index'; import styles from '../styles.css.js'; const TopPageContentSlot = (props: SkeletonLayoutProps) => { - const { notifications, headerVariant } = props; + const { + appLayoutProps: { headerVariant, notifications }, + appLayoutState: { appLayoutInternals }, + } = props; return ( <> {notifications && ( @@ -21,7 +25,9 @@ const TopPageContentSlot = (props: SkeletonLayoutProps) => { )} >
)} - {notifications} + {notifications && ( + {notifications} + )} ); }; diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx index 56a782bc93..289902d4c5 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx @@ -4,26 +4,39 @@ import React from 'react'; import clsx from 'clsx'; import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { AppLayoutNavigationImplementation as AppLayoutNavigation } from '../../navigation'; +import { AppLayoutToolbarImplementation as AppLayoutToolbar } from '../../toolbar'; import { SkeletonLayoutProps } from '../index'; import sharedStyles from '../../../resize/styles.css.js'; import styles from '../styles.css.js'; const TopPageSlot = (props: SkeletonLayoutProps) => { - const { toolbar, navigation, navigationOpen, toolsOpen, navigationAnimationDisabled } = props; + const { + appLayoutState: { + resolvedNavigationOpen, + navigationAnimationDisabled, + activeDrawer, + hasToolbar, + appLayoutInternals, + toolbarProps, + resolvedNavigation, + }, + } = props; + const toolsOpen = !!activeDrawer; return ( <> - {toolbar} - {navigation && ( + {hasToolbar && } + {resolvedNavigation && (
- {navigation} + {resolvedNavigation && }
)} diff --git a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx index 7f860a91ce..e25d903e76 100644 --- a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx +++ b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx @@ -12,6 +12,7 @@ import { useMobile } from '../../internal/hooks/use-mobile'; import { useUniqueId } from '../../internal/hooks/use-unique-id'; import { useGetGlobalBreadcrumbs } from '../../internal/plugins/helpers/use-global-breadcrumbs'; import globalVars from '../../internal/styles/global-vars'; +import { createWidgetizedFunction } from '../../internal/widgets'; import { getSplitPanelDefaultSize } from '../../split-panel/utils/size-utils'; import { AppLayoutProps } from '../interfaces'; import { SplitPanelProviderProps } from '../split-panel'; @@ -475,3 +476,5 @@ export const useAppLayout = (props: AppLayoutInternalProps, forwardRef: Forwarde splitPanelInternals, }; }; + +export const createWidgetizedUseAppLayout = createWidgetizedFunction(useAppLayout); diff --git a/src/internal/widgets/index.tsx b/src/internal/widgets/index.tsx index 5050d08115..6f14d24645 100644 --- a/src/internal/widgets/index.tsx +++ b/src/internal/widgets/index.tsx @@ -27,10 +27,10 @@ export function createWidgetizedComponent any>(fn: F): () => F { +export function createWidgetizedFunction any>(fn: F): () => F { return (): F => { - return ((props?: Parameters[0]): ReturnType => { - return fn(props); + return ((...args: Parameters): ReturnType => { + return fn(...args); }) as F; }; } From a64c3a1b09fe087994da2ffb3046defdee532d9e Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Tue, 22 Apr 2025 12:38:22 +0200 Subject: [PATCH 05/24] feat: AppLayout state as widget --- package-lock.json | 11 + package.json | 1 + pages/app-layout/runtime-drawers.page.tsx | 5 +- .../widget-contract-split-panel.test.tsx.snap | 2934 +++++++++++------ .../widget-contract.test.tsx.snap | 2371 +------------ .../__tests__/app-layout.ssr.test.tsx | 9 +- src/app-layout/__tests__/skeleton.test.tsx | 10 +- src/app-layout/__tests__/utils.tsx | 4 +- .../widget-contract-split-panel.test.tsx | 7 + .../__tests__/widget-contract.test.tsx | 7 + .../app-layout-state.tsx | 26 + .../visual-refresh-toolbar/index.tsx | 52 +- .../visual-refresh-toolbar/internal.tsx | 2 + .../visual-refresh-toolbar/skeleton/index.tsx | 15 +- .../skeleton/widget-slots/index.tsx | 12 +- src/internal/widgets/index.tsx | 47 +- 16 files changed, 2110 insertions(+), 3403 deletions(-) create mode 100644 src/app-layout/visual-refresh-toolbar/app-layout-state.tsx diff --git a/package-lock.json b/package-lock.json index bd96b3f2b3..a6427596bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "intl-messageformat": "^10.3.1", "mnth": "^2.0.0", "react-keyed-flatten-children": "^2.2.1", + "react-reverse-portal": "^2.3.0", "react-transition-group": "^4.4.2", "tslib": "^2.4.0", "weekstart": "^1.1.0" @@ -16547,6 +16548,16 @@ "version": "18.3.1", "license": "MIT" }, + "node_modules/react-reverse-portal": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.3.0.tgz", + "integrity": "sha512-kvbPfLPKg6Y3S6tVq83us2RghvDpOS4GcJxbI7cZ0V0tuzUaSzblRIhVnKLOucfqF4lN/i9oWvEmpEi6bAOYlQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-router": { "version": "5.3.4", "dev": true, diff --git a/package.json b/package.json index f9a75bf7db..3ba4f2afa6 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "intl-messageformat": "^10.3.1", "mnth": "^2.0.0", "react-keyed-flatten-children": "^2.2.1", + "react-reverse-portal": "^2.3.0", "react-transition-group": "^4.4.2", "tslib": "^2.4.0", "weekstart": "^1.1.0" diff --git a/pages/app-layout/runtime-drawers.page.tsx b/pages/app-layout/runtime-drawers.page.tsx index b8285406d0..e146afba26 100644 --- a/pages/app-layout/runtime-drawers.page.tsx +++ b/pages/app-layout/runtime-drawers.page.tsx @@ -31,6 +31,7 @@ type DemoContext = React.Context< >; export default function WithDrawers() { + const [counter, setCounter] = useState(0); const [activeDrawerId, setActiveDrawerId] = useState(null); const [helpPathSlug, setHelpPathSlug] = useState('default'); const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); @@ -94,14 +95,14 @@ export default function WithDrawers() { +
Counter {counter}
+ setUrlParams({ hasTools: detail.checked })}> Use Tools - setUrlParams({ hasDrawers: detail.checked })}> Use Drawers - setUrlParams({ hasTools: detail.checked })}> Use Tools + setUrlParams({ hasDrawers: detail.checked })}> Use Drawers + + + + + ); + }; + + render(); + const button = screen.getByTestId('update-button'); + + act(() => { + fireEvent.click(button); + }); + + expect(button.textContent).toBe('Count: 3'); + }); + + it('should clean up properly on unmount', () => { + const portalNode = createHtmlPortalNode(); + const { unmount } = render( + <> + + + + + + ); + + unmount(); + expect(document.body.innerHTML).not.toContain('test-content'); + }); + + it('should handle unmounting and remounting without errors', () => { + const portalNode = createHtmlPortalNode(); + + const { unmount, rerender } = render( + <> + + + + + + ); + + unmount(); + + expect(() => { + rerender( + <> + + + + + + ); + }).not.toThrow(); + }); +}); diff --git a/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx b/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx index 63e275e84e..8ebf056df4 100644 --- a/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx +++ b/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx @@ -196,7 +196,7 @@ class OutPortal> extends React.PureComponent> extends React.PureComponent> extends React.PureComponent); + this.currentPortalNode?.setPortalProps?.({} as ComponentProps); this.currentPortalNode = node; } @@ -229,11 +232,18 @@ class OutPortal> extends React.PureComponent; + if (!node) { + return; + } node.unmount(this.placeholderNode.current!); - node.setPortalProps({} as ComponentProps); + node.setPortalProps?.({} as ComponentProps); } render() { + if (!this.props.node) { + return null; + } + // Render a placeholder to the DOM, so we can get a reference into // our location in the DOM, and swap it out for the portaled node. const tagName = this.props.node.element.tagName; From c321d92a68baf8dd9cd40c15ec13a6e08f6f46cc Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 25 Apr 2025 09:59:32 +0200 Subject: [PATCH 18/24] chore: Additional tests for reverse portal --- .../__tests__/reverse-portal.test.tsx | 92 ++++++++++++++++++- .../visual-refresh-toolbar/reverse-portal.tsx | 67 ++------------ 2 files changed, 98 insertions(+), 61 deletions(-) diff --git a/src/app-layout/__tests__/reverse-portal.test.tsx b/src/app-layout/__tests__/reverse-portal.test.tsx index 2fd2704320..95115f0baa 100644 --- a/src/app-layout/__tests__/reverse-portal.test.tsx +++ b/src/app-layout/__tests__/reverse-portal.test.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useState } from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { createHtmlPortalNode, InPortal, OutPortal } from '../visual-refresh-toolbar/reverse-portal'; @@ -13,7 +13,7 @@ describe('Reverse portal', () => { ); it('should render content in InPortal and display it in OutPortal', () => { - const portalNode = createHtmlPortalNode(); + const portalNode = createHtmlPortalNode({ containerElement: 'section' }); render( <> @@ -241,4 +241,92 @@ describe('Reverse portal', () => { ); }).not.toThrow(); }); + + it('should set multiple attributes on element creation', () => { + const attributes = { + 'data-testid': 'test-portal', + 'aria-label': 'Test Portal', + class: 'portal-class', + role: 'dialog', + }; + + const portalNode = createHtmlPortalNode({ + attributes, + }); + + for (const [key, value] of Object.entries(attributes)) { + expect(portalNode.element.getAttribute(key)).toBe(value); + } + }); + + it('should handle same placeholder mount attempts', () => { + const portalNode = createHtmlPortalNode(); + const parent = document.createElement('div'); + const placeholder = document.createElement('div'); + + parent.appendChild(placeholder); + document.body.appendChild(parent); + + portalNode.mount(parent, placeholder); + const firstMountParent = portalNode.element.parentNode; + + portalNode.mount(parent, placeholder); + const secondMountParent = portalNode.element.parentNode; + + expect(firstMountParent).toBe(secondMountParent); + + document.body.removeChild(parent); + }); + + it('should handle undefined node', () => { + expect(() => { + render( + +
Content
+
+ ); + }).not.toThrow(); + }); + + it('should handle non-React children correctly', () => { + const portalNode = createHtmlPortalNode(); + + const { container } = render( + <> + 123 + + + ); + + expect(container.textContent).toContain('123'); + }); + + it('should maintain props when switching portal nodes', () => { + const TestComponent = ({ count }: { count: number }) =>
Count: {count}
; + + const WrapperComponent = () => { + const [isNodesSwapped, setIsNodesSwapped] = useState(false); + const firstNode = createHtmlPortalNode(); + const secondNode = createHtmlPortalNode(); + return ( + <> + + + + +
+ +
+ + ); + }; + + const { container } = render(); + + expect(container.textContent).toContain('Count: 1'); + screen.getByTestId('button').click(); + expect(container.textContent).toContain('Count: 1'); + }); }); diff --git a/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx b/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx index 8ebf056df4..91ba6e7f98 100644 --- a/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx +++ b/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx @@ -3,10 +3,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -// Internally, the portalNode must be for either HTML or SVG elements -const ELEMENT_TYPE_HTML = 'html'; -const ELEMENT_TYPE_SVG = 'svg'; - interface BaseOptions { attributes?: { [key: string]: string }; } @@ -15,11 +11,7 @@ type HtmlOptions = BaseOptions & { containerElement?: keyof HTMLElementTagNameMap; }; -type SvgOptions = BaseOptions & { - containerElement?: keyof SVGElementTagNameMap; -}; - -type Options = HtmlOptions | SvgOptions; +type Options = HtmlOptions; type Component

= React.Component

| React.ComponentType

; @@ -40,35 +32,11 @@ interface PortalNodeBase> { } export interface HtmlPortalNode = Component> extends PortalNodeBase { element: HTMLElement; - elementType: typeof ELEMENT_TYPE_HTML; -} -export interface SvgPortalNode = Component> extends PortalNodeBase { - element: SVGElement; - elementType: typeof ELEMENT_TYPE_SVG; } -type AnyPortalNode = Component> = HtmlPortalNode | SvgPortalNode; - -const validateElementType = (domElement: Element, elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG) => { - const ownerDocument = (domElement.ownerDocument ?? document) as any; - // Cast document to `any` because Typescript doesn't know about the legacy `Document.parentWindow` field, and also - // doesn't believe `Window.HTMLElement`/`Window.SVGElement` can be used in instanceof tests. - const ownerWindow = ownerDocument.defaultView ?? ownerDocument.parentWindow ?? window; // `parentWindow` for IE8 and earlier - - switch (elementType) { - case ELEMENT_TYPE_HTML: - return domElement instanceof ownerWindow.HTMLElement; - case ELEMENT_TYPE_SVG: - return domElement instanceof ownerWindow.SVGElement; - default: - throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`); - } -}; +type AnyPortalNode = Component> = HtmlPortalNode; // This is the internal implementation: the public entry points set elementType to an appropriate value -const createPortalNode = >( - elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG, - options?: Options -): AnyPortalNode => { +const createPortalNode = >(options?: Options): AnyPortalNode => { let initialProps = {} as ComponentProps; let parent: Node | undefined; @@ -84,7 +52,6 @@ const createPortalNode = >( const portalNode: AnyPortalNode = { element, - elementType, setPortalProps: (props: ComponentProps) => { initialProps = props; }, @@ -98,16 +65,6 @@ const createPortalNode = >( } portalNode.unmount(); - // To support SVG and other non-html elements, the portalNode's elementType needs to match - // the elementType it's being rendered into - if (newParent !== parent) { - if (!validateElementType(newParent, elementType)) { - throw new Error( - `Invalid element type for portal: "${elementType}" portalNodes must be used with ${elementType} elements, but OutPortal is within <${newParent.tagName}>.` - ); - } - } - newParent.replaceChild(portalNode.element, newPlaceholder); parent = newParent; @@ -146,6 +103,9 @@ class InPortal extends React.PureComponent } addPropsChannel = () => { + if (!this.props.node) { + return; + } Object.assign(this.props.node, { setPortalProps: (props: object) => { // Rerender the child node here if/when the out portal props change @@ -220,7 +180,6 @@ class OutPortal> extends React.PureComponent); this.currentPortalNode = node; } @@ -236,7 +195,6 @@ class OutPortal> extends React.PureComponent); } render() { @@ -247,21 +205,12 @@ class OutPortal> extends React.PureComponent = Component, ->( - options?: HtmlOptions -) => HtmlPortalNode; +const createHtmlPortalNode = createPortalNode; export { createHtmlPortalNode, InPortal, OutPortal }; From 5b4e5a48f5866171b09c99fc152f24b370050bde Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 25 Apr 2025 11:56:36 +0200 Subject: [PATCH 19/24] fix: Integ tests --- .../widget-contract-split-panel.test.tsx.snap | 16 +++++++------- .../visual-refresh-toolbar/use-app-layout.tsx | 21 ++++++++++++++++--- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap index 91e374ba44..2e5243ce16 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap @@ -224,7 +224,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], @@ -523,7 +523,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], @@ -822,7 +822,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], @@ -1142,7 +1142,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], @@ -1484,7 +1484,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], @@ -1801,7 +1801,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], @@ -2118,7 +2118,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], @@ -2456,7 +2456,7 @@ Map { "registered": true, "resolvedNavigation": "__JSX__", "resolvedNavigationOpen": true, - "rootRef": "__JSX__", + "rootRef": [Function], "splitPanelInternals": { "bottomOffset": 0, "getMaxHeight": [Function], diff --git a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx index 7f860a91ce..42862792a3 100644 --- a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx +++ b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx @@ -1,6 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { ForwardedRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; +import React, { + ForwardedRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; @@ -8,6 +16,7 @@ import { SplitPanelSideToggleProps } from '../../internal/context/split-panel-co import { fireNonCancelableEvent } from '../../internal/events'; import { useControllable } from '../../internal/hooks/use-controllable'; import { useIntersectionObserver } from '../../internal/hooks/use-intersection-observer'; +import { useMergeRefs } from '../../internal/hooks/use-merge-refs'; import { useMobile } from '../../internal/hooks/use-mobile'; import { useUniqueId } from '../../internal/hooks/use-unique-id'; import { useGetGlobalBreadcrumbs } from '../../internal/plugins/helpers/use-global-breadcrumbs'; @@ -64,7 +73,13 @@ export const useAppLayout = (props: AppLayoutInternalProps, forwardRef: Forwarde const [navigationAnimationDisabled, setNavigationAnimationDisabled] = useState(true); const [splitPanelAnimationDisabled, setSplitPanelAnimationDisabled] = useState(true); const [isNested, setIsNested] = useState(false); - const rootRef = useRef(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 refDefinitionCallback = useCallback(node => { + setIsNested(getIsNestedInAppLayout(node)); + }, []); + const rootRef = useMergeRefs(rootRefInternal, refDefinitionCallback); const [toolsOpen = false, setToolsOpen] = useControllable(controlledToolsOpen, onToolsChange, false, { componentName: 'AppLayout', @@ -438,7 +453,7 @@ export const useAppLayout = (props: AppLayoutInternalProps, forwardRef: Forwarde useLayoutEffect(() => { if (!hasToolbar) { - setIsNested(getIsNestedInAppLayout(rootRef.current)); + // setIsNested(getIsNestedInAppLayout(rootRef.current)); } }, [hasToolbar]); From c92ba3ccf5efeb3179bec4978aa62f3cb5721815 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 25 Apr 2025 12:15:03 +0200 Subject: [PATCH 20/24] chore: Skip hash for class names in widget contract tests --- .../widget-contract-split-panel.test.tsx.snap | 80 +++++++++---------- .../widget-contract-split-panel.test.tsx | 24 ++++++ 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap index 2e5243ce16..ae9a6fefe7 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap @@ -308,13 +308,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -323,10 +323,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", @@ -607,13 +607,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -622,10 +622,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", @@ -906,13 +906,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -921,10 +921,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", @@ -1226,13 +1226,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -1241,10 +1241,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", @@ -1584,13 +1584,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -1599,10 +1599,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", @@ -1901,13 +1901,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -1916,10 +1916,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", @@ -2218,13 +2218,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -2233,10 +2233,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", @@ -2556,13 +2556,13 @@ Map { }, "skeletonSlotsAttributes": { "contentElAttributes": { - "className": "awsui_content_7nfqu_uf4vt_340 awsui_content_1fj9k_z5zo8_9", + "className": "awsui_content awsui_content", }, "contentHeaderElAttributes": { - "className": "awsui_content-header_7nfqu_uf4vt_340", + "className": "awsui_content-header", }, "contentWrapperElAttributes": { - "className": "awsui_main_7nfqu_uf4vt_321", + "className": "awsui_main", "style": { "--awsui-sticky-vertical-bottom-offset": "0px", "--awsui-sticky-vertical-top-offset": "0px", @@ -2571,10 +2571,10 @@ Map { }, }, "mainElAttributes": { - "className": "awsui_main-landmark_7nfqu_uf4vt_321", + "className": "awsui_main-landmark", }, "wrapperElAttributes": { - "className": "awsui_root_7nfqu_uf4vt_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_uf4vt_197", + "className": "awsui_root awsui_root awsui_has-adaptive-widths-default", "ref": [Function], "style": { "--awsui-max-content-width-g964ok": "", diff --git a/src/app-layout/__tests__/widget-contract-split-panel.test.tsx b/src/app-layout/__tests__/widget-contract-split-panel.test.tsx index ccbfe08b8e..a563e56430 100644 --- a/src/app-layout/__tests__/widget-contract-split-panel.test.tsx +++ b/src/app-layout/__tests__/widget-contract-split-panel.test.tsx @@ -11,6 +11,20 @@ import createWrapper from '../../../lib/components/test-utils/selectors'; const isObject = (value: any) => Object.prototype.toString.call(value) === '[object Object]'; +// this string +// awsui_root_7nfqu_jksfw_153 awsui_root_1fj9k_z5zo8_5 awsui_has-adaptive-widths-default_7nfqu_jksfw_197 +// becomes +// awsui_root awsui_root awsui_has-adaptive-widths-default +function skipHashInClassnames(classNames: string): string { + return ( + classNames + .split(' ') + // For each classname, take everything before the underscore followed by alphanumeric characters at the end + .map(className => className.replace(/_[a-z0-9]+_[a-z0-9]+_\d+$/i, '')) + .join(' ') + ); +} + function sanitizeProps(props: any): any { if (!isObject(props)) { return props; @@ -18,6 +32,16 @@ function sanitizeProps(props: any): any { if (React.isValidElement(props) || props?.current instanceof Element) { return '__JSX__'; } + // Now that classNames and styles are provided by the widget API, + // they need to be included in the check. + // However, they're regenerated on every build, and the hash part makes the snapshot test flaky. + // To avoid this, we strip out the hash portion. + if (props.className) { + return { + ...props, + className: skipHashInClassnames(props.className), + }; + } return Object.fromEntries( Object.entries(props).map(([key, value]) => { return [key, sanitizeProps(value)]; From 887e74fc9114e51352a29177d1f721b68bc1cc7b Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 25 Apr 2025 17:18:28 +0200 Subject: [PATCH 21/24] fix: Tests --- .../widget-contract-split-panel.test.tsx.snap | 28 ++++++++----------- .../widget-contract.test.tsx.snap | 6 ++-- .../__tests__/app-layout.ssr.test.tsx | 4 +-- .../app-layout-state.tsx | 20 ++++++------- .../visual-refresh-toolbar/index.tsx | 12 ++------ .../visual-refresh-toolbar/skeleton/index.tsx | 6 +--- 6 files changed, 31 insertions(+), 45 deletions(-) diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap index ae9a6fefe7..63db2163b7 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract-split-panel.test.tsx.snap @@ -6,7 +6,6 @@ Map { "children": [Function], "forwardRef": null, "node": undefined, - "onMount": [Function], "props": { "ariaLabels": { "drawers": undefined, @@ -36,7 +35,6 @@ Map { "splitPanel": "__JSX__", "toolsWidth": 290, }, - "stateMounted": true, }, [Function] => { "appLayoutProps": { @@ -170,7 +168,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel70", + "splitPanelControlId": "split-panel67", "splitPanelFocusControl": { "refs": { "preferences": { @@ -469,7 +467,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel70", + "splitPanelControlId": "split-panel67", "splitPanelFocusControl": { "refs": { "preferences": { @@ -768,7 +766,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel70", + "splitPanelControlId": "split-panel67", "splitPanelFocusControl": { "refs": { "preferences": { @@ -1088,7 +1086,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel70", + "splitPanelControlId": "split-panel67", "splitPanelFocusControl": { "refs": { "preferences": { @@ -1264,7 +1262,6 @@ Map { "children": [Function], "forwardRef": null, "node": undefined, - "onMount": [Function], "props": { "ariaLabels": { "drawers": undefined, @@ -1294,7 +1291,6 @@ Map { "splitPanel": "__JSX__", "toolsWidth": 290, }, - "stateMounted": true, }, [Function] => { "appLayoutProps": { @@ -1428,7 +1424,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel31", + "splitPanelControlId": "split-panel29", "splitPanelFocusControl": { "refs": { "preferences": { @@ -1570,7 +1566,7 @@ Map { "splitPanelToggleProps": { "active": false, "ariaLabel": undefined, - "controlId": "split-panel31", + "controlId": "split-panel29", "displayed": true, "position": "bottom", }, @@ -1745,7 +1741,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel31", + "splitPanelControlId": "split-panel29", "splitPanelFocusControl": { "refs": { "preferences": { @@ -1887,7 +1883,7 @@ Map { "splitPanelToggleProps": { "active": false, "ariaLabel": undefined, - "controlId": "split-panel31", + "controlId": "split-panel29", "displayed": true, "position": "bottom", }, @@ -2062,7 +2058,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel31", + "splitPanelControlId": "split-panel29", "splitPanelFocusControl": { "refs": { "preferences": { @@ -2204,7 +2200,7 @@ Map { "splitPanelToggleProps": { "active": false, "ariaLabel": undefined, - "controlId": "split-panel31", + "controlId": "split-panel29", "displayed": true, "position": "bottom", }, @@ -2400,7 +2396,7 @@ Map { "setToolbarHeight": [Function], "setToolbarState": [Function], "splitPanelAnimationDisabled": true, - "splitPanelControlId": "split-panel31", + "splitPanelControlId": "split-panel29", "splitPanelFocusControl": { "refs": { "preferences": { @@ -2542,7 +2538,7 @@ Map { "splitPanelToggleProps": { "active": false, "ariaLabel": undefined, - "controlId": "split-panel31", + "controlId": "split-panel29", "displayed": true, "position": "bottom", }, diff --git a/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap b/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap index cc8dbfd8dd..14ca987185 100644 --- a/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap +++ b/src/app-layout/__tests__/__snapshots__/widget-contract.test.tsx.snap @@ -5,7 +5,7 @@ Map { [Function] => { "children": [Function], "forwardRef": null, - "onMount": [Function], + "node": undefined, "props": { "ariaLabels": { "drawers": undefined, @@ -43,7 +43,7 @@ Map { [Function] => { "children": [Function], "forwardRef": null, - "onMount": [Function], + "node": undefined, "props": { "ariaLabels": { "drawers": undefined, @@ -99,7 +99,7 @@ Map { [Function] => { "children": [Function], "forwardRef": null, - "onMount": [Function], + "node": undefined, "props": { "activeDrawerId": "security", "ariaLabels": { diff --git a/src/app-layout/__tests__/app-layout.ssr.test.tsx b/src/app-layout/__tests__/app-layout.ssr.test.tsx index 1d7ffbfd95..3a00aac541 100644 --- a/src/app-layout/__tests__/app-layout.ssr.test.tsx +++ b/src/app-layout/__tests__/app-layout.ssr.test.tsx @@ -35,11 +35,11 @@ test('should render refresh-toolbar app layout with the widget flag', () => { globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; globalWithFlags[Symbol.for('awsui-global-flags')] = { appLayoutWidget: true }; const content = renderToStaticMarkup(); - expect(content.includes('data-testid="app-layout-toolbar-root"')).toBe(true); + expect(content).toBe('

'); }); test('should render refresh-toolbar app layout with the toolbar flag', () => { globalWithFlags[Symbol.for('awsui-visual-refresh-flag')] = () => true; globalWithFlags[Symbol.for('awsui-global-flags')] = { appLayoutToolbar: true }; const content = renderToStaticMarkup(); - expect(content.includes('data-testid="app-layout-toolbar-root"')).toBe(true); + expect(content).toBe('
'); }); diff --git a/src/app-layout/visual-refresh-toolbar/app-layout-state.tsx b/src/app-layout/visual-refresh-toolbar/app-layout-state.tsx index 0c110f8d8d..0ec276e5b8 100644 --- a/src/app-layout/visual-refresh-toolbar/app-layout-state.tsx +++ b/src/app-layout/visual-refresh-toolbar/app-layout-state.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { ForwardedRef, useEffect } from 'react'; +import React, { ForwardedRef } from 'react'; import { createWidgetizedComponent } from '../../internal/widgets'; import { AppLayoutProps } from '../interfaces'; @@ -8,24 +8,24 @@ import { AppLayoutInternalProps } from './interfaces'; import { useSkeletonSlotsAttributes } from './skeleton/widget-slots/use-skeleton-slots-attributes'; import { useAppLayout } from './use-app-layout'; -export const AppLayoutState = (props: { +export interface AppLayoutStateProps { props: AppLayoutInternalProps; forwardRef: ForwardedRef; children: ( state: ReturnType, skeletonSlotsAttributes: ReturnType ) => React.ReactNode; - onMount: () => void; -}) => { +} + +export const AppLayoutState = (props: AppLayoutStateProps) => { const state = useAppLayout(props.props, props.forwardRef); const skeletonSlotsAttributes = useSkeletonSlotsAttributes({ appLayoutProps: props.props, appLayoutState: state }); - useEffect(() => { - props.onMount(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return <>{props.children(state, skeletonSlotsAttributes)}; }; -export const createWidgetizedAppLayoutState = createWidgetizedComponent(AppLayoutState); +export const AppLayoutStateSkeleton = React.forwardRef((props, ref) => { + return
}>{props.children({} as any, {} as any)}
; +}); + +export const createWidgetizedAppLayoutState = createWidgetizedComponent(AppLayoutState, AppLayoutStateSkeleton); diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index 411dc158ce..fc3ab9714b 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useState } from 'react'; +import React from 'react'; import ScreenreaderOnly from '../../internal/components/screenreader-only'; import { AppLayoutProps } from '../interfaces'; @@ -18,28 +18,22 @@ const AppLayoutStateParent = (props: { skeletonSlotsAttributes: ReturnType ) => React.ReactNode; node: HtmlPortalNode; - stateMounted: boolean; }) => { - if (!props.stateMounted) { - return <>{props.children({} as any, {} as any)}; - } - return ; }; const AppLayoutVisualRefreshToolbar = React.forwardRef( (props, forwardRef) => { - const [stateMounted, setStateMounted] = useState(false); const portalNode = React.useMemo(() => (typeof window !== 'undefined' ? createHtmlPortalNode() : null), []); return ( <> - setStateMounted(true)}> + {() => <>} - + {(appLayoutState, skeletonSlotsAttributes) => { return ( diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index 07832e34fe..dad863b50f 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -40,11 +40,7 @@ export const SkeletonLayout = (props: RootSkeletonLayoutProps) => { return ( -
+
{!isAppLayoutStateLoading && }
{!isAppLayoutStateLoading && } From 592817e4171f65a74587e2c0a85057c0040545bc Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 25 Apr 2025 18:03:22 +0200 Subject: [PATCH 22/24] chore: Wrap up --- src/app-layout/__tests__/skeleton.test.tsx | 7 ------- .../widget-contract-split-panel.test.tsx | 18 ++++-------------- .../__tests__/widget-contract.test.tsx | 7 ------- .../visual-refresh-toolbar/reverse-portal.tsx | 12 ++++++++++++ .../visual-refresh-toolbar/use-app-layout.tsx | 4 ++-- 5 files changed, 18 insertions(+), 30 deletions(-) diff --git a/src/app-layout/__tests__/skeleton.test.tsx b/src/app-layout/__tests__/skeleton.test.tsx index b4e01908c2..79764f9e4f 100644 --- a/src/app-layout/__tests__/skeleton.test.tsx +++ b/src/app-layout/__tests__/skeleton.test.tsx @@ -23,15 +23,8 @@ function createWidgetizedComponentMock(Implementation: React.ComponentType, Skel }; } -function createWidgetizedFunctionMock(fn: (args: any[]) => any) { - return () => { - return (...args: any[]) => fn(args); - }; -} - jest.mock('../../../lib/components/internal/widgets', () => ({ createWidgetizedComponent: createWidgetizedComponentMock, - createWidgetizedFunction: createWidgetizedFunctionMock, })); describeEachAppLayout({ themes: ['refresh-toolbar'], skipInitialTest: true }, () => { diff --git a/src/app-layout/__tests__/widget-contract-split-panel.test.tsx b/src/app-layout/__tests__/widget-contract-split-panel.test.tsx index a563e56430..3e1376776d 100644 --- a/src/app-layout/__tests__/widget-contract-split-panel.test.tsx +++ b/src/app-layout/__tests__/widget-contract-split-panel.test.tsx @@ -16,13 +16,10 @@ const isObject = (value: any) => Object.prototype.toString.call(value) === '[obj // becomes // awsui_root awsui_root awsui_has-adaptive-widths-default function skipHashInClassnames(classNames: string): string { - return ( - classNames - .split(' ') - // For each classname, take everything before the underscore followed by alphanumeric characters at the end - .map(className => className.replace(/_[a-z0-9]+_[a-z0-9]+_\d+$/i, '')) - .join(' ') - ); + return classNames + .split(' ') + .map(className => className.replace(/_[a-z0-9]+_[a-z0-9]+_\d+$/i, '')) + .join(' '); } function sanitizeProps(props: any): any { @@ -59,15 +56,8 @@ function createWidgetizedComponentMock(Implementation: React.ComponentType) { }; } -function createWidgetizedFunctionMock(fn: (args: any[]) => any) { - return () => { - return (...args: any[]) => fn(args); - }; -} - jest.mock('../../../lib/components/internal/widgets', () => ({ createWidgetizedComponent: createWidgetizedComponentMock, - createWidgetizedFunction: createWidgetizedFunctionMock, })); jest.mock('../../../lib/components/internal/hooks/use-unique-id', () => { let counter = 0; diff --git a/src/app-layout/__tests__/widget-contract.test.tsx b/src/app-layout/__tests__/widget-contract.test.tsx index a7ec790deb..896112b0d5 100644 --- a/src/app-layout/__tests__/widget-contract.test.tsx +++ b/src/app-layout/__tests__/widget-contract.test.tsx @@ -16,15 +16,8 @@ function createWidgetizedComponentMock(Implementation: React.ComponentType) { }; } -function createWidgetizedFunctionMock(fn: (args: any[]) => any) { - return () => { - return (...args: any[]) => fn(args); - }; -} - jest.mock('../../../lib/components/internal/widgets', () => ({ createWidgetizedComponent: createWidgetizedComponentMock, - createWidgetizedFunction: createWidgetizedFunctionMock, })); jest.mock('../../../lib/components/internal/hooks/use-unique-id', () => { let counter = 0; diff --git a/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx b/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx index 91ba6e7f98..8e997c83a2 100644 --- a/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx +++ b/src/app-layout/visual-refresh-toolbar/reverse-portal.tsx @@ -1,5 +1,17 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + +/** + * This file contains code copied from the npm package: https://www.npmjs.com/package/react-reverse-portal + * + * We don’t use the original package directly for the following reasons: + * + * - The original library throws errors during SSR, whereas this version returns `null` and degrades gracefully. + * - The original package has no test coverage, which makes it unreliable. This version is fully covered by unit tests. + * + * The implementation remains unchanged and uses class components, along with the original comments. + */ + import * as React from 'react'; import * as ReactDOM from 'react-dom'; diff --git a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx index 42862792a3..2fbe048b5a 100644 --- a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx +++ b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx @@ -76,10 +76,10 @@ export const useAppLayout = (props: AppLayoutInternalProps, forwardRef: Forwarde 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 refDefinitionCallback = useCallback(node => { + const onMountRootRef = useCallback(node => { setIsNested(getIsNestedInAppLayout(node)); }, []); - const rootRef = useMergeRefs(rootRefInternal, refDefinitionCallback); + const rootRef = useMergeRefs(rootRefInternal, onMountRootRef); const [toolsOpen = false, setToolsOpen] = useControllable(controlledToolsOpen, onToolsChange, false, { componentName: 'AppLayout', From 89cd1d5d16da72dd3294b6c4e3025475c5942935 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Tue, 29 Apr 2025 15:31:50 +0200 Subject: [PATCH 23/24] chore: Fallbacks for app layout component when is in loading state (avoiding layout cumulative shifting) --- .../visual-refresh-toolbar/internal.tsx | 40 +++++++++++++++++-- .../visual-refresh-toolbar/skeleton/index.tsx | 27 ++++++++++--- .../skeleton/widget-slots/top-page-slot.tsx | 23 ++++++++++- .../visual-refresh-toolbar/use-app-layout.tsx | 16 +------- 4 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/app-layout/visual-refresh-toolbar/internal.tsx b/src/app-layout/visual-refresh-toolbar/internal.tsx index e90267d13c..530f9e4d61 100644 --- a/src/app-layout/visual-refresh-toolbar/internal.tsx +++ b/src/app-layout/visual-refresh-toolbar/internal.tsx @@ -1,13 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { createWidgetizedAppLayoutState } from './app-layout-state'; +import React, { useEffect, useRef, useState } from 'react'; + +import { AppLayoutState as AppLayoutStateImplementation, createWidgetizedAppLayoutState } from './app-layout-state'; import { createWidgetizedAppLayoutDrawer, createWidgetizedAppLayoutGlobalDrawers } from './drawer'; import { createWidgetizedAppLayoutNavigation } from './navigation'; import { createWidgetizedAppLayoutNotifications } from './notifications'; import { createWidgetizedAppLayoutBottomPageContentSlot } from './skeleton/widget-slots/bottom-page-content-slot'; import { createWidgetizedAppLayoutSidePageSlot } from './skeleton/widget-slots/side-page-slot'; import { createWidgetizedAppLayoutTopPageContentSlot } from './skeleton/widget-slots/top-page-content-slot'; -import { createWidgetizedAppLayoutTopPageSlot } from './skeleton/widget-slots/top-page-slot'; +import { createWidgetizedAppLayoutTopPageSlot, TopPageSlot } from './skeleton/widget-slots/top-page-slot'; import { createWidgetizedAppLayoutSplitPanelDrawerBottom, createWidgetizedAppLayoutSplitPanelDrawerSide, @@ -21,8 +23,38 @@ export const AppLayoutNotifications = createWidgetizedAppLayoutNotifications(); export const AppLayoutToolbar = createWidgetizedAppLayoutToolbar(); export const AppLayoutSplitPanelBottom = createWidgetizedAppLayoutSplitPanelDrawerBottom(); export const AppLayoutSplitPanelSide = createWidgetizedAppLayoutSplitPanelDrawerSide(); -export const AppLayoutSkeletonTopSlot = createWidgetizedAppLayoutTopPageSlot(); +export const AppLayoutSkeletonTopSlot = createWidgetizedAppLayoutTopPageSlot( + createAppLayoutPart({ Component: TopPageSlot }) +); export const AppLayoutSkeletonSideSlot = createWidgetizedAppLayoutSidePageSlot(); export const AppLayoutSkeletonTopContentSlot = createWidgetizedAppLayoutTopPageContentSlot(); export const AppLayoutSkeletonBottomContentSlot = createWidgetizedAppLayoutBottomPageContentSlot(); -export const AppLayoutState = createWidgetizedAppLayoutState(); +export const AppLayoutState = createWidgetizedAppLayoutState( + createAppLayoutPart({ Component: AppLayoutStateImplementation }) +); + +const enableDelayedComponents = false; +const enableSyncComponents = true; + +export function createAppLayoutPart({ Component }: { Component: React.JSXElementConstructor }) { + const AppLayoutPartLoader = ({ Skeleton, ...props }: any) => { + const [mount, setMount] = useState(false); + const ref = useRef(null); + + useEffect(() => { + setTimeout(() => { + setMount(true); + }, 1000); + }, []); + + if (enableSyncComponents || (mount && enableDelayedComponents)) { + return ; + } + + if (Skeleton) { + return ; + } + return
; + }; + return AppLayoutPartLoader; +} diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index dad863b50f..e686aa5546 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -1,8 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; +import clsx from 'clsx'; import VisualContext from '../../../internal/components/visual-context'; +import customCssProps from '../../../internal/generated/custom-css-properties'; import { AppLayoutInternalProps } from '../interfaces'; import { AppLayoutSkeletonBottomContentSlot, @@ -14,6 +16,7 @@ import { useAppLayout } from '../use-app-layout'; import { useSkeletonSlotsAttributes } from './widget-slots/use-skeleton-slots-attributes'; import testutilStyles from '../../test-classes/styles.css.js'; +import styles from './styles.css.js'; export interface SkeletonLayoutProps { appLayoutProps: AppLayoutInternalProps; @@ -27,7 +30,7 @@ export interface RootSkeletonLayoutProps extends SkeletonLayoutProps { export const SkeletonLayout = (props: RootSkeletonLayoutProps) => { const { appLayoutProps, appLayoutState, skeletonSlotsAttributes } = props; const { registered } = appLayoutState; - const { contentHeader, content } = appLayoutProps; + const { contentHeader, content, navigationWidth } = appLayoutProps; const { wrapperElAttributes, mainElAttributes, @@ -40,11 +43,25 @@ export const SkeletonLayout = (props: RootSkeletonLayoutProps) => { return ( -
- {!isAppLayoutStateLoading && } -
+
+ +
{!isAppLayoutStateLoading && } -
+
{contentHeader &&
{contentHeader}
} {/*delay rendering the content until registration of this instance is complete*/}
diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx index 289902d4c5..7872dcd6dd 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/widget-slots/top-page-slot.tsx @@ -4,14 +4,16 @@ import React from 'react'; import clsx from 'clsx'; import { createWidgetizedComponent } from '../../../../internal/widgets'; +import { useMultiAppLayout } from '../../multi-layout'; import { AppLayoutNavigationImplementation as AppLayoutNavigation } from '../../navigation'; import { AppLayoutToolbarImplementation as AppLayoutToolbar } from '../../toolbar'; import { SkeletonLayoutProps } from '../index'; +import { ToolbarSkeleton } from '../slot-skeletons'; import sharedStyles from '../../../resize/styles.css.js'; import styles from '../styles.css.js'; -const TopPageSlot = (props: SkeletonLayoutProps) => { +export const TopPageSlot = (props: SkeletonLayoutProps) => { const { appLayoutState: { resolvedNavigationOpen, @@ -43,4 +45,21 @@ const TopPageSlot = (props: SkeletonLayoutProps) => { ); }; -export const createWidgetizedAppLayoutTopPageSlot = createWidgetizedComponent(TopPageSlot); +export const TopPageSlotSkeleton = React.forwardRef((props, ref) => { + const { appLayoutProps } = props; + const { __embeddedViewMode: embeddedViewMode } = appLayoutProps as any; + const { toolbarProps } = useMultiAppLayout(appLayoutProps as any, true); + const hasToolbar = !embeddedViewMode && !!toolbarProps; + const resolvedNavigation = appLayoutProps?.navigationHide ? null : appLayoutProps?.navigation || <>; + const resolvedNavigationOpen = !!resolvedNavigation && appLayoutProps?.navigationOpen; + return ( + <> + {hasToolbar && ( + } toolbarProps={{} as any} appLayoutInternals={{} as any} /> + )} + {resolvedNavigationOpen &&
} + + ); +}); + +export const createWidgetizedAppLayoutTopPageSlot = createWidgetizedComponent(TopPageSlot, TopPageSlotSkeleton); diff --git a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx index 2fbe048b5a..f948370a0f 100644 --- a/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx +++ b/src/app-layout/visual-refresh-toolbar/use-app-layout.tsx @@ -1,14 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { - ForwardedRef, - useCallback, - useEffect, - useImperativeHandle, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import React, { ForwardedRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; @@ -451,12 +443,6 @@ export const useAppLayout = (props: AppLayoutInternalProps, forwardRef: Forwarde return false; }; - useLayoutEffect(() => { - if (!hasToolbar) { - // setIsNested(getIsNestedInAppLayout(rootRef.current)); - } - }, [hasToolbar]); - const splitPanelOffsets = computeSplitPanelOffsets({ placement, hasSplitPanel: !!splitPanel, From 19ddb8be5d409d59be5701b67a4f1ed2861b5f65 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Fri, 2 May 2025 17:19:07 +0200 Subject: [PATCH 24/24] chore: Delay breadcrumbs rendering until app layout state is loaded --- src/app-layout/__tests__/skeleton.test.tsx | 4 ---- src/app-layout/visual-refresh-toolbar/index.tsx | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app-layout/__tests__/skeleton.test.tsx b/src/app-layout/__tests__/skeleton.test.tsx index 79764f9e4f..c24d51f99a 100644 --- a/src/app-layout/__tests__/skeleton.test.tsx +++ b/src/app-layout/__tests__/skeleton.test.tsx @@ -4,8 +4,6 @@ import React from 'react'; import AppLayout from '../../../lib/components/app-layout'; import BreadcrumbGroup from '../../../lib/components/breadcrumb-group'; -import createWrapper from '../../../lib/components/test-utils/dom'; -import { getFunnelKeySelector } from '../../internal/analytics/selectors'; import { describeEachAppLayout, renderComponent } from './utils'; let widgetMockEnabled = false; @@ -40,7 +38,6 @@ describeEachAppLayout({ themes: ['refresh-toolbar'], skipInitialTest: true }, () expect(wrapper.findToolbar()).toBeTruthy(); expect(wrapper.findNavigation()).toBeTruthy(); expect(wrapper.findBreadcrumbs()).toBeTruthy(); - expect(wrapper.find(getFunnelKeySelector('funnel-name'))).toBeTruthy(); expect(wrapper.findNotifications()).toBeTruthy(); expect(wrapper.findTools()).toBeTruthy(); expect(wrapper.findContentRegion()).toBeTruthy(); @@ -66,7 +63,6 @@ describeEachAppLayout({ themes: ['refresh-toolbar'], skipInitialTest: true }, () expect(wrapper.findToolbar()).toBeFalsy(); expect(wrapper.findNavigation()).toBeFalsy(); expect(wrapper.findBreadcrumbs()).toBeFalsy(); - expect(createWrapper().find(getFunnelKeySelector('funnel-name'))).toBeTruthy(); expect(wrapper.findNotifications()).toBeFalsy(); expect(wrapper.findTools()).toBeFalsy(); expect(wrapper.findContentRegion()).toBeTruthy(); diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index fc3ab9714b..290d3564a9 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -38,7 +38,7 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef {/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */} - {!appLayoutState?.hasToolbar && props.breadcrumbs ? ( + {Object.keys(appLayoutState).length > 0 && !appLayoutState?.hasToolbar && props.breadcrumbs ? ( {props.breadcrumbs} ) : null}