diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index c3f11c3ed9..7864370b63 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -54,6 +54,7 @@ const pluralizationMap = { NavigableGroup: 'NavigableGroups', Pagination: 'Paginations', AppLayoutToolbar: 'AppLayoutToolbars', + PanelLayout: 'PanelLayouts', PieChart: 'PieCharts', Popover: 'Popovers', ProgressBar: 'ProgressBars', diff --git a/pages/app-layout/styles.scss b/pages/app-layout/styles.scss index 1b852a33e5..c91b7e3bbb 100644 --- a/pages/app-layout/styles.scss +++ b/pages/app-layout/styles.scss @@ -123,3 +123,10 @@ .ai-panel-logo { font-weight: 600; } + +.ai-artifact-panel { + block-size: 100%; + overflow: auto; + background-color: awsui.$color-background-layout-main; + border-inline-start: 1px solid awsui.$color-border-divider-default; +} diff --git a/pages/app-layout/utils/external-global-left-panel-widget.tsx b/pages/app-layout/utils/external-global-left-panel-widget.tsx index 33ca05802b..55786fe06b 100644 --- a/pages/app-layout/utils/external-global-left-panel-widget.tsx +++ b/pages/app-layout/utils/external-global-left-panel-widget.tsx @@ -1,25 +1,46 @@ // 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 { Box, Button } from '~components'; +import { Box, Button, PanelLayout } from '~components'; import { registerLeftDrawer, updateDrawer } from '~components/internal/plugins/widget'; import { mount, unmount } from '~mount'; import styles from '../styles.scss'; +const DEFAULT_SIZE = 360; +const CHAT_SIZE = 280; +const MIN_CHAT_SIZE = 150; +const MIN_ARTIFACT_SIZE = 360; + const AIDrawer = () => { - return ( + const [hasArtifact, setHasArtifact] = useState(false); + const [artifactLoaded, setArtifactLoaded] = useState(false); + const [chatSize, setChatSize] = useState(CHAT_SIZE); + const [maxPanelSize, setMaxPanelSize] = useState(Number.MAX_SAFE_INTEGER); + const constrainedChatSize = Math.min(chatSize, maxPanelSize); + const collapsed = constrainedChatSize < MIN_CHAT_SIZE; + + const chatContent = ( Chat demo {new Array(100).fill(null).map((_, index) => ( -
Tela content
+
Tela chat content
))}
); + const artifactContent = ( + + + Artifact + + + + {artifactLoaded && new Array(100).fill(null).map((_, index) =>
Tela content
)} +
+ ); + return ( + setChatSize(detail.panelSize)} + onLayoutChange={({ detail }) => setMaxPanelSize(detail.totalSize - MIN_ARTIFACT_SIZE)} + panelContent={chatContent} + mainContent={
{artifactContent}
} + display={hasArtifact ? (collapsed ? 'main-only' : 'all') : 'panel-only'} + /> + ); }; registerLeftDrawer({ id: 'ai-panel', resizable: true, isExpandable: true, - defaultSize: 420, + defaultSize: DEFAULT_SIZE, preserveInactiveContent: true, ariaLabels: { diff --git a/pages/panel-layout/app-layout-panel.page.tsx b/pages/panel-layout/app-layout-panel.page.tsx new file mode 100644 index 0000000000..290f1530f9 --- /dev/null +++ b/pages/panel-layout/app-layout-panel.page.tsx @@ -0,0 +1,200 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { Box, Checkbox, Drawer, FormField, Header, Input, SegmentedControl, SpaceBetween } from '~components'; +import AppLayout from '~components/app-layout'; +import Button from '~components/button'; +import { I18nProvider } from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import PanelLayout, { PanelLayoutProps } from '~components/panel-layout'; + +import AppContext, { AppContextType } from '../app/app-context'; +import labels from '../app-layout/utils/labels'; +import ScreenshotArea from '../utils/screenshot-area'; + +interface PanelLayoutContentProps { + longPanelContent: boolean; + longMainContent: boolean; + buttons: boolean; + minPanelSize: number; + maxPanelSize: number; + minContentSize: number; + display: PanelLayoutProps.Display; + panelPosition: PanelLayoutProps.PanelPosition; +} +type PageContext = React.Context>>; + +const PanelLayoutContent = ({ + longPanelContent, + longMainContent, + buttons, + minContentSize, + minPanelSize, + maxPanelSize, + display, + panelPosition, +}: PanelLayoutContentProps) => { + const [size, setSize] = useState(Math.max(200, minPanelSize)); + const [actualMaxPanelSize, setActualMaxPanelSize] = useState(size); + + const actualSize = Math.min(size, actualMaxPanelSize); + const collapsed = actualMaxPanelSize < minPanelSize; + + return ( + setSize(detail.panelSize)} + onLayoutChange={({ detail }) => setActualMaxPanelSize(Math.min(detail.totalSize - minContentSize, maxPanelSize))} + display={display === 'all' && collapsed ? 'main-only' : display} + panelPosition={panelPosition} + mainFocusable={longMainContent && !buttons ? { ariaLabel: 'Main content' } : undefined} + panelFocusable={longPanelContent && !buttons ? { ariaLabel: 'Panel content' } : undefined} + panelContent={ + +
Panel content
+ {new Array(longPanelContent ? 20 : 1) + .fill('Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + .map((t, i) => ( +
{t}
+ ))} + {buttons && } +
+ } + mainContent={ + +
Main content{collapsed && ' [collapsed]'}
+ {new Array(longMainContent ? 200 : 1) + .fill('Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + .map((t, i) => ( +
{t}
+ ))} + {buttons && } +
+ } + /> + ); +}; + +export default function PanelLayoutPage() { + const { + urlParams: { + longPanelContent = false, + longMainContent = false, + buttons = true, + minPanelSize = 200, + maxPanelSize = 600, + minContentSize = 600, + display = 'all', + panelPosition = 'side-start', + }, + setUrlParams, + } = useContext(AppContext as PageContext); + + return ( + + + + + setUrlParams({ longMainContent: detail.checked })} + > + Long main content + + setUrlParams({ longPanelContent: detail.checked })} + > + Long panel content + + setUrlParams({ buttons: detail.checked })}> + Include interactive content + + + setUrlParams({ minPanelSize: detail.value ? parseInt(detail.value) : 0 })} + /> + + + + setUrlParams({ maxPanelSize: detail.value ? parseInt(detail.value) : Number.MAX_SAFE_INTEGER }) + } + /> + + + + setUrlParams({ minContentSize: detail.value ? parseInt(detail.value) : 0 }) + } + /> + + + setUrlParams({ panelPosition: detail.selectedId as any })} + /> + + + setUrlParams({ display: detail.selectedId as any })} + /> + + + + } + content={
Panel layout in drawer demo
} + drawers={[ + { + id: 'panel', + content: ( + + ), + resizable: true, + defaultSize: 1000, + ariaLabels: { + drawerName: 'Panel', + triggerButton: 'Open panel', + closeButton: 'Close panel', + resizeHandle: 'Resize drawer', + }, + trigger: { iconName: 'contact' }, + }, + ]} + /> +
+
+ ); +} diff --git a/pages/panel-layout/nested.page.tsx b/pages/panel-layout/nested.page.tsx new file mode 100644 index 0000000000..0a042595e6 --- /dev/null +++ b/pages/panel-layout/nested.page.tsx @@ -0,0 +1,143 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext } from 'react'; + +import { Container, FormField, Header, SegmentedControl, SpaceBetween } from '~components'; +import Button from '~components/button'; +import PanelLayout, { PanelLayoutProps } from '~components/panel-layout'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +interface NestedPanelLayoutProps { + outerPanelPosition: PanelLayoutProps.PanelPosition; + innerPanelPosition: PanelLayoutProps.PanelPosition; + nestedLocation: 'panel' | 'main'; +} +type PageContext = React.Context>>; + +const NestedPanelLayoutDemo = ({ outerPanelPosition, innerPanelPosition, nestedLocation }: NestedPanelLayoutProps) => { + const innerPanelLayout = ( + Level 2 Panel}> +

This is a nested panel layout panel (second level).

+

You can resize this panel independently from the outer panel layout.

+ + + {Array.from({ length: 18 }, (_, i) => ( +
Nested panel content {i + 1}
+ ))} +
+ + } + mainContent={ + Second Level Main Content}> +

