Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions pages/app-layout-toolbar/without-toolbar-nested.page.tsx
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will introduce a screenshot test on this page

Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext, useRef, useState } from 'react';

import { AppLayoutToolbar, Button, ContentLayout, Header, HelpPanel, Link, SpaceBetween, Toggle } from '~components';
import { AppLayoutToolbarProps } from '~components/app-layout-toolbar';
import { registerLeftDrawer, updateDrawer } from '~components/internal/plugins/widget';

import AppContext, { AppContextType } from '../app/app-context';
import { Containers, CustomDrawerContent, Navigation } from '../app-layout/utils/content-blocks';
import { drawerLabels } from '../app-layout/utils/drawers';
import { leftDrawerPayload } from '../app-layout/utils/external-global-left-panel-widget';
import appLayoutLabels from '../app-layout/utils/labels';
import { IframeWrapper } from '../utils/iframe-wrapper';
import ScreenshotArea from '../utils/screenshot-area';

registerLeftDrawer({ ...leftDrawerPayload, defaultActive: true });

type DemoContext = React.Context<
AppContextType<{
hasNestedIframe: boolean | undefined;
}>
>;

export default function WithoutToolbar() {
const [activeDrawerId, setActiveDrawerId] = useState<string | null>(null);
const [helpPathSlug, setHelpPathSlug] = useState<string>('default');
const [isToolsOpen, setIsToolsOpen] = useState(false);
const [isNavigationOpen, setIsNavigationOpen] = useState(true);
const pageLayoutRef = useRef<AppLayoutToolbarProps.Ref>(null);
const { urlParams, setUrlParams } = useContext(AppContext as DemoContext);

const drawersProps: Pick<AppLayoutToolbarProps, 'activeDrawerId' | 'onDrawerChange' | 'drawers'> | null = {
activeDrawerId: activeDrawerId,
drawers: [
{
ariaLabels: {
closeButton: 'ProHelp close button',
drawerName: 'ProHelp drawer content',
triggerButton: 'ProHelp trigger button',
resizeHandle: 'ProHelp resize handle',
},
content: <CustomDrawerContent />,
id: 'pro-help',
},
],
onDrawerChange: event => {
setActiveDrawerId(event.detail.activeDrawerId);
},
};

const renderInternalAppLayout = () => (
<AppLayoutToolbar
toolsHide={true}
navigationHide={true}
navigationTriggerHide={true}
content={
<ContentLayout
disableOverlap={true}
header={
<SpaceBetween size="m">
<Header
variant="h1"
description="Sometimes you need custom triggers for drawers and navigation to get the job done."
info={
<Link
data-testid="info-link-header"
variant="info"
onFollow={() => {
setHelpPathSlug('header');
setIsToolsOpen(true);
pageLayoutRef.current?.focusToolsClose();
}}
>
Info
</Link>
}
>
Page layout without the toolbar
</Header>

<SpaceBetween size="xs">
<Toggle
checked={urlParams.hasNestedIframe ?? false}
onChange={event => {
setUrlParams({ hasNestedIframe: event.detail.checked });
}}
>
Has nested iframe
</Toggle>
<Button
onClick={() => {
setIsNavigationOpen(current => !current);
pageLayoutRef.current?.focusNavigation();
}}
>
Toggle navigation
</Button>

<Button
onClick={() => {
setActiveDrawerId('pro-help');
pageLayoutRef.current?.focusActiveDrawer();
}}
>
Open a drawer without trigger
</Button>
<Button onClick={() => setActiveDrawerId(null)}>Close a drawer without trigger</Button>
<Button onClick={() => updateDrawer({ type: 'openDrawer', payload: { id: 'ai-panel' } })}>
Open the left panel
</Button>
</SpaceBetween>
</SpaceBetween>
}
>
<Header
info={
<Link
data-testid="info-link-content"
variant="info"
onFollow={() => {
setHelpPathSlug('content');
setIsToolsOpen(true);
}}
>
Info
</Link>
}
>
Content
</Header>
<Containers />
</ContentLayout>
}
/>
);

return (
<ScreenshotArea gutters={false}>
<AppLayoutToolbar
{...{ __forceEnableRuntimeMessages: true }}
ariaLabels={{ ...appLayoutLabels, ...drawerLabels }}
ref={pageLayoutRef}
content={
urlParams.hasNestedIframe ? (
<IframeWrapper id="smth" AppComponent={renderInternalAppLayout} />
) : (
renderInternalAppLayout()
)
}
onToolsChange={event => {
setIsToolsOpen(event.detail.open);
}}
tools={<Info helpPathSlug={helpPathSlug} />}
toolsOpen={isToolsOpen}
navigationTriggerHide={true}
navigationOpen={isNavigationOpen}
navigation={<Navigation />}
onNavigationChange={event => setIsNavigationOpen(event.detail.open)}
{...drawersProps}
/>
</ScreenshotArea>
);
}

