From dd220acc57a9e5d4825e5e85225cf951bbc7b5ad Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 5 Dec 2025 17:29:52 +0100 Subject: [PATCH 1/4] feat: Add split-view component --- build-tools/utils/pluralize.js | 1 + .../external-global-left-panel-widget.tsx | 71 +++- pages/split-view/app-layout-panel.page.tsx | 203 ++++++++++++ pages/split-view/nested.page.tsx | 159 +++++++++ .../__snapshots__/documenter.test.ts.snap | 261 +++++++++++++++ .../test-utils-selectors.test.tsx.snap | 6 + .../test-utils-wrappers.test.tsx.snap | 66 ++++ src/app-layout/utils/use-pointer-events.ts | 3 +- src/i18n/messages-types.ts | 4 + src/i18n/messages/all.en.json | 4 + .../components/panel-resize-handle/index.tsx | 6 +- .../__integ__/app-layout-panel.test.ts | 172 ++++++++++ src/split-view/__tests__/split-view.test.tsx | 306 ++++++++++++++++++ src/split-view/index.tsx | 41 +++ src/split-view/interfaces.ts | 102 ++++++ src/split-view/internal.tsx | 136 ++++++++ src/split-view/styles.scss | 51 +++ src/split-view/test-classes/styles.scss | 11 + src/test-utils/dom/split-view/index.ts | 31 ++ 19 files changed, 1624 insertions(+), 10 deletions(-) create mode 100644 pages/split-view/app-layout-panel.page.tsx create mode 100644 pages/split-view/nested.page.tsx create mode 100644 src/split-view/__integ__/app-layout-panel.test.ts create mode 100644 src/split-view/__tests__/split-view.test.tsx create mode 100644 src/split-view/index.tsx create mode 100644 src/split-view/interfaces.ts create mode 100644 src/split-view/internal.tsx create mode 100644 src/split-view/styles.scss create mode 100644 src/split-view/test-classes/styles.scss create mode 100644 src/test-utils/dom/split-view/index.ts diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index c3f11c3ed9..1eab15b6cb 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -69,6 +69,7 @@ const pluralizationMap = { SpaceBetween: 'SpaceBetweens', Spinner: 'Spinners', SplitPanel: 'SplitPanels', + SplitView: 'SplitViews', StatusIndicator: 'StatusIndicators', Steps: 'Steps', Table: 'Tables', 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..c8e4248885 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,49 @@ // 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 { useContainerQuery } from '@cloudscape-design/component-toolkit'; + +import { Box, Button, SplitView } 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, ref] = useContainerQuery(entry => entry.contentBoxWidth - MIN_ARTIFACT_SIZE); + const maxPanelSize = _maxPanelSize ?? Number.MAX_SAFE_INTEGER; + const constrainedChatSize = Math.min(chatSize, maxPanelSize); + const collapsed = constrainedChatSize < MIN_CHAT_SIZE; + + const chatContent = ( + <> Chat demo + + {artifactLoaded && new Array(100).fill(null).map((_, index) =>
Tela content
)}
); + return ( +
+ setChatSize(detail.panelSize)} + 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/split-view/app-layout-panel.page.tsx b/pages/split-view/app-layout-panel.page.tsx new file mode 100644 index 0000000000..054721c2e5 --- /dev/null +++ b/pages/split-view/app-layout-panel.page.tsx @@ -0,0 +1,203 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { useContainerQuery } from '@cloudscape-design/component-toolkit'; + +import { 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 SplitView, { SplitViewProps } from '~components/split-view'; + +import AppContext, { AppContextType } from '../app/app-context'; +import labels from '../app-layout/utils/labels'; +import ScreenshotArea from '../utils/screenshot-area'; + +interface SplitViewContentProps { + longPanelContent: boolean; + longMainContent: boolean; + minPanelSize: number; + maxPanelSize: number; + minContentSize: number; + display: SplitViewProps.Display; + panelPosition: SplitViewProps.PanelPosition; +} +type PageContext = React.Context>>; + +const SplitViewContent = ({ + longPanelContent, + longMainContent, + minContentSize, + minPanelSize, + maxPanelSize, + display, + panelPosition, +}: SplitViewContentProps) => { + const [size, setSize] = useState(Math.max(200, minPanelSize)); + + const [_actualMaxPanelSize, ref] = useContainerQuery( + entry => Math.min(entry.contentBoxWidth - minContentSize, maxPanelSize), + [minContentSize, maxPanelSize] + ); + const actualMaxPanelSize = _actualMaxPanelSize ?? maxPanelSize; + const actualSize = Math.min(size, actualMaxPanelSize); + + const collapsed = actualMaxPanelSize < minPanelSize; + + return ( +
+ {collapsed ? ( + 'Collapsed view' + ) : ( + setSize(detail.panelSize)} + display={display} + panelPosition={panelPosition} + panelContent={ + <> +
Panel content
+ {new Array(longPanelContent ? 20 : 1) + .fill('Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + .map((t, i) => ( +
{t}
+ ))} + + + } + mainContent={ + <> +
Main content
+ {new Array(longMainContent ? 200 : 1) + .fill('Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + .map((t, i) => ( +
{t}
+ ))} + + + } + /> + )} +
+ ); +}; + +export default function SplitViewPage() { + const { + urlParams: { + longPanelContent = false, + longMainContent = false, + 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({ 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={
Split view 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/split-view/nested.page.tsx b/pages/split-view/nested.page.tsx new file mode 100644 index 0000000000..58149b5503 --- /dev/null +++ b/pages/split-view/nested.page.tsx @@ -0,0 +1,159 @@ +// 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 { I18nProvider } from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import SplitView, { SplitViewProps } from '~components/split-view'; + +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; + +interface NestedSplitViewProps { + outerPanelPosition: SplitViewProps.PanelPosition; + innerPanelPosition: SplitViewProps.PanelPosition; + nestedLocation: 'panel' | 'main'; +} +type PageContext = React.Context>>; + +const NestedSplitViewDemo = ({ outerPanelPosition, innerPanelPosition, nestedLocation }: NestedSplitViewProps) => { + const innerSplitView = ( + Level 2 Panel}> +

This is a nested split view panel (second level).

+

You can resize this panel independently from the outer split view.

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

This is the main content area of the second split view.

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

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

+

It contains simple content without any nesting.

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

+ This page demonstrates nested split views, where one split view contains another split view in either + its panel or main content area. +

+

+ Each split view 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 f51f9bdadd..4e1ee7144d 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -24126,6 +24126,186 @@ use the \`id\` attribute, consider setting it on a parent element instead.", } `; +exports[`Components definition for split-view matches the snapshot: split-view 1`] = ` +{ + "dashCaseName": "split-view", + "events": [ + { + "cancelable": false, + "description": "Called when the user resizes the panel.", + "detailInlineType": { + "name": "SplitViewProps.PanelResizeDetail", + "properties": [ + { + "name": "panelSize", + "optional": false, + "type": "number", + }, + { + "name": "totalSize", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "detailType": "SplitViewProps.PanelResizeDetail", + "name": "onPanelResize", + }, + ], + "functions": [ + { + "description": "Focuses the resize handle of the split view.", + "name": "focusResizeHandle", + "parameters": [], + "returnType": "void", + }, + ], + "name": "SplitView", + "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 may vary, depending on \`minPanelSize\` and \`maxPanelSize\`.", + "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": "SplitViewProps.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": "SplitViewProps.I18nStrings", + "properties": [ + { + "name": "resizeHandleAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "resizeHandleTooltipText", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "SplitViewProps.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": "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", + }, + { + "defaultValue": "'side-start'", + "description": "Position of the panel with respect to the main content", + "inlineType": { + "name": "SplitViewProps.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 \`onResize\`. + +The actual size may vary, depending on \`minPanelSize\` and \`maxPanelSize\`.", + "name": "panelSize", + "optional": true, + "type": "number", + }, + { + "defaultValue": "'panel'", + "description": "Determines how the panel is styled: +- 'panel': Styled as a solid panel with border dividing it from main content. +- 'custom': No styling applied: add your own.", + "inlineType": { + "name": "SplitViewProps.PanelVariant", + "type": "union", + "values": [ + "custom", + "panel", + ], + }, + "name": "panelVariant", + "optional": true, + "type": "string", + }, + { + "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 status-indicator matches the snapshot: status-indicator 1`] = ` { "dashCaseName": "status-indicator", @@ -39460,6 +39640,54 @@ Returns the current value of the input.", "methods": [], "name": "SpinnerWrapper", }, + { + "methods": [ + { + "description": "Returns the wrapper for the main content element.", + "name": "findContent", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the wrapper for the panel element.", + "name": "findPanel", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the wrapper for the resize handle element. +Returns null if the split view is not resizable.", + "name": "findResizeHandle", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + ], + "name": "SplitViewWrapper", + }, { "methods": [], "name": "StatusIndicatorWrapper", @@ -48025,6 +48253,39 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from "methods": [], "name": "SpinnerWrapper", }, + { + "methods": [ + { + "description": "Returns the wrapper for the main content element.", + "name": "findContent", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the wrapper for the panel element.", + "name": "findPanel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the wrapper for the resize handle element. +Returns null if the split view is not resizable.", + "name": "findResizeHandle", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + ], + "name": "SplitViewWrapper", + }, { "methods": [], "name": "StatusIndicatorWrapper", 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 5daaf28ad8..034837d1bd 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 @@ -582,6 +582,12 @@ exports[`test-utils selectors 1`] = ` "awsui_root_rjqu5", "awsui_slider_rjqu5", ], + "split-view": [ + "awsui_content_cz4sm", + "awsui_panel_cz4sm", + "awsui_root_cz4sm", + "awsui_slider_cz4sm", + ], "status-indicator": [ "awsui_root_1cbgc", ], 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 9e433a47f5..64305189e1 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 @@ -76,6 +76,7 @@ import SliderWrapper from './slider'; import SpaceBetweenWrapper from './space-between'; import SpinnerWrapper from './spinner'; import SplitPanelWrapper from './split-panel'; +import SplitViewWrapper from './split-view'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; @@ -163,6 +164,7 @@ export { SliderWrapper }; export { SpaceBetweenWrapper }; export { SpinnerWrapper }; export { SplitPanelWrapper }; +export { SplitViewWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; @@ -1458,6 +1460,25 @@ findSplitPanel(selector?: string): SplitPanelWrapper | null; * @returns {Array} */ findAllSplitPanels(selector?: string): Array; +/** + * Returns the wrapper of the first SplitView that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first SplitView. + * If no matching SplitView is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {SplitViewWrapper | null} + */ +findSplitView(selector?: string): SplitViewWrapper | null; + +/** + * Returns an array of SplitView wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the SplitViews inside the current wrapper. + * If no matching SplitView is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllSplitViews(selector?: string): Array; /** * Returns the wrapper of the first StatusIndicator that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first StatusIndicator. @@ -2675,6 +2696,19 @@ ElementWrapper.prototype.findSplitPanel = function(selector) { ElementWrapper.prototype.findAllSplitPanels = function(selector) { return this.findAllComponents(SplitPanelWrapper, selector); }; +ElementWrapper.prototype.findSplitView = function(selector) { + let rootSelector = \`.\${SplitViewWrapper.rootSelector}\`; + if("legacyRootSelector" in SplitViewWrapper && SplitViewWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${SplitViewWrapper.rootSelector}, .\${SplitViewWrapper.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, SplitViewWrapper); +}; + +ElementWrapper.prototype.findAllSplitViews = function(selector) { + return this.findAllComponents(SplitViewWrapper, selector); +}; ElementWrapper.prototype.findStatusIndicator = function(selector) { let rootSelector = \`.\${StatusIndicatorWrapper.rootSelector}\`; if("legacyRootSelector" in StatusIndicatorWrapper && StatusIndicatorWrapper.legacyRootSelector){ @@ -2996,6 +3030,7 @@ import SliderWrapper from './slider'; import SpaceBetweenWrapper from './space-between'; import SpinnerWrapper from './spinner'; import SplitPanelWrapper from './split-panel'; +import SplitViewWrapper from './split-view'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; @@ -3083,6 +3118,7 @@ export { SliderWrapper }; export { SpaceBetweenWrapper }; export { SpinnerWrapper }; export { SplitPanelWrapper }; +export { SplitViewWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; @@ -4244,6 +4280,23 @@ findSplitPanel(selector?: string): SplitPanelWrapper; * @returns {MultiElementWrapper} */ findAllSplitPanels(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the SplitViews with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches SplitViews. + * + * @param {string} [selector] CSS Selector + * @returns {SplitViewWrapper} + */ +findSplitView(selector?: string): SplitViewWrapper; + +/** + * Returns a multi-element wrapper that matches SplitViews with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches SplitViews. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllSplitViews(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the StatusIndicators with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches StatusIndicators. @@ -5425,6 +5478,19 @@ ElementWrapper.prototype.findSplitPanel = function(selector) { ElementWrapper.prototype.findAllSplitPanels = function(selector) { return this.findAllComponents(SplitPanelWrapper, selector); }; +ElementWrapper.prototype.findSplitView = function(selector) { + let rootSelector = \`.\${SplitViewWrapper.rootSelector}\`; + if("legacyRootSelector" in SplitViewWrapper && SplitViewWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${SplitViewWrapper.rootSelector}, .\${SplitViewWrapper.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, SplitViewWrapper); +}; + +ElementWrapper.prototype.findAllSplitViews = function(selector) { + return this.findAllComponents(SplitViewWrapper, selector); +}; ElementWrapper.prototype.findStatusIndicator = function(selector) { let rootSelector = \`.\${StatusIndicatorWrapper.rootSelector}\`; if("legacyRootSelector" in StatusIndicatorWrapper && StatusIndicatorWrapper.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.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/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/split-view/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()); + } + } + + getSplitViewWrapper() { + return appLayoutWrapper.findActiveDrawer()!.findSplitView(); + } + + async getSplitViewPanelSize() { + const splitView = this.getSplitViewWrapper(); + const panel = splitView.findPanel(); + if (!panel) { + return null; + } + + const element = await this.browser.$(panel.toSelector()); + const size = await element.getCSSProperty('inline-size'); + return size.value; + } + + async resizeSplitView(deltaX: number) { + const splitView = this.getSplitViewWrapper(); + const handle = splitView.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 splitView = this.getSplitViewWrapper(); + const panelButton = splitView.findPanel().findButton(); + await this.click(panelButton.toSelector()); + } + + async focusMainContentButton() { + const splitView = this.getSplitViewWrapper(); + const contentButton = splitView.findContent().findButton(); + await this.click(contentButton.toSelector()); + } + + isPanelButtonFocused() { + const splitView = this.getSplitViewWrapper(); + const panelButton = splitView.findPanel().findButton(); + return this.isFocused(panelButton.toSelector()); + } + + isMainContentButtonFocused() { + const splitView = this.getSplitViewWrapper(); + const contentButton = splitView.findContent().findButton(); + return this.isFocused(contentButton.toSelector()); + } + + isResizeHandleFocused() { + const splitView = this.getSplitViewWrapper(); + const handle = splitView.findResizeHandle(); + if (!handle) { + return false; + } + return this.isFocused(handle.toSelector()); + } +} + +describe('SplitView in App Layout Panel', () => { + const setupTest = ( + params: Record = {}, + testFn: (page: SplitViewAppLayoutPage) => Promise + ) => { + return useBrowser(async browser => { + const page = new SplitViewAppLayoutPage(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 splitView = page.getSplitViewWrapper(); + const panel = splitView.findPanel(); + const content = splitView.findContent(); + + 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.getSplitViewPanelSize(); + expect(panelSize).toBe('250px'); + }) + ); + + test( + 'respects maximum panel size constraint', + setupTest({ minPanelSize: 200, maxPanelSize: 300 }, async page => { + await page.resizeSplitView(200); + const panelSize = await page.getSplitViewPanelSize(); + expect(parseInt(panelSize!)).toBe(300); + }) + ); + + test( + 'respects minimum panel size constraint', + setupTest({ minPanelSize: 100 }, async page => { + await page.resizeSplitView(-100); + const panelSize = await page.getSplitViewPanelSize(); + 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/split-view/__tests__/split-view.test.tsx b/src/split-view/__tests__/split-view.test.tsx new file mode 100644 index 0000000000..f23fc03c99 --- /dev/null +++ b/src/split-view/__tests__/split-view.test.tsx @@ -0,0 +1,306 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { 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 SplitView, { SplitViewProps } from '../../../lib/components/split-view'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/split-view/styles.css.js'; + +// 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 renderSplitView(props: Partial = {}) { + const ref = React.createRef(); + const defaultProps: SplitViewProps = { + panelContent: 'Panel content', + mainContent: 'Main content', + ...props, + }; + const renderResult = render(); + const wrapper = createWrapper(renderResult.container).findSplitView()!; + return { wrapper, ref }; +} + +const CONTAINER_WIDTH = 800; + +describe('SplitView 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 } = renderSplitView({ + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + expect(wrapper.findContent()!.getElement()).toHaveTextContent('Test main content'); + expect(wrapper.findPanel()!.getElement()).toHaveTextContent('Test panel content'); + }); + + test('renders without resize handle when not resizable', () => { + const { wrapper } = renderSplitView({ resizable: false }); + + expect(wrapper.findResizeHandle()).toBeNull(); + }); + + test('renders with resize handle when resizable', () => { + const { wrapper } = renderSplitView({ resizable: true }); + + expect(wrapper.findResizeHandle()).not.toBeNull(); + }); + }); + + describe('Panel sizing', () => { + test('applies default panel size when no size specified', () => { + const { wrapper } = renderSplitView(); + const panel = wrapper.findPanel()!.getElement(); + + expect(panel).toHaveStyle('inline-size: 200px'); + }); + + test('applies defaultPanelSize in uncontrolled mode', () => { + const { wrapper } = renderSplitView({ defaultPanelSize: 300 }); + const panel = wrapper.findPanel()!.getElement(); + + expect(panel).toHaveStyle('inline-size: 300px'); + }); + + test('applies panelSize in controlled mode', () => { + const { wrapper } = renderSplitView({ panelSize: 250, onPanelResize: () => {} }); + const panel = wrapper.findPanel()!.getElement(); + + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('uses minPanelSize when defaultPanelSize is not provided', () => { + const { wrapper } = renderSplitView({ minPanelSize: 150 }); + const panel = wrapper.findPanel()!.getElement(); + + expect(panel).toHaveStyle('inline-size: 150px'); + }); + }); + + describe('Panel variant', () => { + test('renders with panel variant by default', () => { + const { wrapper } = renderSplitView(); + + expect(wrapper.findPanel()!.getElement()).toHaveClass(styles['panel-variant-panel']); + }); + + test('renders with custom variant when specified', () => { + const { wrapper } = renderSplitView({ panelVariant: 'custom' }); + + expect(wrapper.findPanel()!.getElement()).not.toHaveClass(styles['panel-variant-panel']); + }); + }); + + describe('Accessibility', () => { + test('resize handle has proper aria attributes', () => { + const { wrapper } = renderSplitView({ 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 } = renderSplitView({ 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 } = renderSplitView({ resizable: false }); + + expect(() => { + ref.current!.focusResizeHandle(); + }).not.toThrow(); + }); + + test('does nothing when resizable is false', () => { + const { wrapper, ref } = renderSplitView({ 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 } = renderSplitView({ + display: 'all', + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findContent(); + const panel = wrapper.findPanel(); + + 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 } = renderSplitView({ + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findContent(); + const panel = wrapper.findPanel(); + + expect(content).not.toBeNull(); + expect(panel).not.toBeNull(); + }); + + test('hides main content when display is "panel-only"', () => { + const { wrapper } = renderSplitView({ + display: 'panel-only', + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findContent(); + const panel = wrapper.findPanel(); + + expect(content).toBeNull(); + expect(panel).not.toBeNull(); + }); + + test('hides panel when display is "main-only"', () => { + const { wrapper } = renderSplitView({ + display: 'main-only', + mainContent: 'Test main content', + panelContent: 'Test panel content', + }); + + const content = wrapper.findContent(); + const panel = wrapper.findPanel(); + + expect(content).not.toBeNull(); + expect(panel).toBeNull(); + }); + + test('does not render resize handle when display is "panel-only"', () => { + const { wrapper } = renderSplitView({ + display: 'panel-only', + resizable: true, + }); + + expect(wrapper.findResizeHandle()).toBeNull(); + }); + + test('does not render resize handle when display is "main-only"', () => { + const { wrapper } = renderSplitView({ + display: 'main-only', + resizable: true, + }); + + expect(wrapper.findResizeHandle()).toBeNull(); + }); + + test('does not apply panel sizing when display is not "all"', () => { + const { wrapper } = renderSplitView({ + display: 'panel-only', + panelSize: 300, + onPanelResize: () => {}, + }); + + expect(wrapper.findPanel()!.getElement()).not.toHaveStyle('inline-size: 300px'); + }); + }); + + describe('useResize hook integration', () => { + beforeEach(() => { + mockUseResize.mockClear(); + }); + + test('passes correct parameters to useResize hook', () => { + renderSplitView({ + 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, + }; + }); + + renderSplitView({ + resizable: true, + onPanelResize: onResizeMock, + panelSize: 300, + }); + + capturedOnResize!(350); + + expect(onResizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { totalSize: CONTAINER_WIDTH, panelSize: 350 }, + }) + ); + }); + }); +}); diff --git a/src/split-view/index.tsx b/src/split-view/index.tsx new file mode 100644 index 0000000000..6f6e67aa2c --- /dev/null +++ b/src/split-view/index.tsx @@ -0,0 +1,41 @@ +// 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 { SplitViewProps } from './interfaces'; +import InternalSplitView from './internal'; + +export { SplitViewProps }; + +const SplitView = React.forwardRef(function SplitView( + { panelPosition = 'side-start', resizable = false, panelVariant = 'panel', display = 'all', ...props }, + ref +) { + const baseComponentProps = useBaseComponent('SplitView', { + props: { panelPosition, resizable, panelVariant }, + metadata: { + hasDefaultSize: props.defaultPanelSize !== undefined, + hasSize: props.panelSize !== undefined, + hasMinSize: props.minPanelSize !== undefined, + hasMaxSize: props.maxPanelSize !== undefined, + }, + }); + return ( + + ); +}); + +export default SplitView; + +applyDisplayName(SplitView, 'SplitView'); diff --git a/src/split-view/interfaces.ts b/src/split-view/interfaces.ts new file mode 100644 index 0000000000..49afeb00bf --- /dev/null +++ b/src/split-view/interfaces.ts @@ -0,0 +1,102 @@ +// 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 SplitViewProps extends BaseComponentProps { + /** + * Position of the panel with respect to the main content + */ + panelPosition?: SplitViewProps.PanelPosition; + + /** + * Initial panel size, for uncontrolled behavior. + * + * The actual size may vary, depending on `minPanelSize` and `maxPanelSize`. + */ + defaultPanelSize?: number; + + /** + * Size of the panel. If provided, and panel is resizable, the component is controlled, + * so you must also provide `onResize`. + * + * The actual size may vary, depending on `minPanelSize` and `maxPanelSize`. + */ + 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; + + /** + * Determines how the panel is styled: + * - 'panel': Styled as a solid panel with border dividing it from main content. + * - 'custom': No styling applied: add your own. + */ + panelVariant?: SplitViewProps.PanelVariant; + + /** + * Panel contents. + */ + panelContent: ReactNode; + + /** + * Main content area displayed next to the panel. + */ + mainContent: ReactNode; + + /** + * 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?: SplitViewProps.Display; + + /** + * An object containing all the necessary localized strings required by the component. + * @i18n + */ + i18nStrings?: SplitViewProps.I18nStrings; + + /** + * Called when the user resizes the panel. + */ + onPanelResize?: NonCancelableEventHandler; +} + +export namespace SplitViewProps { + export type PanelPosition = 'side-start' | 'side-end'; + export type PanelVariant = 'panel' | 'custom'; + export type Display = 'all' | 'panel-only' | 'main-only'; + + export interface PanelResizeDetail { + totalSize: number; + panelSize: number; + } + + export interface I18nStrings { + resizeHandleAriaLabel?: string; + resizeHandleTooltipText?: string; + } + + export interface Ref { + /** + * Focuses the resize handle of the split view. + */ + focusResizeHandle(): void; + } +} diff --git a/src/split-view/internal.tsx b/src/split-view/internal.tsx new file mode 100644 index 0000000000..c2be90b13e --- /dev/null +++ b/src/split-view/internal.tsx @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { useMergeRefs } 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 { SplitViewProps } from './interfaces'; + +import styles from './styles.css.js'; +import testStyles from './test-classes/styles.css.js'; + +const DEFAULT_PANEL_SIZE = 200; + +type InternalSplitViewProps = SomeRequired & + InternalBaseComponentProps; + +const InternalSplitView = React.forwardRef( + ( + { + panelPosition, + panelContent, + mainContent, + defaultPanelSize, + panelSize: controlledPanelSize, + resizable, + panelVariant, + onPanelResize, + minPanelSize, + maxPanelSize, + i18nStrings, + display, + __internalRootRef, + ...props + }, + ref + ) => { + const baseProps = getBaseProps(props); + + const sliderRef = React.useRef(null); + const panelRef = React.useRef(null); + const [containerWidth, rootRef] = useContainerWidth(); + + React.useImperativeHandle( + ref, + () => ({ + focusResizeHandle() { + if (resizable && display === 'all' && sliderRef.current) { + sliderRef.current.focus(); + } + }, + }), + [resizable, display] + ); + + const [panelSize = DEFAULT_PANEL_SIZE, setPanelSize] = useControllable( + controlledPanelSize, + onPanelResize, + defaultPanelSize ?? minPanelSize, + { + componentName: 'SplitView', + controlledProp: 'panelSize', + changeHandler: 'onPanelResize', + } + ); + + const resizeHandlePosition = panelPosition === 'side-end' ? 'side' : panelPosition; + const resizeProps = useResize({ + currentWidth: panelSize, + minWidth: minPanelSize ?? 0, + maxWidth: Math.min(maxPanelSize ?? containerWidth, containerWidth), + panelRef: panelRef, + handleRef: sliderRef, + position: resizeHandlePosition, + onResize: size => { + setPanelSize(size); + fireNonCancelableEvent(onPanelResize, { totalSize: containerWidth, panelSize: size }); + }, + }); + + const mergedRef = useMergeRefs(rootRef, __internalRootRef, ref); + + const wrappedPanelContent =
{panelContent}
; + const wrappedMainContent = ( +
{mainContent}
+ ); + + return ( +
+ {panelPosition === 'side-end' && wrappedMainContent} +
+ {panelPosition === 'side-start' && wrappedPanelContent} + {resizable && display === 'all' && ( +
+ +
+ )} + {panelPosition === 'side-end' && wrappedPanelContent} +
+ {panelPosition === 'side-start' && wrappedMainContent} +
+ ); + } +); + +export default InternalSplitView; diff --git a/src/split-view/styles.scss b/src/split-view/styles.scss new file mode 100644 index 0000000000..0c573c1136 --- /dev/null +++ b/src/split-view/styles.scss @@ -0,0 +1,51 @@ +/* + 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; + +.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; + flex-grow: 1; + } + .display-main-only > & { + display: none; + } + .display-panel-only > & { + flex: 1; + } + &-variant-panel { + background-color: awsui.$color-background-layout-panel-content; + border-inline-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-panel-bottom; + > .panel-content { + padding-inline-start: awsui.$space-m; + padding-block-start: awsui.$space-m; + padding-block-end: awsui.$space-m; + } + } +} + +.content { + overflow-y: auto; + flex-grow: 1; + flex-shrink: 1; + .display-panel-only > & { + display: none; + } +} diff --git a/src/split-view/test-classes/styles.scss b/src/split-view/test-classes/styles.scss new file mode 100644 index 0000000000..8984c5681c --- /dev/null +++ b/src/split-view/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, +.slider { + /* used in test-utils */ +} diff --git a/src/test-utils/dom/split-view/index.ts b/src/test-utils/dom/split-view/index.ts new file mode 100644 index 0000000000..524f231e66 --- /dev/null +++ b/src/test-utils/dom/split-view/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 '../../../split-view/test-classes/styles.selectors.js'; + +export default class SplitViewWrapper extends ComponentWrapper { + static rootSelector: string = styles.root; + + /** + * Returns the wrapper for the panel element. + */ + findPanel(): ElementWrapper | null { + return this.findByClassName(styles.panel); + } + + /** + * Returns the wrapper for the main content element. + */ + findContent(): ElementWrapper | null { + return this.findByClassName(styles.content); + } + + /** + * Returns the wrapper for the resize handle element. + * Returns null if the split view is not resizable. + */ + findResizeHandle(): ElementWrapper | null { + return this.findByClassName(styles.slider); + } +} From 1dd4084366af8e3525a22c1e4c2413a59ac1fd7c Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 11 Dec 2025 13:28:43 +0100 Subject: [PATCH 2/4] fix: Divider in side-end position --- src/split-view/styles.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/split-view/styles.scss b/src/split-view/styles.scss index 0c573c1136..688e3149f5 100644 --- a/src/split-view/styles.scss +++ b/src/split-view/styles.scss @@ -33,6 +33,10 @@ &-variant-panel { background-color: awsui.$color-background-layout-panel-content; border-inline-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-panel-bottom; + .content + & { + border-inline-end: 0; + border-inline-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-panel-bottom; + } > .panel-content { padding-inline-start: awsui.$space-m; padding-block-start: awsui.$space-m; From 850829c3790fc972ae1ffa8e813c8574a7bf5bdb Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 12 Dec 2025 10:54:13 +0100 Subject: [PATCH 3/4] Add translated messages --- src/i18n/messages/all.ar.json | 4 ++++ src/i18n/messages/all.de.json | 4 ++++ src/i18n/messages/all.en-GB.json | 4 ++++ src/i18n/messages/all.en.json | 4 ++++ src/i18n/messages/all.es.json | 4 ++++ src/i18n/messages/all.fr.json | 4 ++++ src/i18n/messages/all.id.json | 4 ++++ src/i18n/messages/all.it.json | 4 ++++ src/i18n/messages/all.ja.json | 4 ++++ src/i18n/messages/all.ko.json | 4 ++++ src/i18n/messages/all.pt-BR.json | 4 ++++ src/i18n/messages/all.tr.json | 4 ++++ src/i18n/messages/all.zh-CN.json | 4 ++++ src/i18n/messages/all.zh-TW.json | 4 ++++ 14 files changed, 56 insertions(+) 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 aed5aa963b..c7b1ec9057 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -180,6 +180,10 @@ "i18nStrings.descriptionText": "{hasFeedback, select, true {Refresh to try again. We are tracking this issue, but you can share more information here.} other {Refresh to try again.}}", "i18nStrings.refreshActionText": "Refresh page" }, + "features-notification-drawer": { + "i18nStrings.title": "Latest feature releases", + "i18nStrings.viewAll": "View all feature releases" + }, "file-token-group": { "i18nStrings.limitShowFewer": "Show fewer", "i18nStrings.limitShowMore": "Show more", 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": "百分比", From 5a23765768472e727f0d707c3b8315dc9d66fc65 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 12 Dec 2025 16:35:31 +0100 Subject: [PATCH 4/4] Bug fixes and doc updates --- .../__snapshots__/documenter.test.ts.snap | 10 +- .../__integ__/app-layout-panel.test.ts | 14 +- src/split-view/__tests__/split-view.test.tsx | 309 +++++++++++++++++- src/split-view/interfaces.ts | 2 +- src/split-view/internal.tsx | 12 +- src/test-utils/dom/split-view/index.ts | 4 +- 6 files changed, 315 insertions(+), 36 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 4e1ee7144d..1708e35fc5 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -24258,7 +24258,7 @@ use the \`id\` attribute, consider setting it on a parent element instead.", }, { "description": "Size of the panel. If provided, and panel is resizable, the component is controlled, -so you must also provide \`onResize\`. +so you must also provide \`onPanelResize\`. The actual size may vary, depending on \`minPanelSize\` and \`maxPanelSize\`.", "name": "panelSize", @@ -39644,7 +39644,7 @@ Returns the current value of the input.", "methods": [ { "description": "Returns the wrapper for the main content element.", - "name": "findContent", + "name": "findMainContent", "parameters": [], "returnType": { "isNullable": true, @@ -39658,7 +39658,7 @@ Returns the current value of the input.", }, { "description": "Returns the wrapper for the panel element.", - "name": "findPanel", + "name": "findPanelContent", "parameters": [], "returnType": { "isNullable": true, @@ -48257,7 +48257,7 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from "methods": [ { "description": "Returns the wrapper for the main content element.", - "name": "findContent", + "name": "findMainContent", "parameters": [], "returnType": { "isNullable": false, @@ -48266,7 +48266,7 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from }, { "description": "Returns the wrapper for the panel element.", - "name": "findPanel", + "name": "findPanelContent", "parameters": [], "returnType": { "isNullable": false, diff --git a/src/split-view/__integ__/app-layout-panel.test.ts b/src/split-view/__integ__/app-layout-panel.test.ts index 1811924a01..36c0b669b2 100644 --- a/src/split-view/__integ__/app-layout-panel.test.ts +++ b/src/split-view/__integ__/app-layout-panel.test.ts @@ -33,7 +33,7 @@ class SplitViewAppLayoutPage extends BasePageObject { async getSplitViewPanelSize() { const splitView = this.getSplitViewWrapper(); - const panel = splitView.findPanel(); + const panel = splitView.findPanelContent(); if (!panel) { return null; } @@ -56,25 +56,25 @@ class SplitViewAppLayoutPage extends BasePageObject { async focusPanelButton() { const splitView = this.getSplitViewWrapper(); - const panelButton = splitView.findPanel().findButton(); + const panelButton = splitView.findPanelContent().findButton(); await this.click(panelButton.toSelector()); } async focusMainContentButton() { const splitView = this.getSplitViewWrapper(); - const contentButton = splitView.findContent().findButton(); + const contentButton = splitView.findMainContent().findButton(); await this.click(contentButton.toSelector()); } isPanelButtonFocused() { const splitView = this.getSplitViewWrapper(); - const panelButton = splitView.findPanel().findButton(); + const panelButton = splitView.findPanelContent().findButton(); return this.isFocused(panelButton.toSelector()); } isMainContentButtonFocused() { const splitView = this.getSplitViewWrapper(); - const contentButton = splitView.findContent().findButton(); + const contentButton = splitView.findMainContent().findButton(); return this.isFocused(contentButton.toSelector()); } @@ -106,8 +106,8 @@ describe('SplitView in App Layout Panel', () => { 'displays panel and main content with proper headers', setupTest({}, async page => { const splitView = page.getSplitViewWrapper(); - const panel = splitView.findPanel(); - const content = splitView.findContent(); + const panel = splitView.findPanelContent(); + const content = splitView.findMainContent(); await expect(page.getText(panel.findHeader().toSelector())).resolves.toContain('Panel content'); await expect(page.getText(content.findHeader().toSelector())).resolves.toContain('Main content'); diff --git a/src/split-view/__tests__/split-view.test.tsx b/src/split-view/__tests__/split-view.test.tsx index f23fc03c99..3edcb94d02 100644 --- a/src/split-view/__tests__/split-view.test.tsx +++ b/src/split-view/__tests__/split-view.test.tsx @@ -59,8 +59,8 @@ describe('SplitView Component', () => { panelContent: 'Test panel content', }); - expect(wrapper.findContent()!.getElement()).toHaveTextContent('Test main content'); - expect(wrapper.findPanel()!.getElement()).toHaveTextContent('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', () => { @@ -79,28 +79,28 @@ describe('SplitView Component', () => { describe('Panel sizing', () => { test('applies default panel size when no size specified', () => { const { wrapper } = renderSplitView(); - const panel = wrapper.findPanel()!.getElement(); + const panel = wrapper.findPanelContent()!.getElement(); expect(panel).toHaveStyle('inline-size: 200px'); }); test('applies defaultPanelSize in uncontrolled mode', () => { const { wrapper } = renderSplitView({ defaultPanelSize: 300 }); - const panel = wrapper.findPanel()!.getElement(); + const panel = wrapper.findPanelContent()!.getElement(); expect(panel).toHaveStyle('inline-size: 300px'); }); test('applies panelSize in controlled mode', () => { const { wrapper } = renderSplitView({ panelSize: 250, onPanelResize: () => {} }); - const panel = wrapper.findPanel()!.getElement(); + const panel = wrapper.findPanelContent()!.getElement(); expect(panel).toHaveStyle('inline-size: 250px'); }); test('uses minPanelSize when defaultPanelSize is not provided', () => { const { wrapper } = renderSplitView({ minPanelSize: 150 }); - const panel = wrapper.findPanel()!.getElement(); + const panel = wrapper.findPanelContent()!.getElement(); expect(panel).toHaveStyle('inline-size: 150px'); }); @@ -110,13 +110,13 @@ describe('SplitView Component', () => { test('renders with panel variant by default', () => { const { wrapper } = renderSplitView(); - expect(wrapper.findPanel()!.getElement()).toHaveClass(styles['panel-variant-panel']); + expect(wrapper.findPanelContent()!.getElement()).toHaveClass(styles['panel-variant-panel']); }); test('renders with custom variant when specified', () => { const { wrapper } = renderSplitView({ panelVariant: 'custom' }); - expect(wrapper.findPanel()!.getElement()).not.toHaveClass(styles['panel-variant-panel']); + expect(wrapper.findPanelContent()!.getElement()).not.toHaveClass(styles['panel-variant-panel']); }); }); @@ -171,8 +171,8 @@ describe('SplitView Component', () => { panelContent: 'Test panel content', }); - const content = wrapper.findContent(); - const panel = wrapper.findPanel(); + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); expect(content).not.toBeNull(); expect(panel).not.toBeNull(); @@ -186,8 +186,8 @@ describe('SplitView Component', () => { panelContent: 'Test panel content', }); - const content = wrapper.findContent(); - const panel = wrapper.findPanel(); + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); expect(content).not.toBeNull(); expect(panel).not.toBeNull(); @@ -200,8 +200,8 @@ describe('SplitView Component', () => { panelContent: 'Test panel content', }); - const content = wrapper.findContent(); - const panel = wrapper.findPanel(); + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); expect(content).toBeNull(); expect(panel).not.toBeNull(); @@ -214,8 +214,8 @@ describe('SplitView Component', () => { panelContent: 'Test panel content', }); - const content = wrapper.findContent(); - const panel = wrapper.findPanel(); + const content = wrapper.findMainContent(); + const panel = wrapper.findPanelContent(); expect(content).not.toBeNull(); expect(panel).toBeNull(); @@ -246,7 +246,7 @@ describe('SplitView Component', () => { onPanelResize: () => {}, }); - expect(wrapper.findPanel()!.getElement()).not.toHaveStyle('inline-size: 300px'); + expect(wrapper.findPanelContent()!.getElement()).not.toHaveStyle('inline-size: 300px'); }); }); @@ -303,4 +303,279 @@ describe('SplitView Component', () => { ); }); }); + + describe('Panel size bounds handling', () => { + describe('panelSize (controlled mode)', () => { + test('clamps panelSize below minPanelSize to minPanelSize', () => { + const { wrapper } = renderSplitView({ + panelSize: 50, + minPanelSize: 150, + maxPanelSize: 500, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 150px'); + }); + + test('clamps panelSize above maxPanelSize to maxPanelSize', () => { + const { wrapper } = renderSplitView({ + panelSize: 600, + minPanelSize: 100, + maxPanelSize: 400, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 400px'); + }); + + test('clamps panelSize above container width to container width', () => { + const { wrapper } = renderSplitView({ + panelSize: 1000, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle(`inline-size: ${CONTAINER_WIDTH}px`); + }); + + test('clamps panelSize when both above maxPanelSize and container width', () => { + const { wrapper } = renderSplitView({ + panelSize: 1000, + maxPanelSize: 600, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + // 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 } = renderSplitView({ + panelSize: 250, + minPanelSize: 100, + maxPanelSize: 400, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('clamps panelSize below minPanelSize when minPanelSize equals maxPanelSize', () => { + const { wrapper } = renderSplitView({ + panelSize: 100, + minPanelSize: 300, + maxPanelSize: 300, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 300px'); + }); + }); + + describe('defaultPanelSize (uncontrolled mode)', () => { + test('clamps defaultPanelSize below minPanelSize to minPanelSize', () => { + const { wrapper } = renderSplitView({ + defaultPanelSize: 50, + minPanelSize: 150, + maxPanelSize: 500, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 150px'); + }); + + test('clamps defaultPanelSize above maxPanelSize to maxPanelSize', () => { + const { wrapper } = renderSplitView({ + defaultPanelSize: 600, + minPanelSize: 100, + maxPanelSize: 400, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 400px'); + }); + + test('clamps defaultPanelSize above container width to container width', () => { + const { wrapper } = renderSplitView({ + defaultPanelSize: 1000, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle(`inline-size: ${CONTAINER_WIDTH}px`); + }); + + test('clamps defaultPanelSize when both above maxPanelSize and container width', () => { + const { wrapper } = renderSplitView({ + defaultPanelSize: 1000, + maxPanelSize: 600, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + // 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 } = renderSplitView({ + defaultPanelSize: 250, + minPanelSize: 100, + maxPanelSize: 400, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('clamps defaultPanelSize when minPanelSize equals maxPanelSize', () => { + const { wrapper } = renderSplitView({ + defaultPanelSize: 100, + minPanelSize: 300, + maxPanelSize: 300, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 300px'); + }); + }); + + describe('edge cases with missing min/max bounds', () => { + test('clamps panelSize when only maxPanelSize is provided', () => { + const { wrapper } = renderSplitView({ + panelSize: 600, + maxPanelSize: 400, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 400px'); + }); + + test('clamps panelSize when only minPanelSize is provided', () => { + const { wrapper } = renderSplitView({ + panelSize: 50, + minPanelSize: 150, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 150px'); + }); + + test('uses defaultPanelSize when only maxPanelSize is provided and size is within bounds', () => { + const { wrapper } = renderSplitView({ + defaultPanelSize: 250, + maxPanelSize: 400, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 250px'); + }); + + test('clamps negative panelSize to minPanelSize when provided', () => { + const { wrapper } = renderSplitView({ + panelSize: -50, + minPanelSize: 100, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 100px'); + }); + + test('clamps negative panelSize to 0 when minPanelSize is not provided', () => { + const { wrapper } = renderSplitView({ + panelSize: -50, + onPanelResize: () => {}, + }); + + const panel = wrapper.findPanelContent()!.getElement(); + expect(panel).toHaveStyle('inline-size: 0px'); + }); + }); + + describe('useResize hook receives clamped values', () => { + test('passes clamped panelSize to useResize when below minPanelSize', () => { + mockUseResize.mockClear(); + + renderSplitView({ + 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(); + + renderSplitView({ + 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(); + + renderSplitView({ + 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(); + + renderSplitView({ + resizable: true, + defaultPanelSize: 600, + minPanelSize: 100, + maxPanelSize: 400, + }); + + expect(mockUseResize).toHaveBeenCalledWith( + expect.objectContaining({ + currentWidth: 400, + minWidth: 100, + maxWidth: 400, + }) + ); + }); + }); + }); }); diff --git a/src/split-view/interfaces.ts b/src/split-view/interfaces.ts index 49afeb00bf..4b9103187e 100644 --- a/src/split-view/interfaces.ts +++ b/src/split-view/interfaces.ts @@ -20,7 +20,7 @@ export interface SplitViewProps extends BaseComponentProps { /** * Size of the panel. If provided, and panel is resizable, the component is controlled, - * so you must also provide `onResize`. + * so you must also provide `onPanelResize`. * * The actual size may vary, depending on `minPanelSize` and `maxPanelSize`. */ diff --git a/src/split-view/internal.tsx b/src/split-view/internal.tsx index c2be90b13e..c44e7af99c 100644 --- a/src/split-view/internal.tsx +++ b/src/split-view/internal.tsx @@ -72,11 +72,15 @@ const InternalSplitView = React.forwardRef {panelPosition === 'side-start' && wrappedPanelContent} {resizable && display === 'all' && ( diff --git a/src/test-utils/dom/split-view/index.ts b/src/test-utils/dom/split-view/index.ts index 524f231e66..4e066d59b4 100644 --- a/src/test-utils/dom/split-view/index.ts +++ b/src/test-utils/dom/split-view/index.ts @@ -10,14 +10,14 @@ export default class SplitViewWrapper extends ComponentWrapper { /** * Returns the wrapper for the panel element. */ - findPanel(): ElementWrapper | null { + findPanelContent(): ElementWrapper | null { return this.findByClassName(styles.panel); } /** * Returns the wrapper for the main content element. */ - findContent(): ElementWrapper | null { + findMainContent(): ElementWrapper | null { return this.findByClassName(styles.content); }