diff --git a/build-tools/utils/custom-css-properties.js b/build-tools/utils/custom-css-properties.js index b212ce560f..275b186605 100644 --- a/build-tools/utils/custom-css-properties.js +++ b/build-tools/utils/custom-css-properties.js @@ -39,6 +39,7 @@ const customCssPropertiesList = [ 'toolsMaxWidth', 'toolsWidth', 'toolsAnimationStartingOpacity', + 'activeGlobalBottomDrawerHeight', // Annotation Context Custom Properties 'contentScrollMargin', // Flashbar Custom Properties diff --git a/pages/app-layout/utils/external-widget.tsx b/pages/app-layout/utils/external-widget.tsx index 1fd2d3fa2b..894962f1c6 100644 --- a/pages/app-layout/utils/external-widget.tsx +++ b/pages/app-layout/utils/external-widget.tsx @@ -135,6 +135,9 @@ awsuiPlugins.appLayout.registerDrawer({ isExpandable: true, + movable: true, + position: 'bottom', + ariaLabels: { closeButton: 'Close button', content: 'Content', diff --git a/src/app-layout/runtime-drawer/index.tsx b/src/app-layout/runtime-drawer/index.tsx index 14004148a6..e1b3b14604 100644 --- a/src/app-layout/runtime-drawer/index.tsx +++ b/src/app-layout/runtime-drawer/index.tsx @@ -5,6 +5,8 @@ import React, { useContext, useEffect, useRef } from 'react'; import { fireNonCancelableEvent, NonCancelableEventHandler } from '../../internal/events'; import { DrawerConfig as RuntimeDrawerConfig, + DrawerPosition, + DrawerPositionChangeParams, DrawerStateChangeParams, } from '../../internal/plugins/controllers/drawers'; import { sortByPriority } from '../../internal/plugins/helpers/utils'; @@ -15,6 +17,9 @@ import styles from './styles.css.js'; export interface RuntimeDrawer extends AppLayoutProps.Drawer { onToggle?: NonCancelableEventHandler; + movable?: boolean; + position?: DrawerPosition; + onPositionChange?: NonCancelableEventHandler; } export interface DrawersLayout { diff --git a/src/app-layout/utils/use-drawers.ts b/src/app-layout/utils/use-drawers.ts index d5201cf6a8..4cac7c72fe 100644 --- a/src/app-layout/utils/use-drawers.ts +++ b/src/app-layout/utils/use-drawers.ts @@ -50,7 +50,7 @@ function getToolsDrawerItem(props: ToolsProps): AppLayoutProps.Drawer | null { }; } -const DRAWERS_LIMIT = 2; +const DRAWERS_LIMIT = 3; const DEFAULT_ON_CHANGE_PARAMS = { initiatedByUserAction: true }; diff --git a/src/app-layout/visual-refresh-toolbar/compute-layout.ts b/src/app-layout/visual-refresh-toolbar/compute-layout.ts index 387bcf3f6e..91bffc6653 100644 --- a/src/app-layout/visual-refresh-toolbar/compute-layout.ts +++ b/src/app-layout/visual-refresh-toolbar/compute-layout.ts @@ -81,6 +81,7 @@ interface VerticalLayoutInput { toolbarHeight: number; stickyNotifications: boolean; notificationsHeight: number; + activeBottomDrawerHeight: number; } export interface VerticalLayoutOutput { @@ -88,6 +89,7 @@ export interface VerticalLayoutOutput { notifications: number; header: number; drawers: number; + bottomDrawer: number; } export function computeVerticalLayout({ @@ -96,6 +98,7 @@ export function computeVerticalLayout({ toolbarHeight, stickyNotifications, notificationsHeight, + activeBottomDrawerHeight, }: VerticalLayoutInput): VerticalLayoutOutput { const toolbar = topOffset; let notifications = topOffset; @@ -110,7 +113,7 @@ export function computeVerticalLayout({ header += notificationsHeight; } - return { toolbar, notifications, header, drawers }; + return { toolbar, notifications, header, drawers, bottomDrawer: activeBottomDrawerHeight }; } interface SplitPanelOffsetInput { @@ -150,8 +153,10 @@ export function getDrawerStyles( ): { drawerTopOffset: number; drawerHeight: string; + globalDrawerHeight: string; } { const drawerTopOffset = isMobile ? verticalOffsets.toolbar : (verticalOffsets.drawers ?? placement.insetBlockStart); - const drawerHeight = `calc(100vh - ${drawerTopOffset}px - ${placement.insetBlockEnd}px)`; - return { drawerTopOffset, drawerHeight }; + const drawerHeight = `calc(100vh - ${drawerTopOffset}px - ${placement.insetBlockEnd}px - ${verticalOffsets.bottomDrawer}px)`; + const globalDrawerHeight = `calc(100vh - ${drawerTopOffset}px - ${placement.insetBlockEnd}px)`; + return { drawerTopOffset, drawerHeight, globalDrawerHeight }; } diff --git a/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx b/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx index 10c29fc66d..2b0bad04d8 100644 --- a/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx +++ b/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx @@ -52,10 +52,20 @@ function AppLayoutGlobalDrawerImplementation({ content: activeGlobalDrawer ? activeGlobalDrawer.ariaLabels?.drawerName : ariaLabels?.tools, }; - const { drawerTopOffset, drawerHeight } = getDrawerStyles(verticalOffsets, isMobile, placement); + const getMaxHeight = () => { + const availableHeight = document.documentElement.clientHeight - placement.insetBlockStart - placement.insetBlockEnd; + // If the page is likely zoomed in at 200%, allow the bottom panel to fill the content area + return availableHeight < 400 ? availableHeight - 40 : availableHeight - 250; + }; + const MIN_HEIGHT = 160; + + const position = activeGlobalDrawer?.position ?? 'side'; + const { drawerTopOffset, globalDrawerHeight } = getDrawerStyles(verticalOffsets, isMobile, placement); const activeDrawerSize = (activeDrawerId ? activeGlobalDrawersSizes[activeDrawerId] : 0) ?? 0; - const minDrawerSize = (activeDrawerId ? minGlobalDrawersSizes[activeDrawerId] : 0) ?? 0; - const maxDrawerSize = (activeDrawerId ? maxGlobalDrawersSizes[activeDrawerId] : 0) ?? 0; + const minDrawerSize = + position === 'side' ? ((activeDrawerId ? minGlobalDrawersSizes[activeDrawerId] : 0) ?? 0) : MIN_HEIGHT; + const maxDrawerSize = + position === 'side' ? ((activeDrawerId ? maxGlobalDrawersSizes[activeDrawerId] : 0) ?? 0) : getMaxHeight(); const refs = globalDrawersFocusControl.refs[activeDrawerId]; const resizeProps = useResize({ currentWidth: activeDrawerSize, @@ -64,6 +74,7 @@ function AppLayoutGlobalDrawerImplementation({ panelRef: drawerRef, handleRef: refs?.slider, onResize: size => onActiveDrawerResize({ id: activeDrawerId!, size }), + position, }); const size = getLimitedValue(minDrawerSize, activeDrawerSize, maxDrawerSize); const lastOpenedDrawerId = drawersOpenQueue.length ? drawersOpenQueue[0] : null; @@ -73,6 +84,7 @@ function AppLayoutGlobalDrawerImplementation({ const animationDisabled = (activeGlobalDrawer?.defaultActive && !drawersOpenQueue.includes(activeGlobalDrawer.id)) || (wasExpanded && !isExpanded); + const motionClassName = `with-motion-${position === 'side' ? 'horizontal' : 'vertical'}`; return ( @@ -86,8 +98,9 @@ function AppLayoutGlobalDrawerImplementation({ styles.drawer, styles['drawer-global'], styles[state], - !animationDisabled && sharedStyles['with-motion-horizontal'], + !animationDisabled && sharedStyles[motionClassName], !animationDisabled && isExpanded && styles['with-expanded-motion'], + styles[position], { [styles['drawer-hidden']]: !show, [styles['last-opened']]: lastOpenedDrawerId === activeDrawerId || isExpanded, @@ -114,21 +127,23 @@ function AppLayoutGlobalDrawerImplementation({ } }} style={{ - blockSize: drawerHeight, - insetBlockStart: drawerTopOffset, ...(!isMobile && { [customCssProps.drawerSize]: `${['entering', 'entered'].includes(state) ? (isExpanded ? '100%' : size + 'px') : 0}`, }), + ...(position === 'side' && { + insetBlockStart: drawerTopOffset, + blockSize: globalDrawerHeight, + }), }} data-testid={`awsui-app-layout-drawer-${activeDrawerId}`} >
- {!isMobile &&
} + {!isMobile &&
} {!isMobile && activeGlobalDrawer?.resizable && !isExpanded && (
@@ -174,7 +189,14 @@ function AppLayoutGlobalDrawerImplementation({ />
-
+
{activeGlobalDrawer?.content}
diff --git a/src/app-layout/visual-refresh-toolbar/drawer/styles.scss b/src/app-layout/visual-refresh-toolbar/drawer/styles.scss index 7b6e89d191..fc3de2744e 100644 --- a/src/app-layout/visual-refresh-toolbar/drawer/styles.scss +++ b/src/app-layout/visual-refresh-toolbar/drawer/styles.scss @@ -47,9 +47,13 @@ $drawer-resize-handle-size: awsui.$space-m; } @include desktop-only { - &:not(.legacy) { + &:not(.bottom):not(.legacy) { border-inline-start: awsui.$border-divider-section-width solid awsui.$color-border-layout; } + + &.bottom:not(.legacy) { + border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-layout; + } } @include mobile-only { @@ -171,13 +175,25 @@ $drawer-resize-handle-size: awsui.$space-m; } > .drawer-gap { - grid-column: 1; - grid-row: 1; - block-size: 100%; - inline-size: $global-drawer-gap-size; background: awsui.$color-gap-global-drawer; - border-inline-end: awsui.$border-divider-section-width solid awsui.$color-border-layout; box-sizing: border-box; + + &.side { + grid-column: 1; + grid-row: 1; + block-size: 100%; + inline-size: $global-drawer-gap-size; + border-inline-end: awsui.$border-divider-section-width solid awsui.$color-border-layout; + } + + &.bottom { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + inline-size: 100%; + block-size: $global-drawer-gap-size; + border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-layout; + } } > .drawer-slider { @@ -237,4 +253,37 @@ $drawer-resize-handle-size: awsui.$space-m; } } } + + &.bottom { + @include desktop-only { + position: fixed; + inset-block-end: 0; + inset-inline-start: 0; + inline-size: 100%; + overflow: auto; + block-size: var(#{custom-props.$drawerSize}); + z-index: 840; + + > .global-drawer-wrapper { + display: block; + overflow-y: auto; + position: relative; + min-inline-size: 0; + + > .drawer-slider { + position: absolute; + inset-block-start: $global-drawer-gap-size; + inset-inline-start: 0; + inline-size: 100%; + display: flex; + justify-content: center; + z-index: 2; + } + + > .drawer-content-container { + block-size: var(#{custom-props.$drawerSize}); + } + } + } + } } diff --git a/src/app-layout/visual-refresh-toolbar/drawer/use-resize.ts b/src/app-layout/visual-refresh-toolbar/drawer/use-resize.ts index 04fa41012b..7f382980fd 100644 --- a/src/app-layout/visual-refresh-toolbar/drawer/use-resize.ts +++ b/src/app-layout/visual-refresh-toolbar/drawer/use-resize.ts @@ -14,9 +14,10 @@ interface ResizeProps { panelRef: React.RefObject; handleRef: React.RefObject; onResize: (newWidth: number) => void; + position?: 'side' | 'bottom'; } -export function useResize({ currentWidth, minWidth, maxWidth, panelRef, handleRef, onResize }: ResizeProps) { +export function useResize({ currentWidth, minWidth, maxWidth, panelRef, handleRef, onResize, position }: ResizeProps) { const onResizeHandler = (newWidth: number) => { const size = getLimitedValue(minWidth, newWidth, maxWidth); @@ -26,7 +27,7 @@ export function useResize({ currentWidth, minWidth, maxWidth, panelRef, handleRe }; const sizeControlProps: SizeControlProps = { - position: 'side', + position: position ?? 'side', panelRef, handleRef, onResize: onResizeHandler, diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index b78bfa65b0..0f75672f7c 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -243,6 +243,9 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef navigationFocusControl.setFocus(true), })); + const activeBottomDrawerId = activeGlobalDrawers.find(drawer => drawer.position === 'bottom')?.id; + const activeBottomDrawerHeight = activeBottomDrawerId ? activeGlobalDrawersSizes[activeBottomDrawerId] : 0; + const resolvedStickyNotifications = !!stickyNotifications && !isMobile; //navigation must be null if hidden so toolbar knows to hide the toggle button const resolvedNavigation = navigationHide ? null : navigation || <>; @@ -265,7 +268,15 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef key !== activeBottomDrawerId) + .reduce( + (acc, curr) => ({ + ...acc, + [curr]: activeGlobalDrawersSizes[curr], + }), + {} + ), }); const { ref: intersectionObserverRef, isIntersecting } = useIntersectionObserver({ initialState: true }); @@ -311,6 +322,7 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef { - const drawerToClose = drawersOpenQueue[drawersOpenQueue.length - 1]; + const sideDrawersOpenQueue = drawersOpenQueue.filter( + drawerId => globalDrawers.find(globalDrawer => globalDrawer.id === drawerId)?.position !== 'bottom' + ); + const drawerToClose = sideDrawersOpenQueue[sideDrawersOpenQueue.length - 1]; if (activeDrawer && activeDrawer?.id === drawerToClose) { onActiveDrawerChange(null, { initiatedByUserAction: true }); } else if (activeGlobalDrawersIds.includes(drawerToClose)) { @@ -553,6 +568,8 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef ); diff --git a/src/app-layout/visual-refresh-toolbar/interfaces.ts b/src/app-layout/visual-refresh-toolbar/interfaces.ts index 0de8881fed..48ac453e9a 100644 --- a/src/app-layout/visual-refresh-toolbar/interfaces.ts +++ b/src/app-layout/visual-refresh-toolbar/interfaces.ts @@ -5,6 +5,8 @@ import React from 'react'; import { BreadcrumbGroupProps } from '../../breadcrumb-group/interfaces'; import { SplitPanelSideToggleProps } from '../../internal/context/split-panel-context'; +import { NonCancelableEventHandler } from '../../internal/events'; +import { DrawerPosition, DrawerPositionChangeParams } from '../../internal/plugins/controllers/drawers'; import { AppLayoutProps, AppLayoutPropsWithDefaults } from '../interfaces'; import { OnChangeParams } from '../utils/use-drawers'; import { FocusControlMultipleStates, FocusControlState } from '../utils/use-focus-control'; @@ -19,6 +21,9 @@ export type InternalDrawer = AppLayoutProps.Drawer & { defaultActive?: boolean; isExpandable?: boolean; ariaLabels: AppLayoutProps.Drawer['ariaLabels'] & { expandedModeButton?: string }; + movable?: boolean; + position?: DrawerPosition; + onPositionChange?: NonCancelableEventHandler; }; // Widgetization notice: structures in this file are shared multiple app layout instances, possibly different minor versions. diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx index 921091e46a..9655460d88 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/skeleton/index.tsx @@ -1,8 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useRef } from 'react'; import clsx from 'clsx'; +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { GeneratedAnalyticsMetadataAppLayoutToolbarComponent } from '../../../app-layout-toolbar/analytics-metadata/interfaces'; @@ -47,6 +48,8 @@ interface SkeletonLayoutProps isNested?: boolean; drawerExpandedMode: boolean; drawerExpandedModeInChildLayout: boolean; + activeBottomDrawerSize: number; + activeBottomDrawerId?: string; } const componentAnalyticsMetadata: GeneratedAnalyticsMetadataAppLayoutToolbarComponent = { @@ -85,12 +88,25 @@ export const SkeletonLayout = React.forwardRef { const isMobile = useMobile(); const isMaxWidth = maxContentWidth === Number.MAX_VALUE || maxContentWidth === Number.MAX_SAFE_INTEGER; const anyPanelOpen = navigationOpen || toolsOpen; + const bottomDrawerWrapperRef = useRef(null); + useResizeObserver(bottomDrawerWrapperRef, entry => { + if (activeBottomDrawerId) { + // TODO: turn this into a global css var and apply to the drawer + if (!isMobile) { + document.getElementById(activeBottomDrawerId)!.style.inlineSize = `${entry.contentBoxWidth}px`; + } else { + document.getElementById(activeBottomDrawerId)!.style.inlineSize = '100%'; + } + } + }); return (
@@ -146,7 +163,10 @@ export const SkeletonLayout = React.forwardRef{content}
{bottomSplitPanel && ( -
+
{bottomSplitPanel}
)} @@ -175,6 +195,7 @@ export const SkeletonLayout = React.forwardRef
{globalTools}
+
); diff --git a/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss b/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss index a2997c154c..60d142bf21 100644 --- a/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss +++ b/src/app-layout/visual-refresh-toolbar/skeleton/styles.scss @@ -50,9 +50,11 @@ // desktop grid @include desktop-only { grid-template-areas: - 'toolbar toolbar toolbar toolbar toolbar toolbar toolbar' - 'navigation . notifications . sideSplitPanel tools global-tools' - 'navigation . main . sideSplitPanel tools global-tools'; + 'toolbar toolbar toolbar toolbar toolbar toolbar toolbar' + 'navigation . notifications . sideSplitPanel tools global-tools' + 'navigation . main . sideSplitPanel tools global-tools' + 'global-tools-bottom global-tools-bottom global-tools-bottom global-tools-bottom global-tools-bottom global-tools-bottom global-tools'; + grid-template-columns: min-content minmax(#{awsui.$space-layout-content-horizontal}, 1fr) @@ -60,7 +62,7 @@ minmax(#{awsui.$space-layout-content-horizontal}, 1fr) min-content min-content; - grid-template-rows: min-content min-content 1fr min-content; + grid-template-rows: min-content min-content 1fr auto; &.has-adaptive-widths-default { #{custom-props.$maxContentWidth}: map.get(constants.$adaptive-content-widths, styles.$breakpoint-xx-large); @@ -100,6 +102,13 @@ } } +.global-tools-bottom-stub { + grid-area: global-tools-bottom; + position: sticky; + inset-block-end: 0; + overflow: hidden; +} + .navigation { z-index: constants.$drawer-z-index; @@ -191,7 +200,7 @@ .main { grid-area: main; margin-block-start: awsui.$space-scaled-s; - margin-block-end: awsui.$space-layout-content-bottom; + margin-block-end: calc(#{awsui.$space-layout-content-bottom} + var(#{custom-props.$activeGlobalBottomDrawerHeight})); &-disable-paddings { margin-block: 0; diff --git a/src/internal/plugins/controllers/drawers.ts b/src/internal/plugins/controllers/drawers.ts index d30706d411..59a7185d61 100644 --- a/src/internal/plugins/controllers/drawers.ts +++ b/src/internal/plugins/controllers/drawers.ts @@ -6,6 +6,8 @@ import { reportRuntimeApiWarning } from '../helpers/metrics'; type DrawerVisibilityChange = (callback: (isVisible: boolean) => void) => void; +export type DrawerPosition = 'side' | 'bottom'; + interface MountContentContext { onVisibilityChange: DrawerVisibilityChange; } @@ -15,6 +17,10 @@ export interface DrawerStateChangeParams { initiatedByUserAction?: boolean; } +export interface DrawerPositionChangeParams { + position: DrawerPosition; +} + export interface DrawerConfig { id: string; type?: 'local' | 'global'; @@ -40,6 +46,9 @@ export interface DrawerConfig { unmountContent: (container: HTMLElement) => void; preserveInactiveContent?: boolean; onToggle?: NonCancelableEventHandler; + movable?: boolean; + position?: DrawerPosition; + onPositionChange?: NonCancelableEventHandler; } const updatableProperties = [