This is the main content area of the second panel layout.

+ + {Array.from({ length: 15 }, (_, i) => ( +
Content line {i + 1}
+ ))} +
+
+ } + /> + ); + + const simpleContent = ( + Simple Content}> +

This is the {nestedLocation === 'panel' ? 'main' : 'panel'} content of the outer panel layout.

+

It contains simple content without any nesting.

+ + + {Array.from({ length: 20 }, (_, i) => ( +
Simple content line {i + 1}
+ ))} +
+
+ ); + + return ( + + ); +}; + +export default function NestedPanelLayoutPage() { + const { + urlParams: { outerPanelPosition = 'side-start', innerPanelPosition = 'side-start', nestedLocation = 'panel' }, + setUrlParams, + } = useContext(AppContext as PageContext); + + return ( + + + +

+ This page demonstrates nested panel layouts, where one panel layout contains another panel layout in either + its panel or main content area. +

+

+ Each panel layout maintains its own resize behavior and can be configured with different panel positions and + constraints. +

+ + + + setUrlParams({ outerPanelPosition: detail.selectedId as any })} + /> + + + + setUrlParams({ innerPanelPosition: detail.selectedId as any })} + /> + + + + setUrlParams({ nestedLocation: detail.selectedId as any })} + /> + + +
+ +
+ +
+
+
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index dcfce083a3..fe0cdc02c8 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Components definition for alert matches the snapshot: alert 1`] = ` { @@ -18000,6 +18000,242 @@ never disabled. When the user clicks on it but there are no more items to show, } `; +exports[`Components definition for panel-layout matches the snapshot: panel-layout 1`] = ` +{ + "dashCaseName": "panel-layout", + "events": [ + { + "cancelable": false, + "description": "Called when the panel and/or main content size changes. This can be due +to user resizing or changes to the available space on the page.", + "detailInlineType": { + "name": "PanelLayoutProps.PanelResizeDetail", + "properties": [ + { + "name": "panelSize", + "optional": false, + "type": "number", + }, + { + "name": "totalSize", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "detailType": "PanelLayoutProps.PanelResizeDetail", + "name": "onLayoutChange", + }, + { + "cancelable": false, + "description": "Called when the user resizes the panel.", + "detailInlineType": { + "name": "PanelLayoutProps.PanelResizeDetail", + "properties": [ + { + "name": "panelSize", + "optional": false, + "type": "number", + }, + { + "name": "totalSize", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "detailType": "PanelLayoutProps.PanelResizeDetail", + "name": "onPanelResize", + }, + ], + "functions": [ + { + "description": "Focuses the resize handle of the panel layout.", + "name": "focusResizeHandle", + "parameters": [], + "returnType": "void", + }, + ], + "name": "PanelLayout", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Initial panel size, for uncontrolled behavior. + +The actual size will be constrained by \`minPanelSize\` and \`maxPanelSize\`, if set.", + "name": "defaultPanelSize", + "optional": true, + "type": "number", + }, + { + "defaultValue": "'all'", + "description": "Determines which content is displayed: +- 'all': Both panel and main content are displayed. +- 'panel-only': Only panel is displayed. +- 'main-only': Only main content is displayed.", + "inlineType": { + "name": "PanelLayoutProps.Display", + "type": "union", + "values": [ + "all", + "panel-only", + "main-only", + ], + }, + "name": "display", + "optional": true, + "type": "string", + }, + { + "description": "An object containing all the necessary localized strings required by the component.", + "i18nTag": true, + "inlineType": { + "name": "PanelLayoutProps.I18nStrings", + "properties": [ + { + "name": "resizeHandleAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "resizeHandleTooltipText", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "PanelLayoutProps.I18nStrings", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Makes the main content area focusable. This should be used if there are no focusable elements +inside the content, to ensure it is keyboard scrollable. + +Provide either \`{ariaLabel: "Main label"}\` or \`{ariaLabelledby: "main-label-id"}\`", + "inlineType": { + "name": "PanelLayoutProps.FocusableConfig", + "properties": [ + { + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "mainFocusable", + "optional": true, + "type": "PanelLayoutProps.FocusableConfig", + }, + { + "description": "The maximum size of the panel.", + "name": "maxPanelSize", + "optional": true, + "type": "number", + }, + { + "description": "The minimum size of the panel.", + "name": "minPanelSize", + "optional": true, + "type": "number", + }, + { + "description": "Makes the panel content focusable. This should be used if there are no focusable elements +inside the panel, to ensure it is keyboard scrollable. + +Provide either \`{ariaLabel: "Panel label"}\` or \`{ariaLabelledby: "panel-label-id"}\`", + "inlineType": { + "name": "PanelLayoutProps.FocusableConfig", + "properties": [ + { + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "panelFocusable", + "optional": true, + "type": "PanelLayoutProps.FocusableConfig", + }, + { + "defaultValue": "'side-start'", + "description": "Position of the panel with respect to the main content", + "inlineType": { + "name": "PanelLayoutProps.PanelPosition", + "type": "union", + "values": [ + "side-start", + "side-end", + ], + }, + "name": "panelPosition", + "optional": true, + "type": "string", + }, + { + "description": "Size of the panel. If provided, and panel is resizable, the component is controlled, +so you must also provide \`onPanelResize\`. + +The actual size will be constrained by \`minPanelSize\` and \`maxPanelSize\`, if set.", + "name": "panelSize", + "optional": true, + "type": "number", + }, + { + "defaultValue": "false", + "description": "Indicates whether the panel is resizable.", + "name": "resizable", + "optional": true, + "type": "boolean", + }, + ], + "regions": [ + { + "description": "Main content area displayed next to the panel.", + "isDefault": false, + "name": "mainContent", + }, + { + "description": "Panel contents.", + "isDefault": false, + "name": "panelContent", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Components definition for pie-chart matches the snapshot: pie-chart 1`] = ` { "dashCaseName": "pie-chart", @@ -37484,6 +37720,54 @@ wrapper.selectOptionByValue('option_1'); ], "name": "NavigableGroupWrapper", }, + { + "methods": [ + { + "description": "Returns the wrapper for the main content element.", + "name": "findMainContent", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the wrapper for the panel element.", + "name": "findPanelContent", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the wrapper for the resize handle element. +Returns null if the panel layout is not resizable.", + "name": "findResizeHandle", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + ], + "name": "PanelLayoutWrapper", + }, { "methods": [ { @@ -46661,6 +46945,39 @@ The dismiss button is only rendered when the \`dismissible\` property is set to ], "name": "NavigableGroupWrapper", }, + { + "methods": [ + { + "description": "Returns the wrapper for the main content element.", + "name": "findMainContent", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the wrapper for the panel element.", + "name": "findPanelContent", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the wrapper for the resize handle element. +Returns null if the panel layout is not resizable.", + "name": "findResizeHandle", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + ], + "name": "PanelLayoutWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index d990255f90..c9e066f3ce 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`test-utils selectors 1`] = ` { @@ -456,6 +456,12 @@ exports[`test-utils selectors 1`] = ` "awsui_page-number_fvjdu", "awsui_root_fvjdu", ], + "panel-layout": [ + "awsui_content_119z0", + "awsui_panel_119z0", + "awsui_resize-handle_119z0", + "awsui_root_119z0", + ], "pie-chart": [ "awsui_inner-content_1edmh", "awsui_label--highlighted_1edmh", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 96c079421a..ef6efccd03 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -61,6 +61,7 @@ import ModalWrapper from './modal'; import MultiselectWrapper from './multiselect'; import NavigableGroupWrapper from './navigable-group'; import PaginationWrapper from './pagination'; +import PanelLayoutWrapper from './panel-layout'; import PieChartWrapper from './pie-chart'; import PopoverWrapper from './popover'; import ProgressBarWrapper from './progress-bar'; @@ -148,6 +149,7 @@ export { ModalWrapper }; export { MultiselectWrapper }; export { NavigableGroupWrapper }; export { PaginationWrapper }; +export { PanelLayoutWrapper }; export { PieChartWrapper }; export { PopoverWrapper }; export { ProgressBarWrapper }; @@ -1173,6 +1175,25 @@ findPagination(selector?: string): PaginationWrapper | null; * @returns {Array} */ findAllPaginations(selector?: string): Array; +/** + * Returns the wrapper of the first PanelLayout that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first PanelLayout. + * If no matching PanelLayout is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {PanelLayoutWrapper | null} + */ +findPanelLayout(selector?: string): PanelLayoutWrapper | null; + +/** + * Returns an array of PanelLayout wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the PanelLayouts inside the current wrapper. + * If no matching PanelLayout is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllPanelLayouts(selector?: string): Array; /** * Returns the wrapper of the first PieChart that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first PieChart. @@ -2480,6 +2501,19 @@ ElementWrapper.prototype.findPagination = function(selector) { ElementWrapper.prototype.findAllPaginations = function(selector) { return this.findAllComponents(PaginationWrapper, selector); }; +ElementWrapper.prototype.findPanelLayout = function(selector) { + let rootSelector = \`.\${PanelLayoutWrapper.rootSelector}\`; + if("legacyRootSelector" in PanelLayoutWrapper && PanelLayoutWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${PanelLayoutWrapper.rootSelector}, .\${PanelLayoutWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, PanelLayoutWrapper); +}; + +ElementWrapper.prototype.findAllPanelLayouts = function(selector) { + return this.findAllComponents(PanelLayoutWrapper, selector); +}; ElementWrapper.prototype.findPieChart = function(selector) { let rootSelector = \`.\${PieChartWrapper.rootSelector}\`; if("legacyRootSelector" in PieChartWrapper && PieChartWrapper.legacyRootSelector){ @@ -2981,6 +3015,7 @@ import ModalWrapper from './modal'; import MultiselectWrapper from './multiselect'; import NavigableGroupWrapper from './navigable-group'; import PaginationWrapper from './pagination'; +import PanelLayoutWrapper from './panel-layout'; import PieChartWrapper from './pie-chart'; import PopoverWrapper from './popover'; import ProgressBarWrapper from './progress-bar'; @@ -3068,6 +3103,7 @@ export { ModalWrapper }; export { MultiselectWrapper }; export { NavigableGroupWrapper }; export { PaginationWrapper }; +export { PanelLayoutWrapper }; export { PieChartWrapper }; export { PopoverWrapper }; export { ProgressBarWrapper }; @@ -3989,6 +4025,23 @@ findPagination(selector?: string): PaginationWrapper; * @returns {MultiElementWrapper} */ findAllPaginations(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the PanelLayouts with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches PanelLayouts. + * + * @param {string} [selector] CSS Selector + * @returns {PanelLayoutWrapper} + */ +findPanelLayout(selector?: string): PanelLayoutWrapper; + +/** + * Returns a multi-element wrapper that matches PanelLayouts with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches PanelLayouts. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllPanelLayouts(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the PieCharts with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches PieCharts. @@ -5230,6 +5283,19 @@ ElementWrapper.prototype.findPagination = function(selector) { ElementWrapper.prototype.findAllPaginations = function(selector) { return this.findAllComponents(PaginationWrapper, selector); }; +ElementWrapper.prototype.findPanelLayout = function(selector) { + let rootSelector = \`.\${PanelLayoutWrapper.rootSelector}\`; + if("legacyRootSelector" in PanelLayoutWrapper && PanelLayoutWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${PanelLayoutWrapper.rootSelector}, .\${PanelLayoutWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, PanelLayoutWrapper); +}; + +ElementWrapper.prototype.findAllPanelLayouts = function(selector) { + return this.findAllComponents(PanelLayoutWrapper, selector); +}; ElementWrapper.prototype.findPieChart = function(selector) { let rootSelector = \`.\${PieChartWrapper.rootSelector}\`; if("legacyRootSelector" in PieChartWrapper && PieChartWrapper.legacyRootSelector){ diff --git a/src/app-layout/utils/use-pointer-events.ts b/src/app-layout/utils/use-pointer-events.ts index a7b134cbd8..4c2f342cd7 100644 --- a/src/app-layout/utils/use-pointer-events.ts +++ b/src/app-layout/utils/use-pointer-events.ts @@ -33,8 +33,7 @@ export const usePointerEvents = ({ position, panelRef, handleRef, onResize }: Si // The handle offset aligns the cursor with the middle of the resize handle. const handleOffset = getLogicalBoundingClientRect(handleRef.current).inlineSize / 2; const panelBoundingClientRect = getLogicalBoundingClientRect(panelRef.current); - const width = - panelBoundingClientRect.insetInlineEnd + mouseClientX + handleOffset - panelBoundingClientRect.inlineSize; + const width = mouseClientX + handleOffset - panelBoundingClientRect.insetInlineStart; onResize(width); } else { diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 7fa4483395..1352fd0ba0 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -315,6 +315,10 @@ export interface I18nFormatArgTypes { } "ariaLabels.previousPageLabel": never; } + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": never; + "i18nStrings.resizeHandleTooltipText": never; + } "pie-chart": { "i18nStrings.detailsValue": never; "i18nStrings.detailsPercentage": never; diff --git a/src/i18n/messages/all.ar.json b/src/i18n/messages/all.ar.json index 39cfb04532..008e420b13 100644 --- a/src/i18n/messages/all.ar.json +++ b/src/i18n/messages/all.ar.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "صفحة رقم {pageNumber} من إجمالي عدد الصفحات", "ariaLabels.previousPageLabel": "الصفحة السابقة" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "مقبض تغيير حجم اللوحة", + "i18nStrings.resizeHandleTooltipText": "قم بالسحب أو التحديد لتغيير الحجم" + }, "pie-chart": { "i18nStrings.detailsValue": "عمود القيمة", "i18nStrings.detailsPercentage": "النسبة المئوية", diff --git a/src/i18n/messages/all.de.json b/src/i18n/messages/all.de.json index 97f6e7cf7e..f7a9cef13a 100644 --- a/src/i18n/messages/all.de.json +++ b/src/i18n/messages/all.de.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Seite {pageNumber} aller Seiten", "ariaLabels.previousPageLabel": "Vorherige Seite" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Größe des Panels ändern", + "i18nStrings.resizeHandleTooltipText": "Zum Ändern der Größe ziehen oder auswählen" + }, "pie-chart": { "i18nStrings.detailsValue": "Wert", "i18nStrings.detailsPercentage": "Prozentsatz", diff --git a/src/i18n/messages/all.en-GB.json b/src/i18n/messages/all.en-GB.json index 2ce501c795..1ad2c1996e 100644 --- a/src/i18n/messages/all.en-GB.json +++ b/src/i18n/messages/all.en-GB.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Page {pageNumber} of all pages", "ariaLabels.previousPageLabel": "Previous page" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Panel resize handle", + "i18nStrings.resizeHandleTooltipText": "Drag or select to resize" + }, "pie-chart": { "i18nStrings.detailsValue": "Value", "i18nStrings.detailsPercentage": "Percentage", diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index ec5d255e91..aed5aa963b 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Page {pageNumber} of all pages", "ariaLabels.previousPageLabel": "Previous page" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Panel resize handle", + "i18nStrings.resizeHandleTooltipText": "Drag or select to resize" + }, "pie-chart": { "i18nStrings.detailsValue": "Value", "i18nStrings.detailsPercentage": "Percentage", diff --git a/src/i18n/messages/all.es.json b/src/i18n/messages/all.es.json index 19639f7b56..90c8f35248 100644 --- a/src/i18n/messages/all.es.json +++ b/src/i18n/messages/all.es.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Página {pageNumber} de todas las páginas", "ariaLabels.previousPageLabel": "Página anterior" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Controlador de cambio del tamaño del panel", + "i18nStrings.resizeHandleTooltipText": "Arrastre o seleccione para cambiar el tamaño" + }, "pie-chart": { "i18nStrings.detailsValue": "Valor", "i18nStrings.detailsPercentage": "Porcentaje", diff --git a/src/i18n/messages/all.fr.json b/src/i18n/messages/all.fr.json index bd58c03fd7..6a41faff14 100644 --- a/src/i18n/messages/all.fr.json +++ b/src/i18n/messages/all.fr.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Page {pageNumber} de toutes les pages", "ariaLabels.previousPageLabel": "Page précédente" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Poignée de redimensionnement du panneau", + "i18nStrings.resizeHandleTooltipText": "Faites glisser ou sélectionnez pour redimensionner" + }, "pie-chart": { "i18nStrings.detailsValue": "Valeur", "i18nStrings.detailsPercentage": "Pourcentage", diff --git a/src/i18n/messages/all.id.json b/src/i18n/messages/all.id.json index b7095eec87..c34b7988d1 100644 --- a/src/i18n/messages/all.id.json +++ b/src/i18n/messages/all.id.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Halaman {pageNumber} dari semua halaman", "ariaLabels.previousPageLabel": "Halaman sebelumnya" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Handel pengubahan ukuran panel", + "i18nStrings.resizeHandleTooltipText": "Seret atau pilih untuk mengubah ukuran" + }, "pie-chart": { "i18nStrings.detailsValue": "Nilai", "i18nStrings.detailsPercentage": "Persentase", diff --git a/src/i18n/messages/all.it.json b/src/i18n/messages/all.it.json index b51d1eadb6..a6515a5e71 100644 --- a/src/i18n/messages/all.it.json +++ b/src/i18n/messages/all.it.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Pagina {pageNumber} di tutte le pagine", "ariaLabels.previousPageLabel": "Pagina precedente" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Maniglia di ridimensionamento del pannello", + "i18nStrings.resizeHandleTooltipText": "Trascina o seleziona per ridimensionare" + }, "pie-chart": { "i18nStrings.detailsValue": "Valore", "i18nStrings.detailsPercentage": "Percentuale", diff --git a/src/i18n/messages/all.ja.json b/src/i18n/messages/all.ja.json index 202b47605b..65d9f9c68d 100644 --- a/src/i18n/messages/all.ja.json +++ b/src/i18n/messages/all.ja.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "全ページ中 {pageNumber} ページ", "ariaLabels.previousPageLabel": "前のページ" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "パネルのサイズ変更ハンドル", + "i18nStrings.resizeHandleTooltipText": "ドラッグまたは選択してリサイズする" + }, "pie-chart": { "i18nStrings.detailsValue": "値", "i18nStrings.detailsPercentage": "パーセンテージ", diff --git a/src/i18n/messages/all.ko.json b/src/i18n/messages/all.ko.json index 4d4407148b..4e125fa177 100644 --- a/src/i18n/messages/all.ko.json +++ b/src/i18n/messages/all.ko.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "전체 페이지 중 {pageNumber}페이지", "ariaLabels.previousPageLabel": "이전 페이지" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "패널 크기 조정 핸들", + "i18nStrings.resizeHandleTooltipText": "드래그하거나 선택하여 크기 조정" + }, "pie-chart": { "i18nStrings.detailsValue": "값", "i18nStrings.detailsPercentage": "백분율", diff --git a/src/i18n/messages/all.pt-BR.json b/src/i18n/messages/all.pt-BR.json index cc8a6c596d..050ae67cb7 100644 --- a/src/i18n/messages/all.pt-BR.json +++ b/src/i18n/messages/all.pt-BR.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Página {pageNumber} de todas as páginas", "ariaLabels.previousPageLabel": "Página anterior" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Alça de redimensionamento do painel", + "i18nStrings.resizeHandleTooltipText": "Arraste ou selecione para redimensionar" + }, "pie-chart": { "i18nStrings.detailsValue": "Valor", "i18nStrings.detailsPercentage": "Porcentagem", diff --git a/src/i18n/messages/all.tr.json b/src/i18n/messages/all.tr.json index 1fc04ee184..7fb8a438bc 100644 --- a/src/i18n/messages/all.tr.json +++ b/src/i18n/messages/all.tr.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "Sayfa {pageNumber}/tüm sayfalar", "ariaLabels.previousPageLabel": "Önceki sayfa" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "Panel yeniden boyutlandırma tutamacı", + "i18nStrings.resizeHandleTooltipText": "Yeniden boyutlandırmak için sürükleyin veya seçin" + }, "pie-chart": { "i18nStrings.detailsValue": "Değer", "i18nStrings.detailsPercentage": "Yüzde", diff --git a/src/i18n/messages/all.zh-CN.json b/src/i18n/messages/all.zh-CN.json index 5876af8f57..a1a50aec6a 100644 --- a/src/i18n/messages/all.zh-CN.json +++ b/src/i18n/messages/all.zh-CN.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "所有页面中的第 {pageNumber} 页", "ariaLabels.previousPageLabel": "上一页" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "面板大小调整手柄", + "i18nStrings.resizeHandleTooltipText": "通过拖动或选择来调整大小" + }, "pie-chart": { "i18nStrings.detailsValue": "值", "i18nStrings.detailsPercentage": "百分比", diff --git a/src/i18n/messages/all.zh-TW.json b/src/i18n/messages/all.zh-TW.json index 5f54b2c92c..a3e229d170 100644 --- a/src/i18n/messages/all.zh-TW.json +++ b/src/i18n/messages/all.zh-TW.json @@ -242,6 +242,10 @@ "ariaLabels.pageLabel": "所有頁面中的第 {pageNumber} 頁", "ariaLabels.previousPageLabel": "上一頁" }, + "panel-resize-handle": { + "i18nStrings.resizeHandleAriaLabel": "面板調整大小控點", + "i18nStrings.resizeHandleTooltipText": "拖曳或選取以調整大小" + }, "pie-chart": { "i18nStrings.detailsValue": "值", "i18nStrings.detailsPercentage": "百分比", diff --git a/src/internal/components/panel-resize-handle/index.tsx b/src/internal/components/panel-resize-handle/index.tsx index 925ba05c33..4a43986e18 100644 --- a/src/internal/components/panel-resize-handle/index.tsx +++ b/src/internal/components/panel-resize-handle/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import clsx from 'clsx'; +import { useInternalI18n } from '../../do-not-use/i18n'; import InternalDragHandle, { DragHandleProps } from '../drag-handle'; import styles from './styles.css.js'; @@ -23,11 +24,12 @@ export default React.forwardRef(function Pane { className, ariaLabel, tooltipText, ariaValuenow, position, onDirectionClick, onKeyDown, onPointerDown, disabled }, ref ) { + const i18n = useInternalI18n('panel-resize-handle'); return ( = {}) { + const urlParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + urlParams.set(key, value.toString()); + }); + + const url = `#/light/panel-layout/app-layout-panel?${urlParams.toString()}`; + await this.browser.url(url); + await this.waitForVisible(appLayoutWrapper.toSelector()); + } + + async openDrawer() { + if (!(await this.isDisplayed(appLayoutWrapper.findActiveDrawer()!.toSelector()))) { + await this.click(appLayoutWrapper.findDrawerTriggerById('panel').toSelector()); + await this.waitForVisible(appLayoutWrapper.findActiveDrawer()!.toSelector()); + } + } + + getPanelLayoutWrapper() { + return appLayoutWrapper.findActiveDrawer()!.findPanelLayout(); + } + + async getPanelLayoutPanelSize() { + const panelLayout = this.getPanelLayoutWrapper(); + const panel = panelLayout.findPanelContent(); + if (!panel) { + return null; + } + + const element = await this.browser.$(panel.toSelector()).parentElement(); + const size = await element.getCSSProperty('inline-size'); + return size.value; + } + + async resizePanelLayout(deltaX: number) { + const panelLayout = this.getPanelLayoutWrapper(); + const handle = panelLayout.findResizeHandle(); + if (!handle) { + throw new Error('Resize handle not found'); + } + + const handleElement = await this.browser.$(handle.toSelector()); + await handleElement.dragAndDrop({ x: deltaX, y: 0 }); + } + + async focusPanelButton() { + const panelLayout = this.getPanelLayoutWrapper(); + const panelButton = panelLayout.findPanelContent().findButton(); + await this.click(panelButton.toSelector()); + } + + async focusMainContentButton() { + const panelLayout = this.getPanelLayoutWrapper(); + const contentButton = panelLayout.findMainContent().findButton(); + await this.click(contentButton.toSelector()); + } + + isPanelButtonFocused() { + const panelLayout = this.getPanelLayoutWrapper(); + const panelButton = panelLayout.findPanelContent().findButton(); + return this.isFocused(panelButton.toSelector()); + } + + isMainContentButtonFocused() { + const panelLayout = this.getPanelLayoutWrapper(); + const contentButton = panelLayout.findMainContent().findButton(); + return this.isFocused(contentButton.toSelector()); + } + + isResizeHandleFocused() { + const panelLayout = this.getPanelLayoutWrapper(); + const handle = panelLayout.findResizeHandle(); + if (!handle) { + return false; + } + return this.isFocused(handle.toSelector()); + } +} + +describe('PanelLayout in App Layout Panel', () => { + const setupTest = ( + params: Record = {}, + testFn: (page: PanelLayoutAppLayoutPage) => Promise + ) => { + return useBrowser(async browser => { + const page = new PanelLayoutAppLayoutPage(browser); + await page.setWindowSize({ width: 1800, height: 800 }); + await page.visit(params); + await page.openDrawer(); + await testFn(page); + }); + }; + + test( + 'displays panel and main content with proper headers', + setupTest({}, async page => { + const panelLayout = page.getPanelLayoutWrapper(); + const panel = panelLayout.findPanelContent(); + const content = panelLayout.findMainContent(); + + await expect(page.getText(panel.findHeader().toSelector())).resolves.toContain('Panel content'); + await expect(page.getText(content.findHeader().toSelector())).resolves.toContain('Main content'); + }) + ); + + test( + 'applies default panel size correctly', + setupTest({ minPanelSize: 250 }, async page => { + const panelSize = await page.getPanelLayoutPanelSize(); + expect(panelSize).toBe('250px'); + }) + ); + + test( + 'respects maximum panel size constraint', + setupTest({ minPanelSize: 200, maxPanelSize: 300 }, async page => { + await page.resizePanelLayout(200); + const panelSize = await page.getPanelLayoutPanelSize(); + expect(parseInt(panelSize!)).toBe(300); + }) + ); + + test( + 'respects minimum panel size constraint', + setupTest({ minPanelSize: 100 }, async page => { + await page.resizePanelLayout(-100); + const panelSize = await page.getPanelLayoutPanelSize(); + expect(parseInt(panelSize!)).toBe(100); + }) + ); + + describe('panelPosition: side-start (default)', () => { + test( + 'focuses elements in order: panel content -> resize handle -> main content when tabbing forward', + setupTest({ panelPosition: 'side-start' }, async page => { + await page.focusPanelButton(); + + await page.keys(['Tab']); + await expect(page.isResizeHandleFocused()).resolves.toBe(true); + + await page.keys(['Tab']); + await expect(page.isMainContentButtonFocused()).resolves.toBe(true); + }) + ); + }); + + describe('panelPosition: side-end', () => { + test( + 'focuses elements in order: main content -> resize handle -> panel content when tabbing forward', + setupTest({ panelPosition: 'side-end' }, async page => { + await page.focusMainContentButton(); + + await page.keys(['Tab']); + await expect(page.isResizeHandleFocused()).resolves.toBe(true); + + await page.keys(['Tab']); + await expect(page.isPanelButtonFocused()).resolves.toBe(true); + }) + ); + }); +}); diff --git a/src/panel-layout/__tests__/panel-layout.test.tsx b/src/panel-layout/__tests__/panel-layout.test.tsx new file mode 100644 index 0000000000..73536b0f5a --- /dev/null +++ b/src/panel-layout/__tests__/panel-layout.test.tsx @@ -0,0 +1,741 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import { useResize } from '../../../lib/components/app-layout/visual-refresh-toolbar/drawer/use-resize'; +import useContainerWidth from '../../../lib/components/internal/utils/use-container-width'; +import PanelLayout, { PanelLayoutProps } from '../../../lib/components/panel-layout'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +// Mock the useContainerWidth hook +jest.mock('../../../lib/components/internal/utils/use-container-width', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Mock the useResize hook +jest.mock('../../../lib/components/app-layout/visual-refresh-toolbar/drawer/use-resize', () => ({ + useResize: jest.fn(), +})); + +function renderPanelLayout(props: Partial = {}) { + const ref = React.createRef(); + const defaultProps: PanelLayoutProps = { + panelContent: 'Panel content', + mainContent: 'Main content', + ...props, + }; + const renderResult = render(); + const wrapper = createWrapper(renderResult.container).findPanelLayout()!; + return { wrapper, ref }; +} + +const CONTAINER_WIDTH = 800; + +describe('PanelLayout Component', () => { + const mockUseContainerWidth = useContainerWidth as jest.MockedFunction; + const mockUseResize = useResize as jest.MockedFunction; + + beforeEach(() => { + // Mock useContainerWidth to return a width of 800 and a mock ref + mockUseContainerWidth.mockReturnValue([CONTAINER_WIDTH, React.createRef()]); + + // Reset useResize mock + mockUseResize.mockReturnValue({ + onKeyDown: jest.fn(), + onDirectionClick: jest.fn(), + onPointerDown: jest.fn(), + relativeSize: 50, + }); + }); + + describe('Basic rendering', () => { + test('renders main content and panel content', () => { + const { wrapper } = renderPanelLayout({ + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + expect(wrapper.findMainContent()!.getElement()).toHaveTextContent('Test main content'); + expect(wrapper.findPanelContent()!.getElement()).toHaveTextContent('Test panel content'); + }); + + test('renders without resize handle when not resizable', () => { + const { wrapper } = renderPanelLayout({ resizable: false }); + + expect(wrapper.findResizeHandle()).toBeNull(); + }); + + test('renders with resize handle when resizable', () => { + const { wrapper } = renderPanelLayout({ resizable: true }); + + expect(wrapper.findResizeHandle()).not.toBeNull(); + }); + }); + + describe('Panel sizing', () => { + test('applies default panel size when no size specified', () => { + const { wrapper } = renderPanelLayout(); + const panel = wrapper.findPanelContent()!.getElement().parentElement; + + expect(panel).toHaveStyle('inline-size: 200px'); + }); + + test('applies defaultPanelSize in uncontrolled mode', () => { + const { wrapper } = renderPanelLayout({ defaultPanelSize: 300 }); + const panel = wrapper.findPanelContent()!.getElement().parentElement; + + expect(panel).toHaveStyle('inline-size: 300px'); + }); + + test('applies panelSize in controlled mode', () => { + const { wrapper } = renderPanelLayout({ panelSize: 250, onPanelResize: () => {} }); + const panel = wrapper.findPanelContent()!.getElement().parentElement; + + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('uses minPanelSize when defaultPanelSize is not provided', () => { + const { wrapper } = renderPanelLayout({ minPanelSize: 150 }); + const panel = wrapper.findPanelContent()!.getElement().parentElement; + + expect(panel).toHaveStyle('inline-size: 150px'); + }); + }); + + describe('Accessibility', () => { + test('resize handle has proper aria attributes', () => { + const { wrapper } = renderPanelLayout({ + resizable: true, + i18nStrings: { resizeHandleAriaLabel: 'Resize handle' }, + }); + const handle = wrapper.findResizeHandle(); + + expect(handle).not.toBeNull(); + expect(handle!.getElement()).toHaveAttribute('aria-label', 'Resize handle'); + }); + }); + + describe('focusHandle', () => { + test('focuses the resize handle when resizable is true', () => { + const { wrapper, ref } = renderPanelLayout({ resizable: true }); + const handle = wrapper.findResizeHandle(); + + expect(handle).not.toBeNull(); + expect(document.activeElement).not.toBe(handle!.getElement()); + + ref.current!.focusResizeHandle(); + + expect(document.activeElement).toBe(handle!.getElement()); + }); + + test('does not throw error when resizable is false', () => { + const { ref } = renderPanelLayout({ resizable: false }); + + expect(() => { + ref.current!.focusResizeHandle(); + }).not.toThrow(); + }); + + test('does nothing when resizable is false', () => { + const { wrapper, ref } = renderPanelLayout({ resizable: false }); + const originalActiveElement = document.activeElement; + + expect(wrapper.findResizeHandle()).toBeNull(); + + ref.current!.focusResizeHandle(); + + expect(document.activeElement).toBe(originalActiveElement); + }); + }); + + describe('Display property', () => { + test('renders both content and panel when display is "all" (default)', () => { + const { wrapper } = renderPanelLayout({ + display: 'all', + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); + + expect(content).not.toBeNull(); + expect(panel).not.toBeNull(); + expect(content!.getElement()).toHaveTextContent('Test main content'); + expect(panel!.getElement()).toHaveTextContent('Test panel content'); + }); + + test('defaults to "all" display when no display prop provided', () => { + const { wrapper } = renderPanelLayout({ + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); + + expect(content).not.toBeNull(); + expect(panel).not.toBeNull(); + }); + + test('hides main content when display is "panel-only"', () => { + const { wrapper } = renderPanelLayout({ + display: 'panel-only', + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); + + expect(content).toBeNull(); + expect(panel).not.toBeNull(); + }); + + test('hides panel when display is "main-only"', () => { + const { wrapper } = renderPanelLayout({ + display: 'main-only', + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); + + expect(content).not.toBeNull(); + expect(panel).toBeNull(); + }); + + test('does not render resize handle when display is "panel-only"', () => { + const { wrapper } = renderPanelLayout({ + display: 'panel-only', + resizable: true, + }); + + expect(wrapper.findResizeHandle()).toBeNull(); + }); + + test('does not render resize handle when display is "main-only"', () => { + const { wrapper } = renderPanelLayout({ + display: 'main-only', + resizable: true, + }); + + expect(wrapper.findResizeHandle()).toBeNull(); + }); + + test('does not apply panel sizing when display is not "all"', () => { + const { wrapper } = renderPanelLayout({ + display: 'panel-only', + panelSize: 300, + onPanelResize: () => {}, + }); + + expect(wrapper.findPanelContent()!.getElement().parentElement).not.toHaveStyle('inline-size: 300px'); + }); + }); + + describe('useResize hook integration', () => { + beforeEach(() => { + mockUseResize.mockClear(); + }); + + test('passes correct parameters to useResize hook', () => { + renderPanelLayout({ + resizable: true, + panelSize: 250, + minPanelSize: 100, + maxPanelSize: 500, + }); + + expect(mockUseResize).toHaveBeenCalledWith( + expect.objectContaining({ + currentWidth: 250, + minWidth: 100, + maxWidth: 500, + position: 'side-start', + }) + ); + }); + + test('calls onResize callback with expected details', () => { + const onResizeMock = jest.fn(); + let capturedOnResize: ((size: number) => void) | undefined; + + // Mock useResize to capture the onResize callback passed to it + mockUseResize.mockImplementation(({ onResize }) => { + capturedOnResize = onResize; + return { + onKeyDown: jest.fn(), + onDirectionClick: jest.fn(), + onPointerDown: jest.fn(), + relativeSize: 50, + }; + }); + + renderPanelLayout({ + resizable: true, + onPanelResize: onResizeMock, + panelSize: 300, + }); + + capturedOnResize!(350); + + expect(onResizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { totalSize: CONTAINER_WIDTH, panelSize: 350 }, + }) + ); + }); + }); + + describe('Panel size bounds handling', () => { + describe('panelSize (controlled mode)', () => { + test('clamps panelSize below minPanelSize to minPanelSize', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 50, + minPanelSize: 150, + maxPanelSize: 500, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 150px'); + }); + + test('clamps panelSize above maxPanelSize to maxPanelSize', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 600, + minPanelSize: 100, + maxPanelSize: 400, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 400px'); + }); + + test('clamps panelSize above container width to container width', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 1000, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle(`inline-size: ${CONTAINER_WIDTH}px`); + }); + + test('clamps panelSize when both above maxPanelSize and container width', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 1000, + maxPanelSize: 600, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + // Should clamp to maxPanelSize (600), which is less than containerWidth (800) + expect(panel).toHaveStyle('inline-size: 600px'); + }); + + test('uses panelSize within bounds without clamping', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 250, + minPanelSize: 100, + maxPanelSize: 400, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('clamps panelSize below minPanelSize when minPanelSize equals maxPanelSize', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 100, + minPanelSize: 300, + maxPanelSize: 300, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 300px'); + }); + }); + + describe('defaultPanelSize (uncontrolled mode)', () => { + test('clamps defaultPanelSize below minPanelSize to minPanelSize', () => { + const { wrapper } = renderPanelLayout({ + defaultPanelSize: 50, + minPanelSize: 150, + maxPanelSize: 500, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 150px'); + }); + + test('clamps defaultPanelSize above maxPanelSize to maxPanelSize', () => { + const { wrapper } = renderPanelLayout({ + defaultPanelSize: 600, + minPanelSize: 100, + maxPanelSize: 400, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 400px'); + }); + + test('clamps defaultPanelSize above container width to container width', () => { + const { wrapper } = renderPanelLayout({ + defaultPanelSize: 1000, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle(`inline-size: ${CONTAINER_WIDTH}px`); + }); + + test('clamps defaultPanelSize when both above maxPanelSize and container width', () => { + const { wrapper } = renderPanelLayout({ + defaultPanelSize: 1000, + maxPanelSize: 600, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + // Should clamp to maxPanelSize (600), which is less than containerWidth (800) + expect(panel).toHaveStyle('inline-size: 600px'); + }); + + test('uses defaultPanelSize within bounds without clamping', () => { + const { wrapper } = renderPanelLayout({ + defaultPanelSize: 250, + minPanelSize: 100, + maxPanelSize: 400, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('clamps defaultPanelSize when minPanelSize equals maxPanelSize', () => { + const { wrapper } = renderPanelLayout({ + defaultPanelSize: 100, + minPanelSize: 300, + maxPanelSize: 300, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 300px'); + }); + }); + + describe('edge cases with missing min/max bounds', () => { + test('clamps panelSize when only maxPanelSize is provided', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 600, + maxPanelSize: 400, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 400px'); + }); + + test('clamps panelSize when only minPanelSize is provided', () => { + const { wrapper } = renderPanelLayout({ + panelSize: 50, + minPanelSize: 150, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 150px'); + }); + + test('uses defaultPanelSize when only maxPanelSize is provided and size is within bounds', () => { + const { wrapper } = renderPanelLayout({ + defaultPanelSize: 250, + maxPanelSize: 400, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('clamps negative panelSize to minPanelSize when provided', () => { + const { wrapper } = renderPanelLayout({ + panelSize: -50, + minPanelSize: 100, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 100px'); + }); + + test('clamps negative panelSize to 0 when minPanelSize is not provided', () => { + const { wrapper } = renderPanelLayout({ + panelSize: -50, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement().parentElement; + expect(panel).toHaveStyle('inline-size: 0px'); + }); + }); + + describe('useResize hook receives clamped values', () => { + test('passes clamped panelSize to useResize when below minPanelSize', () => { + mockUseResize.mockClear(); + + renderPanelLayout({ + resizable: true, + panelSize: 50, + minPanelSize: 150, + maxPanelSize: 500, + onPanelResize: () => {}, + }); + + expect(mockUseResize).toHaveBeenCalledWith( + expect.objectContaining({ + currentWidth: 150, + minWidth: 150, + maxWidth: 500, + }) + ); + }); + + test('passes clamped panelSize to useResize when above maxPanelSize', () => { + mockUseResize.mockClear(); + + renderPanelLayout({ + resizable: true, + panelSize: 600, + minPanelSize: 100, + maxPanelSize: 400, + onPanelResize: () => {}, + }); + + expect(mockUseResize).toHaveBeenCalledWith( + expect.objectContaining({ + currentWidth: 400, + minWidth: 100, + maxWidth: 400, + }) + ); + }); + + test('passes clamped defaultPanelSize to useResize when below minPanelSize', () => { + mockUseResize.mockClear(); + + renderPanelLayout({ + resizable: true, + defaultPanelSize: 50, + minPanelSize: 150, + maxPanelSize: 500, + }); + + expect(mockUseResize).toHaveBeenCalledWith( + expect.objectContaining({ + currentWidth: 150, + minWidth: 150, + maxWidth: 500, + }) + ); + }); + + test('passes clamped defaultPanelSize to useResize when above maxPanelSize', () => { + mockUseResize.mockClear(); + + renderPanelLayout({ + resizable: true, + defaultPanelSize: 600, + minPanelSize: 100, + maxPanelSize: 400, + }); + + expect(mockUseResize).toHaveBeenCalledWith( + expect.objectContaining({ + currentWidth: 400, + minWidth: 100, + maxWidth: 400, + }) + ); + }); + }); + }); + + describe('panelFocusable', () => { + test('makes panel content focusable with ariaLabel', () => { + const { wrapper } = renderPanelLayout({ + panelFocusable: { ariaLabel: 'Panel region' }, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveAttribute('tabindex', '0'); + expect(panel).toHaveAttribute('role', 'region'); + expect(panel).toHaveAttribute('aria-label', 'Panel region'); + }); + + test('makes panel content focusable with ariaLabelledby', () => { + const { wrapper } = renderPanelLayout({ + panelFocusable: { ariaLabelledby: 'panel-header-id' }, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveAttribute('tabindex', '0'); + expect(panel).toHaveAttribute('role', 'region'); + expect(panel).toHaveAttribute('aria-labelledby', 'panel-header-id'); + }); + + test('does not make panel content focusable when panelFocusable is not provided', () => { + const { wrapper } = renderPanelLayout(); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).not.toHaveAttribute('tabindex'); + expect(panel).not.toHaveAttribute('role'); + expect(panel).not.toHaveAttribute('aria-label'); + expect(panel).not.toHaveAttribute('aria-labelledby'); + }); + }); + + describe('mainFocusable', () => { + test('makes main content focusable with ariaLabel', () => { + const { wrapper } = renderPanelLayout({ + mainFocusable: { ariaLabel: 'Main region' }, + }); + + const content = wrapper.findMainContent()!.getElement(); + expect(content).toHaveAttribute('tabindex', '0'); + expect(content).toHaveAttribute('role', 'region'); + expect(content).toHaveAttribute('aria-label', 'Main region'); + }); + + test('makes main content focusable with ariaLabelledby', () => { + const { wrapper } = renderPanelLayout({ + mainFocusable: { ariaLabelledby: 'main-header-id' }, + }); + + const content = wrapper.findMainContent()!.getElement(); + expect(content).toHaveAttribute('tabindex', '0'); + expect(content).toHaveAttribute('role', 'region'); + expect(content).toHaveAttribute('aria-labelledby', 'main-header-id'); + }); + + test('does not make main content focusable when mainFocusable is not provided', () => { + const { wrapper } = renderPanelLayout(); + + const content = wrapper.findMainContent()!.getElement(); + expect(content).not.toHaveAttribute('tabindex'); + expect(content).not.toHaveAttribute('role'); + expect(content).not.toHaveAttribute('aria-label'); + expect(content).not.toHaveAttribute('aria-labelledby'); + }); + }); + + describe('onLayoutChange', () => { + test('is called when container width changes', () => { + const onLayoutChange = jest.fn(); + + const { rerender } = render( + + ); + + // Simulate container width change + mockUseContainerWidth.mockReturnValue([1000, React.createRef()]); + rerender(); + + expect(onLayoutChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { totalSize: 1000, panelSize: 200 }, + }) + ); + }); + + test('is called when panelSize prop changes in controlled mode', () => { + const onLayoutChange = jest.fn(); + const onPanelResize = jest.fn(); + + const { rerender } = render( + + ); + + // Change panelSize prop + rerender( + + ); + + expect(onLayoutChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { totalSize: CONTAINER_WIDTH, panelSize: 400 }, + }) + ); + }); + + test('is called when user resizes the panel', () => { + const onLayoutChange = jest.fn(); + let newPanelSize = 300; + const onPanelResize = jest.fn(event => { + newPanelSize = event.detail.panelSize; + }); + let capturedOnResize: ((size: number) => void) | undefined; + + mockUseResize.mockImplementation(({ onResize }) => { + capturedOnResize = onResize; + return { + onKeyDown: jest.fn(), + onDirectionClick: jest.fn(), + onPointerDown: jest.fn(), + relativeSize: 50, + }; + }); + + const { rerender } = render( + + ); + + // Simulate user resize - this triggers onPanelResize + act(() => { + capturedOnResize!(350); + }); + + // In controlled mode, parent would update panelSize prop + rerender( + + ); + + expect(onLayoutChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { totalSize: CONTAINER_WIDTH, panelSize: 350 }, + }) + ); + }); + }); +}); diff --git a/src/panel-layout/index.tsx b/src/panel-layout/index.tsx new file mode 100644 index 0000000000..fac6400310 --- /dev/null +++ b/src/panel-layout/index.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { getExternalProps } from '../internal/utils/external-props'; +import { PanelLayoutProps } from './interfaces'; +import InternalPanelLayout from './internal'; + +export { PanelLayoutProps }; + +const PanelLayout = React.forwardRef(function PanelLayout( + { panelPosition = 'side-start', resizable = false, display = 'all', ...props }, + ref +) { + const baseComponentProps = useBaseComponent('PanelLayout', { + props: { panelPosition, resizable }, + metadata: { + mainFocusable: props.mainFocusable !== undefined, + panelFocusable: props.panelFocusable !== undefined, + hasDefaultPanelSize: props.defaultPanelSize !== undefined, + hasPanelSize: props.panelSize !== undefined, + hasMinPanelSize: props.minPanelSize !== undefined, + hasMaxPanelSize: props.maxPanelSize !== undefined, + }, + }); + return ( + + ); +}); + +export default PanelLayout; + +applyDisplayName(PanelLayout, 'PanelLayout'); diff --git a/src/panel-layout/interfaces.ts b/src/panel-layout/interfaces.ts new file mode 100644 index 0000000000..5d039cd623 --- /dev/null +++ b/src/panel-layout/interfaces.ts @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ReactNode } from 'react'; + +import { BaseComponentProps } from '../internal/base-component'; +import { NonCancelableEventHandler } from '../internal/events'; + +export interface PanelLayoutProps extends BaseComponentProps { + /** + * Position of the panel with respect to the main content + */ + panelPosition?: PanelLayoutProps.PanelPosition; + + /** + * Initial panel size, for uncontrolled behavior. + * + * The actual size will be constrained by `minPanelSize` and `maxPanelSize`, if set. + */ + defaultPanelSize?: number; + + /** + * Size of the panel. If provided, and panel is resizable, the component is controlled, + * so you must also provide `onPanelResize`. + * + * The actual size will be constrained by `minPanelSize` and `maxPanelSize`, if set. + */ + panelSize?: number; + + /** + * The minimum size of the panel. + */ + minPanelSize?: number; + + /** + * The maximum size of the panel. + */ + maxPanelSize?: number; + + /** + * Indicates whether the panel is resizable. + */ + resizable?: boolean; + + /** + * Panel contents. + */ + panelContent: ReactNode; + + /** + * Main content area displayed next to the panel. + */ + mainContent: ReactNode; + + /** + * Makes the panel content focusable. This should be used if there are no focusable elements + * inside the panel, to ensure it is keyboard scrollable. + * + * Provide either `{ariaLabel: "Panel label"}` or `{ariaLabelledby: "panel-label-id"}` + */ + panelFocusable?: PanelLayoutProps.FocusableConfig; + + /** + * Makes the main content area focusable. This should be used if there are no focusable elements + * inside the content, to ensure it is keyboard scrollable. + * + * Provide either `{ariaLabel: "Main label"}` or `{ariaLabelledby: "main-label-id"}` + */ + mainFocusable?: PanelLayoutProps.FocusableConfig; + + /** + * Determines which content is displayed: + * - 'all': Both panel and main content are displayed. + * - 'panel-only': Only panel is displayed. + * - 'main-only': Only main content is displayed. + */ + display?: PanelLayoutProps.Display; + + /** + * An object containing all the necessary localized strings required by the component. + * @i18n + */ + i18nStrings?: PanelLayoutProps.I18nStrings; + + /** + * Called when the user resizes the panel. + */ + onPanelResize?: NonCancelableEventHandler; + + /** + * Called when the panel and/or main content size changes. This can be due + * to user resizing or changes to the available space on the page. + */ + onLayoutChange?: NonCancelableEventHandler; +} + +export namespace PanelLayoutProps { + export type PanelPosition = 'side-start' | 'side-end'; + export type Display = 'all' | 'panel-only' | 'main-only'; + + export interface PanelResizeDetail { + totalSize: number; + panelSize: number; + } + + export interface FocusableConfig { + ariaLabel?: string; + ariaLabelledby?: string; + } + + export interface I18nStrings { + resizeHandleAriaLabel?: string; + resizeHandleTooltipText?: string; + } + + export interface Ref { + /** + * Focuses the resize handle of the panel layout. + */ + focusResizeHandle(): void; + } +} diff --git a/src/panel-layout/internal.tsx b/src/panel-layout/internal.tsx new file mode 100644 index 0000000000..1fc71027ac --- /dev/null +++ b/src/panel-layout/internal.tsx @@ -0,0 +1,162 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect } from 'react'; +import clsx from 'clsx'; + +import { useMergeRefs, useStableCallback } from '@cloudscape-design/component-toolkit/internal'; + +import { useResize } from '../app-layout/visual-refresh-toolbar/drawer/use-resize'; +import { getBaseProps } from '../internal/base-component'; +import PanelResizeHandle from '../internal/components/panel-resize-handle'; +import { fireNonCancelableEvent } from '../internal/events'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useControllable } from '../internal/hooks/use-controllable'; +import { SomeRequired } from '../internal/types'; +import useContainerWidth from '../internal/utils/use-container-width'; +import { PanelLayoutProps } from './interfaces'; + +import styles from './styles.css.js'; +import testStyles from './test-classes/styles.css.js'; + +const DEFAULT_PANEL_SIZE = 200; + +type InternalPanelLayoutProps = SomeRequired & + InternalBaseComponentProps; + +const InternalPanelLayout = React.forwardRef( + ( + { + panelPosition, + panelContent, + mainContent, + defaultPanelSize, + panelSize: controlledPanelSize, + resizable, + onPanelResize, + onLayoutChange, + minPanelSize, + maxPanelSize, + i18nStrings, + display, + panelFocusable, + mainFocusable, + __internalRootRef, + ...props + }, + ref + ) => { + const baseProps = getBaseProps(props); + + const resizeHandleRef = React.useRef(null); + const panelRef = React.useRef(null); + const [containerWidth, rootRef] = useContainerWidth(); + + React.useImperativeHandle( + ref, + () => ({ + focusResizeHandle() { + resizeHandleRef.current?.focus(); + }, + }), + [] + ); + + const [panelSize = DEFAULT_PANEL_SIZE, setPanelSize] = useControllable( + controlledPanelSize, + onPanelResize, + defaultPanelSize ?? minPanelSize, + { + componentName: 'PanelLayout', + controlledProp: 'panelSize', + changeHandler: 'onPanelResize', + } + ); + + const actualMaxSize = Math.min(maxPanelSize ?? containerWidth, containerWidth); + const actualMinSize = minPanelSize ?? 0; + const actualPanelSize = Math.max(Math.min(panelSize, actualMaxSize), actualMinSize); + + const stableLayoutChangeEvent = useStableCallback((details: PanelLayoutProps.PanelResizeDetail) => + fireNonCancelableEvent(onLayoutChange, details) + ); + useEffect(() => { + stableLayoutChangeEvent({ totalSize: containerWidth, panelSize: actualPanelSize }); + }, [containerWidth, actualPanelSize, stableLayoutChangeEvent]); + + const resizeHandlePosition = panelPosition === 'side-end' ? 'side' : panelPosition; + const resizeProps = useResize({ + currentWidth: actualPanelSize, + minWidth: actualMinSize, + maxWidth: actualMaxSize, + panelRef: panelRef, + handleRef: resizeHandleRef, + position: resizeHandlePosition, + onResize: size => { + setPanelSize(size); + fireNonCancelableEvent(onPanelResize, { totalSize: containerWidth, panelSize: size }); + }, + }); + + const mergedRef = useMergeRefs(rootRef, __internalRootRef, ref); + + const wrappedPanelContent = ( +
+ {panelContent} +
+ ); + const wrappedMainContent = ( +
+ {mainContent} +
+ ); + const handle = ( +
+ +
+ ); + + return ( +
+ {panelPosition === 'side-end' && wrappedMainContent} +
+ {panelPosition === 'side-start' && wrappedPanelContent} + {resizable && display === 'all' && handle} + {panelPosition === 'side-end' && wrappedPanelContent} +
+ {panelPosition === 'side-start' && wrappedMainContent} +
+ ); + } +); + +export default InternalPanelLayout; diff --git a/src/panel-layout/styles.scss b/src/panel-layout/styles.scss new file mode 100644 index 0000000000..c58f9f8158 --- /dev/null +++ b/src/panel-layout/styles.scss @@ -0,0 +1,63 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; +@use '../internal/styles' as styles; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; + +// Our standard focus rings don't work for this use-case because the element +// is scrollable. This renders a ring inside the element, that is sticky and +// overlays the content and scrollbars as expected. +@mixin container-inner-focus-ring { + $width: 2px; + border-start-start-radius: calc(#{awsui.$border-radius-control-default-focus-ring} + #{$width}); + border-start-end-radius: calc(#{awsui.$border-radius-control-default-focus-ring} + #{$width}); + border-end-start-radius: calc(#{awsui.$border-radius-control-default-focus-ring} + #{$width}); + border-end-end-radius: calc(#{awsui.$border-radius-control-default-focus-ring} + #{$width}); + outline: $width solid awsui.$color-border-item-focused; + outline-offset: calc(-1 * #{$width}); +} + +.root { + @include styles.styles-reset; + block-size: 100%; + overflow: hidden; + display: flex; +} + +.panel { + display: flex; + flex-shrink: 0; + > .handle { + display: flex; + align-items: center; + } + > .panel-content { + overflow-y: auto; + overflow-x: visible; + flex-grow: 1; + @include focus-visible.when-visible { + @include container-inner-focus-ring; + } + } + .display-main-only > & { + display: none; + } + .display-panel-only > & { + flex: 1; + } +} + +.content { + overflow-y: auto; + flex-grow: 1; + flex-shrink: 1; + .display-panel-only > & { + display: none; + } + @include focus-visible.when-visible { + @include container-inner-focus-ring; + } +} diff --git a/src/panel-layout/test-classes/styles.scss b/src/panel-layout/test-classes/styles.scss new file mode 100644 index 0000000000..d7bfe2f491 --- /dev/null +++ b/src/panel-layout/test-classes/styles.scss @@ -0,0 +1,11 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.root, +.panel, +.content, +.resize-handle { + /* used in test-utils */ +} diff --git a/src/test-utils/dom/panel-layout/index.ts b/src/test-utils/dom/panel-layout/index.ts new file mode 100644 index 0000000000..6748b4083d --- /dev/null +++ b/src/test-utils/dom/panel-layout/index.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../panel-layout/test-classes/styles.selectors.js'; + +export default class PanelLayoutWrapper extends ComponentWrapper { + static rootSelector: string = styles.root; + + /** + * Returns the wrapper for the panel element. + */ + findPanelContent(): ElementWrapper | null { + return this.findByClassName(styles.panel); + } + + /** + * Returns the wrapper for the main content element. + */ + findMainContent(): ElementWrapper | null { + return this.findByClassName(styles.content); + } + + /** + * Returns the wrapper for the resize handle element. + * Returns null if the panel layout is not resizable. + */ + findResizeHandle(): ElementWrapper | null { + return this.findByClassName(styles['resize-handle']); + } +}