function Info({ helpPathSlug }: { helpPathSlug: string }) {
return <HelpPanel header={<h2>Info</h2>}>Here is some info for you: {helpPathSlug}</HelpPanel>;
}
8 changes: 8 additions & 0 deletions pages/app-layout-toolbar/without-toolbar.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import React, { useRef, useState } from 'react';

import { AppLayoutToolbar, Button, ContentLayout, Header, HelpPanel, Link, SpaceBetween } from '~components';
import { AppLayoutToolbarProps } from '~components/app-layout-toolbar';
import { registerLeftDrawer, updateDrawer } from '~components/internal/plugins/widget';

import { Containers, CustomDrawerContent, Navigation } from '../app-layout/utils/content-blocks';
import { drawerLabels } from '../app-layout/utils/drawers';
import { leftDrawerPayload } from '../app-layout/utils/external-global-left-panel-widget';
import appLayoutLabels from '../app-layout/utils/labels';
import ScreenshotArea from '../utils/screenshot-area';

registerLeftDrawer({ ...leftDrawerPayload, defaultActive: true });

export default function WithDrawers() {
const [activeDrawerId, setActiveDrawerId] = useState<string | null>(null);
const [helpPathSlug, setHelpPathSlug] = useState<string>('default');
Expand Down Expand Up @@ -39,6 +43,7 @@ export default function WithDrawers() {
return (
<ScreenshotArea gutters={false}>
<AppLayoutToolbar
{...{ __forceEnableRuntimeMessages: true }}
ariaLabels={{ ...appLayoutLabels, ...drawerLabels }}
ref={pageLayoutRef}
content={
Expand Down Expand Up @@ -85,6 +90,9 @@ export default function WithDrawers() {
Open a drawer without trigger
</Button>
<Button onClick={() => setActiveDrawerId(null)}>Close a drawer without trigger</Button>
<Button onClick={() => updateDrawer({ type: 'openDrawer', payload: { id: 'ai-panel' } })}>
Open the left panel
</Button>
</SpaceBetween>
</SpaceBetween>
}
Expand Down
1 change: 1 addition & 0 deletions src/__a11y__/a11y-app-layout-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const EXCLUDED_PAGES = [
// Not a use case that's encouraged.
'app-layout/multi-layout-global-drawer-child-layout',
'app-layout/with-error-boundaries',
'app-layout-toolbar/without-toolbar-nested',
];

describe('A11y checks for app layout toolbar', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/__a11y__/run-a11y-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export default function runA11yTests(theme: Theme, mode: Mode, skip: string[] =
// this page intentionally has issues to test the helper
'undefined-texts',
'app-layout/with-error-boundaries',
// nested app layouts aren't accessible, as every page should contain a level-one heading
'app-layout-toolbar/without-toolbar-nested',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the reason for excluding these?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because nested app layouts aren't accessible and there is no good way to fix them

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add a comment explaining the accessibility issue?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

];
const testFunction =
skipPages.includes(inputUrl) ||
Expand Down
59 changes: 59 additions & 0 deletions src/app-layout/__tests__/runtime-drawers-widgetized.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,65 @@ describeEachAppLayout({ themes: ['refresh-toolbar'] }, ({ size }) => {
}
});

describe('left drawer without toolbar', () => {
test('should not render left drawer when toolbar is not present', () => {
awsuiWidgetPlugins.registerLeftDrawer({ ...drawerDefaults, trigger: undefined, defaultActive: true });
const { globalDrawersWrapper, wrapper } = renderComponent(<AppLayout navigationHide={true} toolsHide={true} />);

expect(wrapper.findToolbar()).toBeFalsy();
expect(globalDrawersWrapper.findDrawerById(drawerDefaults.id)).toBeFalsy();
});

test('should render left drawer when toolbar is not present (__forceEnableRuntimeMessages is provided)', () => {
awsuiWidgetPlugins.registerLeftDrawer({ ...drawerDefaults, trigger: undefined, defaultActive: true });
const { globalDrawersWrapper, wrapper } = renderComponent(
<AppLayout {...{ __forceEnableRuntimeMessages: true }} navigationHide={true} toolsHide={true} />
);

expect(wrapper.findToolbar()).toBeFalsy();
expect(globalDrawersWrapper.findDrawerById(drawerDefaults.id)!.isActive()).toBe(true);
});

test('should open left drawer via API when toolbar is not present (__forceEnableRuntimeMessages is provided)', () => {
awsuiWidgetPlugins.registerLeftDrawer({ ...drawerDefaults, trigger: undefined });
const { globalDrawersWrapper, wrapper } = renderComponent(
<AppLayout {...{ __forceEnableRuntimeMessages: true }} navigationHide={true} toolsHide={true} />
);

expect(wrapper.findToolbar()).toBeFalsy();
expect(globalDrawersWrapper.findDrawerById(drawerDefaults.id)).toBeFalsy();

act(() => awsuiWidgetPlugins.updateDrawer({ type: 'openDrawer', payload: { id: drawerDefaults.id } }));

expect(globalDrawersWrapper.findDrawerById(drawerDefaults.id)!.isActive()).toBe(true);
});

test('should render left drawer when toolbar is not present and has a nested app layout (__forceEnableRuntimeMessages is provided)', () => {
awsuiWidgetPlugins.registerLeftDrawer({ ...drawerDefaults, trigger: undefined, defaultActive: true });
const { container } = render(
<AppLayout
{...{ __forceEnableRuntimeMessages: true }}
data-testid="outer"
navigationHide={true}
toolsHide={true}
content={<AppLayout data-testid="inner" navigationHide={true} toolsHide={true} />}
/>
);

const outerWrapper = createWrapper(container).find('[data-testid="outer"]')!.findAppLayout()!;
const innerWrapper = createWrapper(container).find('[data-testid="inner"]')!.findAppLayout()!;
const outerDrawersUtils = getGlobalDrawersTestUtils(outerWrapper);
const innerDrawersUtils = getGlobalDrawersTestUtils(innerWrapper);

expect(outerWrapper.findToolbar()).toBeFalsy();
expect(innerWrapper.findToolbar()).toBeFalsy();
expect(outerDrawersUtils.findDrawerById(drawerDefaults.id)!.isActive()).toBe(true);
expect(outerDrawersUtils.findActiveDrawers().length).toBe(1);
expect(innerDrawersUtils.findDrawerById(drawerDefaults.id)).toBeFalsy();
expect(innerDrawersUtils.findActiveDrawers().length).toBe(0);
});
});

describe('metrics', () => {
let sendPanoramaMetricSpy: jest.SpyInstance;
beforeEach(() => {
Expand Down
58 changes: 58 additions & 0 deletions src/app-layout/visual-refresh-toolbar/skeleton/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,64 @@
box-sizing: border-box;
}

/**
* A zero-height sticky element placed in the toolbar grid area when the toolbar is absent
* and the left panel (AI drawer) is open. It reuses the toolbar's pseudo-element technique
* to render the border-radius corner between the AI drawer and the content area.
* In light mode: inverse radius trick (dark square + white square with radius).
* In dark mode: a vertical border line with a rounded top corner + top border on the element itself.
*/
.pseudo-toolbar {
grid-area: toolbar;
position: sticky;
z-index: constants.$toolbar-z-index;

&:before {
@include theming.dark-mode-only {
content: '';
position: absolute;
inline-size: awsui.$border-divider-section-width;
block-size: 100vh;
background: awsui.$color-border-layout;
}
}
}

.pseudo-toolbar-content {
position: relative;

@include theming.dark-mode-only {
border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-layout;
border-start-start-radius: awsui.$space-xxs;
}

&:before,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These styles are for showing a round-corner for content area. Since the pseudo toolbar should not occupy any space, there is no way to apply styles to it directly

&:after {
content: '';
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 5px;
block-size: 5px;
background: constants.$ai-drawer-background;

@include theming.dark-mode-only {
inset-block-start: -1px;
}
}

&::after {
background-color: awsui.$color-background-layout-toolbar;
border-start-start-radius: awsui.$space-xxs;

@include theming.dark-mode-only {
background-color: transparent;
border-inline-start: awsui.$border-divider-section-width solid awsui.$color-border-layout;
border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-layout;
}
}
}

.notifications-container {
grid-area: notifications;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const mergeProps: MergeProps = (ownProps, additionalProps) => {
toolbar.onActiveGlobalDrawersChange = props.onActiveGlobalDrawersChange;
}
if (
props.aiDrawer &&
props.aiDrawer?.trigger &&
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are toolbar only props, so if the drawer is trigger-less, its props shouldn't be forwarded to the toolbar

props.aiDrawerFocusRef &&
!checkAlreadyExists(!!toolbar.aiDrawerFocusRef, 'aiDrawerFocusRef')
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const useAppLayout = (
const onMountRootRef = useCallback(node => {
setIsNested(getIsNestedInAppLayout(node));
}, []);
const { __forceEnableRuntimeMessages: forceEnableRuntimeMessages } = rest as any;

const [toolsOpen = false, setToolsOpen] = useControllable(controlledToolsOpen, onToolsChange, false, {
componentName: 'AppLayout',
Expand Down Expand Up @@ -239,7 +240,7 @@ export const useAppLayout = (
}
};

useWidgetMessages(hasToolbar, message => {
useWidgetMessages(hasToolbar || forceEnableRuntimeMessages, message => {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introduced an opt-in flag here. I considered detecting whether the AppLayout is the outermost instance in nested layouts, but that doesn't work with isolated iframes and would introduce a regression

if (message.type === 'expandDrawer' || message.type === 'exitExpandedMode') {
drawerGenericMessageHandler(message);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ export const BeforeMainSlotImplementationInternal = ({
featureNotificationsProps={featureNotificationsProps}
/>
)}
{!toolbarProps && !!activeAiDrawerId && (
<div
className={styles['pseudo-toolbar']}
style={{
insetBlockStart: appLayoutState.appLayoutInternals?.verticalOffsets?.toolbar,
}}
>
<div className={styles['pseudo-toolbar-content']} />
</div>
)}
{aiDrawer && (
<div
className={clsx(
Expand Down
Loading