diff --git a/package.json b/package.json index ac5eafc2d5..6b678f8f68 100644 --- a/package.json +++ b/package.json @@ -167,7 +167,7 @@ { "path": "lib/components/internal/widget-exports.js", "brotli": false, - "limit": "830 kB", + "limit": "835 kB", "ignore": "react-dom" } ], diff --git a/pages/app-layout/split-panel-with-custom-header.page.tsx b/pages/app-layout/split-panel-with-custom-header.page.tsx new file mode 100644 index 0000000000..e1c0600812 --- /dev/null +++ b/pages/app-layout/split-panel-with-custom-header.page.tsx @@ -0,0 +1,324 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useRef, useState } from 'react'; + +import AppLayout, { AppLayoutProps } from '~components/app-layout'; +import Badge from '~components/badge'; +import Box from '~components/box'; +import Button from '~components/button'; +import ButtonDropdown from '~components/button-dropdown'; +import ColumnLayout from '~components/column-layout'; +import FormField from '~components/form-field/internal'; +import Header from '~components/header'; +import Input from '~components/input'; +import FocusLock, { FocusLockRef } from '~components/internal/components/focus-lock'; +import Link from '~components/link'; +import SpaceBetween from '~components/space-between'; +import SplitPanel from '~components/split-panel'; + +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; +import { Breadcrumbs, ScrollableDrawerContent, Tools } from './utils/content-blocks'; +import labels from './utils/labels'; +import { splitPaneli18nStrings } from './utils/strings'; +import * as toolsContent from './utils/tools-content'; + +import styles from './styles.scss'; + +type SplitPanelDemoContext = React.Context< + AppContextType<{ + ariaLabel?: string; + description?: string; + editableHeader: boolean; + headerText?: string; + linkedHeader?: boolean; + renderActionsButtonDropdown: boolean; + renderActionsButtonLink: boolean; + renderBeforeButtons: boolean; + renderBeforeBadge: boolean; + renderInfoLink: boolean; + splitPanelOpen: boolean; + splitPanelPosition: AppLayoutProps.SplitPanelPreferences['position']; + }> +>; + +function EditableHeader({ onChange, value }: { onChange: (text: string) => void; value: string }) { + const [internalValue, setInternalValue] = useState(value); + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + const focusLockRef = useRef(null); + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + } + }, [editing]); + + return editing ? ( + + +
{ + onChange(internalValue); + setEditing(false); + }} + > + + + + + + setInternalValue(detail.value)} + ref={inputRef} + /> + + +
+ ); +} + +export default function () { + const { urlParams, setUrlParams } = useContext(AppContext as SplitPanelDemoContext); + const [toolsOpen, setToolsOpen] = useState(false); + + const { + ariaLabel, + description, + editableHeader, + linkedHeader, + renderActionsButtonDropdown, + renderActionsButtonLink, + renderBeforeBadge, + renderBeforeButtons, + renderInfoLink, + splitPanelOpen, + splitPanelPosition, + } = urlParams; + + // Initalize with a known header text for a11y compliance if not provided. + const headerText = urlParams.headerText === undefined ? 'Header text' : urlParams.headerText; + + const renderHeaderTextAsLink = !editableHeader && linkedHeader && headerText; + const renderActions = renderActionsButtonDropdown || renderActionsButtonLink; + const renderBefore = editableHeader || linkedHeader || renderBeforeBadge || renderBeforeButtons; + const renderHeaderTextInBeforeSlot = editableHeader || linkedHeader || renderBeforeButtons; + + return ( + + } + navigationHide={true} + tools={{toolsContent.long}} + toolsOpen={toolsOpen} + splitPanelOpen={splitPanelOpen} + onSplitPanelToggle={({ detail }) => setUrlParams({ ...urlParams, splitPanelOpen: detail.open })} + splitPanelPreferences={{ + position: splitPanelPosition, + }} + onSplitPanelPreferencesChange={event => { + const { position } = event.detail; + setUrlParams({ splitPanelPosition: position === 'side' ? position : undefined }); + }} + onToolsChange={({ detail }) => setToolsOpen(detail.open)} + splitPanel={ + + {renderActionsButtonLink && Action} + {renderActionsButtonDropdown && ( + + )} + + ) + } + headerBefore={ + renderBefore && ( + + + {renderBeforeBadge && ( + + 3 + + )} + {editableHeader && ( + setUrlParams({ ...urlParams, headerText: value })} + /> + )} + {renderHeaderTextAsLink && ( + + + {headerText} + + + )} + {renderHeaderTextInBeforeSlot && !editableHeader && !renderHeaderTextAsLink && ( + {headerText} + )} + + {renderBeforeButtons && ( + + + + )} + + ) + } + headerDescription={description} + headerInfo={ + renderInfoLink && ( + setToolsOpen(true)}> + Info + + ) + } + ariaLabel={ariaLabel} + > + + + } + content={ + <> +
+
Split panel with custom header elements
+
+ + + + + + + + + + + + + + + + + + + setUrlParams({ ...urlParams, headerText: detail.value })} + /> + + + setUrlParams({ ...urlParams, description: detail.value })} + /> + + + setUrlParams({ ...urlParams, ariaLabel: detail.value })} + /> + + + + + + + } + /> +
+ ); +} diff --git a/pages/app-layout/styles.scss b/pages/app-layout/styles.scss index 020694d140..33abbc97f8 100644 --- a/pages/app-layout/styles.scss +++ b/pages/app-layout/styles.scss @@ -105,3 +105,17 @@ border-block-start: 2px solid grey; padding-block: awsui.$space-scaled-s; } + +.split-panel-header-margin { + display: inline-block; + margin-block-start: calc(#{awsui.$space-scaled-xxs} + 1px); +} + +.split-panel-header-full-width { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + column-gap: awsui.$space-scaled-xs; + row-gap: awsui.$space-scaled-xxs; + align-items: flex-start; +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 63226b0b9f..7fced308c7 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -21935,6 +21935,12 @@ exports[`Components definition for split-panel matches the snapshot: split-panel "functions": [], "name": "SplitPanel", "properties": [ + { + "description": "ARIA label for the panel. Use this if the value passed in the \`header\` property is not descriptive as a label for the panel.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, { "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.", @@ -21958,9 +21964,9 @@ exports[`Components definition for split-panel matches the snapshot: split-panel "type": "string", }, { - "description": "Header of the split panel.", + "description": "Header text of the split panel.", "name": "header", - "optional": false, + "optional": true, "type": "string", }, { @@ -22069,6 +22075,26 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "isDefault": true, "name": "children", }, + { + "description": "Actions for the header.", + "isDefault": false, + "name": "headerActions", + }, + { + "description": "Content displayed before the header text.", + "isDefault": false, + "name": "headerBefore", + }, + { + "description": "Supplementary text below the heading.", + "isDefault": false, + "name": "headerDescription", + }, + { + "description": "The area next to the heading, used to display an Info link.", + "isDefault": false, + "name": "headerInfo", + }, ], "releaseStatus": "stable", } @@ -83837,6 +83863,38 @@ Component's wrapper class", "type": "reference", }, }, + { + "name": "findHeaderActions", + "parameters": [], + "returnType": { + "name": "ElementWrapper | null", + "type": "union", + }, + }, + { + "name": "findHeaderBefore", + "parameters": [], + "returnType": { + "name": "ElementWrapper | null", + "type": "union", + }, + }, + { + "name": "findHeaderDescription", + "parameters": [], + "returnType": { + "name": "ElementWrapper | null", + "type": "union", + }, + }, + { + "name": "findHeaderInfo", + "parameters": [], + "returnType": { + "name": "ElementWrapper | null", + "type": "union", + }, + }, { "name": "findOpenButton", "parameters": [], @@ -139099,6 +139157,42 @@ Component's wrapper class", "typeArguments": [], }, }, + { + "name": "findHeaderActions", + "parameters": [], + "returnType": { + "name": "ElementWrapper", + "type": "reference", + "typeArguments": [], + }, + }, + { + "name": "findHeaderBefore", + "parameters": [], + "returnType": { + "name": "ElementWrapper", + "type": "reference", + "typeArguments": [], + }, + }, + { + "name": "findHeaderDescription", + "parameters": [], + "returnType": { + "name": "ElementWrapper", + "type": "reference", + "typeArguments": [], + }, + }, + { + "name": "findHeaderInfo", + "parameters": [], + "returnType": { + "name": "ElementWrapper", + "type": "reference", + "typeArguments": [], + }, + }, { "name": "findOpenButton", "parameters": [], 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 23d212ddbe..17b9f767f5 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 @@ -564,6 +564,10 @@ exports[`test-utils selectors 1`] = ` ], "split-panel": [ "awsui_close-button_rjqu5", + "awsui_header-actions_rjqu5", + "awsui_header-before_rjqu5", + "awsui_header-description_rjqu5", + "awsui_header-info_rjqu5", "awsui_header-text_rjqu5", "awsui_open-button_rjqu5", "awsui_open-position-bottom_rjqu5", diff --git a/src/app-layout/__integ__/app-layout-split-panel.test.ts b/src/app-layout/__integ__/app-layout-split-panel.test.ts index 16fc324ee5..21cf05bf4c 100644 --- a/src/app-layout/__integ__/app-layout-split-panel.test.ts +++ b/src/app-layout/__integ__/app-layout-split-panel.test.ts @@ -140,7 +140,7 @@ describe.each(['classic', 'refresh', 'refresh-toolbar'] as const)('%s', theme => ); test( - 'switches to bottom position when when tools panel opens and available space is too small', + 'switches to bottom position when tools panel opens and available space is too small', setupTest(async page => { await page.setWindowSize({ ...viewports.desktop, width: 1100 }); await page.openPanel(); diff --git a/src/split-panel/__integ__/header.test.ts b/src/split-panel/__integ__/header.test.ts new file mode 100644 index 0000000000..b5d14aaddb --- /dev/null +++ b/src/split-panel/__integ__/header.test.ts @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { viewports } from '../../app-layout/__integ__/constants'; +import { getUrlParams } from '../../app-layout/__integ__/utils'; + +const wrapper = createWrapper().findAppLayout(); +const url = '#/light/app-layout/split-panel-with-custom-header'; + +describe.each(['refresh', 'refresh-toolbar'] as const)('%s', theme => { + describe.each(['desktop', 'mobile'] as const)('%s', viewport => { + function setupTest(testFn: (page: BasePageObject) => Promise, params = {}) { + return useBrowser(async browser => { + const page = new BasePageObject(browser); + await page.setWindowSize(viewports[viewport]); + await browser.url(`${url}?${getUrlParams(theme, params)}`); + await page.waitForVisible(wrapper.findContentRegion().toSelector()); + await testFn(page); + }); + } + + describe('only the expand button is clickable in collapsed bottom split panel when certain header slots are populated', () => { + const cases = [ + { slotName: 'headerActions', devPageParam: 'renderActionsButtonDropdown' }, + { slotName: 'headerBefore', devPageParam: 'renderBeforeBadge' }, + { slotName: 'headerInfo', devPageParam: 'renderInfoLink' }, + ]; + test.each(cases)('$slotName', ({ devPageParam }) => + setupTest( + async page => { + await page.click(wrapper.findSplitPanel().findHeader().toSelector()); // Click on the header text + await expect( + page.isDisplayedInViewport(wrapper.findSplitPanel().findOpenPanelBottom().toSelector()) + ).resolves.toBe(false); + await page.click(wrapper.findSplitPanelOpenButton().toSelector()); + await expect( + page.isDisplayedInViewport(wrapper.findSplitPanel().findOpenPanelBottom().toSelector()) + ).resolves.toBe(true); + }, + { [devPageParam]: true } + )() + ); + }); + + test( + 'the entire header is clickable in collapsed bottom split panel even if headerDescription slot is populated', + setupTest( + async page => { + await page.click(wrapper.findSplitPanel().findHeader().toSelector()); // Click on the header text + await expect( + page.isDisplayedInViewport(wrapper.findSplitPanel().findOpenPanelBottom().toSelector()) + ).resolves.toBe(true); + await expect( + page.isDisplayedInViewport(wrapper.findSplitPanel().findOpenPanelBottom().toSelector()) + ).resolves.toBe(true); + }, + { headerDescription: true } + ) + ); + }); +}); diff --git a/src/split-panel/__tests__/common.tsx b/src/split-panel/__tests__/common.tsx new file mode 100644 index 0000000000..0960d023d8 --- /dev/null +++ b/src/split-panel/__tests__/common.tsx @@ -0,0 +1,55 @@ +// 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 TestI18nProvider from '../../../lib/components/i18n/testing'; +import { + SplitPanelContextProps, + SplitPanelContextProvider, +} from '../../../lib/components/internal/context/split-panel-context'; +import SplitPanel, { SplitPanelProps } from '../../../lib/components/split-panel'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { defaultSplitPanelContextProps } from './helpers'; + +const i18nStrings = { + closeButtonAriaLabel: 'closeButtonAriaLabel', + openButtonAriaLabel: 'openButtonAriaLabel', + preferencesTitle: 'preferencesTitle', + preferencesPositionLabel: 'preferencesPositionLabel', + preferencesPositionDescription: 'preferencesPositionDescription', + preferencesPositionSide: 'preferencesPositionSide', + preferencesPositionBottom: 'preferencesPositionBottom', + preferencesConfirm: 'preferencesConfirm', + preferencesCancel: 'preferencesCancel', + resizeHandleAriaLabel: 'resizeHandleAriaLabel', +}; + +export const defaultProps: SplitPanelProps = { + header: 'Split panel header', + children:

Split panel content

, + hidePreferencesButton: undefined, + i18nStrings, +}; + +export function renderSplitPanel({ + props, + contextProps, + messages = {}, + modalMessages = {}, +}: { + props?: Partial; + contextProps?: Partial; + messages?: Record; + modalMessages?: Record; +} = {}) { + const { container } = render( + + + + + + ); + const wrapper = createWrapper(container).findSplitPanel(); + return { wrapper }; +} diff --git a/src/split-panel/__tests__/header.test.tsx b/src/split-panel/__tests__/header.test.tsx new file mode 100644 index 0000000000..266194a6af --- /dev/null +++ b/src/split-panel/__tests__/header.test.tsx @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { describeEachAppLayout } from '../../app-layout/__tests__/utils'; +import { renderSplitPanel } from './common'; + +describe('Split panel: Header slots', () => { + test('warns when neither header nor headerBefore are provided', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + try { + renderSplitPanel({ props: { header: undefined } }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[AwsUi] [SplitPanel] You must provide either `header` or `headerBefore`.' + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + describeEachAppLayout({ sizes: ['desktop', 'mobile'] }, () => { + describe('with the panel open', () => { + test('renders headerBefore', () => { + const { wrapper } = renderSplitPanel({ + props: { headerBefore: Before }, + }); + expect(wrapper!.findHeaderBefore()).not.toBeNull(); + expect(wrapper!.findHeaderBefore()!.getElement()).toHaveTextContent('Before'); + }); + + test('renders headerInfo', () => { + const { wrapper } = renderSplitPanel({ + props: { headerInfo: Info }, + }); + expect(wrapper!.findHeaderInfo()).not.toBeNull(); + expect(wrapper!.findHeaderInfo()!.getElement()).toHaveTextContent('Info'); + }); + + test('renders headerActions', () => { + const { wrapper } = renderSplitPanel({ + props: { headerActions: }, + }); + expect(wrapper!.findHeaderActions()).not.toBeNull(); + expect(wrapper!.findHeaderActions()!.getElement()).toHaveTextContent('Action'); + }); + + test('renders headerDescription', () => { + const { wrapper } = renderSplitPanel({ + props: { headerDescription: 'Header description' }, + }); + expect(wrapper!.findHeaderDescription()).not.toBeNull(); + expect(wrapper!.findHeaderDescription()!.getElement()).toHaveTextContent('Header description'); + }); + }); + + describe('with the panel closed', () => { + test('renders headerBefore', () => { + const { wrapper } = renderSplitPanel({ + props: { headerBefore: Before }, + contextProps: { isOpen: false }, + }); + expect(wrapper!.findHeaderBefore()).not.toBeNull(); + expect(wrapper!.findHeaderBefore()!.getElement()).toHaveTextContent('Before'); + }); + + test('renders headerInfo', () => { + const { wrapper } = renderSplitPanel({ + props: { headerInfo: Info }, + contextProps: { isOpen: false }, + }); + expect(wrapper!.findHeaderInfo()).not.toBeNull(); + expect(wrapper!.findHeaderInfo()!.getElement()).toHaveTextContent('Info'); + }); + + test('does not render headerActions', () => { + const { wrapper } = renderSplitPanel({ + props: { headerActions: }, + contextProps: { isOpen: false }, + }); + expect(wrapper!.findHeaderActions()).toBeNull(); + }); + + test('does not render headerDescription', () => { + const { wrapper } = renderSplitPanel({ + props: { headerDescription: 'Header description' }, + contextProps: { isOpen: false }, + }); + expect(wrapper!.findHeaderDescription()).toBeNull(); + }); + }); + + test('does not render header properties when not provided', () => { + const { wrapper } = renderSplitPanel(); + expect(wrapper!.findHeaderActions()).toBeNull(); + expect(wrapper!.findHeaderBefore()).toBeNull(); + expect(wrapper!.findHeaderDescription()).toBeNull(); + expect(wrapper!.findHeaderInfo()).toBeNull(); + }); + }); +}); diff --git a/src/split-panel/__tests__/split-panel.test.tsx b/src/split-panel/__tests__/split-panel.test.tsx index e72e5bf85e..8fc719bcbd 100644 --- a/src/split-panel/__tests__/split-panel.test.tsx +++ b/src/split-panel/__tests__/split-panel.test.tsx @@ -5,15 +5,11 @@ import { fireEvent, render } from '@testing-library/react'; import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; -import TestI18nProvider from '../../../lib/components/i18n/testing'; -import { - SplitPanelContextProps, - SplitPanelContextProvider, -} from '../../../lib/components/internal/context/split-panel-context'; -import SplitPanel, { SplitPanelProps } from '../../../lib/components/split-panel'; +import SplitPanel from '../../../lib/components/split-panel'; import createWrapper from '../../../lib/components/test-utils/dom'; import { testIf } from '../../__tests__/utils'; import { describeEachAppLayout } from '../../app-layout/__tests__/utils'; +import { defaultProps, renderSplitPanel } from './common'; import { defaultSplitPanelContextProps } from './helpers'; import styles from '../../../lib/components/split-panel/styles.css.js'; @@ -29,48 +25,6 @@ jest.mock('../../../lib/components/app-layout/utils/use-pointer-events', () => ( usePointerEvents: () => onSliderPointerDown, })); -const i18nStrings = { - closeButtonAriaLabel: 'closeButtonAriaLabel', - openButtonAriaLabel: 'openButtonAriaLabel', - preferencesTitle: 'preferencesTitle', - preferencesPositionLabel: 'preferencesPositionLabel', - preferencesPositionDescription: 'preferencesPositionDescription', - preferencesPositionSide: 'preferencesPositionSide', - preferencesPositionBottom: 'preferencesPositionBottom', - preferencesConfirm: 'preferencesConfirm', - preferencesCancel: 'preferencesCancel', - resizeHandleAriaLabel: 'resizeHandleAriaLabel', -}; - -const defaultProps: SplitPanelProps = { - header: 'Split panel header', - children:

Split panel content

, - hidePreferencesButton: undefined, - i18nStrings, -}; - -function renderSplitPanel({ - props, - contextProps, - messages = {}, - modalMessages = {}, -}: { - props?: Partial; - contextProps?: Partial; - messages?: Record; - modalMessages?: Record; -} = {}) { - const { container } = render( - - - - - - ); - const wrapper = createWrapper(container).findSplitPanel(); - return { wrapper }; -} - afterEach(() => { jest.clearAllMocks(); }); @@ -282,6 +236,16 @@ describe('Split panel', () => { expect(sidePanelElem?.querySelector(`#${labelId}`)!.textContent).toBe('Split panel header'); }); + + test('split panel uses ARIA label if provided instead of being labelled by panel header', () => { + const { wrapper } = renderSplitPanel({ + contextProps: { position: 'side' }, + props: { closeBehavior, ariaLabel: 'Custom ARIA label' }, + }); + const sidePanelElem = wrapper!.findByClassName(styles['drawer-content-side'])?.getElement(); + expect(sidePanelElem?.getAttribute('aria-labelledby')).toBeFalsy(); + expect(sidePanelElem?.getAttribute('aria-label')).toBe('Custom ARIA label'); + }); }); describe('i18n', () => { diff --git a/src/split-panel/bottom.tsx b/src/split-panel/bottom.tsx index 62346c2cfa..2521d63e12 100644 --- a/src/split-panel/bottom.tsx +++ b/src/split-panel/bottom.tsx @@ -19,6 +19,7 @@ import testUtilStyles from './test-classes/styles.css.js'; interface SplitPanelContentBottomProps extends SplitPanelContentProps { appLayoutMaxWidth: React.CSSProperties | undefined; closeBehavior: SplitPanelProps['closeBehavior']; + hasCustomElements?: boolean; } export function SplitPanelContentBottom({ @@ -32,7 +33,9 @@ export function SplitPanelContentBottom({ children, appLayoutMaxWidth, panelHeaderId, + ariaLabel, onToggle, + hasCustomElements, }: SplitPanelContentBottomProps) { const isRefresh = useVisualRefresh(); const isToolbar = useAppLayoutToolbarDesignEnabled(); @@ -74,13 +77,14 @@ export function SplitPanelContentBottom({ [sharedStyles['with-motion-vertical']]: !animationDisabled, [testUtilStyles['open-position-bottom']]: isOpen, [styles['drawer-closed']]: !isOpen, + [styles['drawer-clickable']]: !hasCustomElements, [styles['drawer-mobile']]: isMobile, [styles['drawer-disable-content-paddings']]: disableContentPaddings, [styles.refresh]: isRefresh, [styles['with-toolbar']]: isToolbar, [styles.hidden]: closeBehavior === 'hide' && !isOpen, })} - onClick={() => !isOpen && onToggle()} + onClick={() => !isOpen && !hasCustomElements && onToggle()} style={{ insetBlockEnd: bottomOffset, insetInlineStart: leftOffset, @@ -98,7 +102,12 @@ export function SplitPanelContentBottom({ {closeBehavior === 'hide' && !isOpen ? null : ( <> {isOpen &&
{resizeHandle}
} -
+
{header}
diff --git a/src/split-panel/implementation.tsx b/src/split-panel/implementation.tsx index 5723f35afc..5a833dd6b2 100644 --- a/src/split-panel/implementation.tsx +++ b/src/split-panel/implementation.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import { useMergeRefs, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; +import { useMergeRefs, useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { useAppLayoutToolbarDesignEnabled } from '../app-layout/utils/feature-flags'; import { SizeControlProps } from '../app-layout/utils/interfaces'; @@ -36,11 +36,20 @@ export function SplitPanelImplementation({ hidePreferencesButton, closeBehavior, i18nStrings = {}, + ariaLabel, + headerActions, + headerBefore, + headerDescription, + headerInfo, ...restProps }: SplitPanelImplementationProps) { const isRefresh = useVisualRefresh(); const isToolbar = useAppLayoutToolbarDesignEnabled(); + if (!header && !headerBefore) { + warnOnce('SplitPanel', 'You must provide either `header` or `headerBefore`.'); + } + const { position, topOffset, @@ -88,53 +97,90 @@ export function SplitPanelImplementation({ [globalVars.stickyVerticalBottomOffset]: bottomOffset, }; - const panelHeaderId = useUniqueId('split-panel-header'); + const panelHeaderUniqueId = useUniqueId('split-panel-header'); + const panelHeaderId = ariaLabel ? undefined : panelHeaderUniqueId; + + const showActions = headerActions && isOpen; + const showDescription = headerDescription && isOpen; + const hasCustomElements = !!headerActions || !!headerBefore || !!headerInfo; const wrappedHeader = (
-

- {header} -

-
- {!hidePreferencesButton && isOpen && ( - <> +
+
+
+

+ {headerBefore && ( +
+ {headerBefore} +
+ )} + {!!header &&
{header}
} +

+ {headerInfo && ( + {headerInfo} + )} +
+ {showActions && ( +
{headerActions}
+ )} +
+
+ {!hidePreferencesButton && isOpen && ( + <> + setPreferencesOpen(true)} + formAction="none" + ariaLabel={i18nStrings.preferencesTitle} + ref={refs.preferences} + /> + + + )} + + {isOpen ? ( setPreferencesOpen(true)} + onClick={onToggle} formAction="none" - ariaLabel={i18nStrings.preferencesTitle} - ref={refs.preferences} + ariaLabel={i18nStrings.closeButtonAriaLabel} + ariaExpanded={isOpen} /> - - - )} - - {isOpen ? ( - - ) : position === 'side' || closeBehavior === 'hide' ? null : ( - - )} + ) : position === 'side' || closeBehavior === 'hide' ? null : ( + + )} +
+ + {showDescription && ( +

{headerDescription}

+ )}
); @@ -211,6 +257,7 @@ export function SplitPanelImplementation({ toggleRef={refs.toggle} header={wrappedHeader} panelHeaderId={panelHeaderId} + ariaLabel={ariaLabel} closeBehavior={closeBehavior} > {children} @@ -229,7 +276,9 @@ export function SplitPanelImplementation({ header={wrappedHeader} panelHeaderId={panelHeaderId} appLayoutMaxWidth={appLayoutMaxWidth} + ariaLabel={ariaLabel} closeBehavior={closeBehavior} + hasCustomElements={hasCustomElements} > {children} diff --git a/src/split-panel/index.tsx b/src/split-panel/index.tsx index 06ba2088f6..1ab536b2f5 100644 --- a/src/split-panel/index.tsx +++ b/src/split-panel/index.tsx @@ -19,6 +19,12 @@ export default function SplitPanel({ }: SplitPanelProps) { const { __internalRootRef } = useBaseComponent('SplitPanel', { props: { closeBehavior, hidePreferencesButton }, + metadata: { + hasHeaderActions: Boolean(restProps.headerActions), + hasHeaderDescription: Boolean(restProps.headerDescription), + hasHeaderInfo: Boolean(restProps.headerInfo), + hasHeaderBefore: Boolean(restProps.headerBefore), + }, }); const i18n = useInternalI18n('split-panel'); const i18nModal = useInternalI18n('modal'); diff --git a/src/split-panel/interfaces.ts b/src/split-panel/interfaces.ts index 97a6b2a624..4c6056a5f0 100644 --- a/src/split-panel/interfaces.ts +++ b/src/split-panel/interfaces.ts @@ -6,9 +6,9 @@ import { BaseComponentProps } from '../internal/base-component'; export interface SplitPanelProps extends BaseComponentProps { /** - * Header of the split panel. + * Header text of the split panel. */ - header: string; + header?: string; children: React.ReactNode; /** * Determines whether the split panel collapses or hides completely when closed. @@ -33,6 +33,31 @@ export interface SplitPanelProps extends BaseComponentProps { * @i18n */ i18nStrings?: SplitPanelProps.I18nStrings; + + /** + * ARIA label for the panel. Use this if the value passed in the `header` property is not descriptive as a label for the panel. + */ + ariaLabel?: string; + + /** + * Actions for the header. + */ + headerActions?: React.ReactNode; + + /** + * Supplementary text below the heading. + */ + headerDescription?: React.ReactNode; + + /** + * The area next to the heading, used to display an Info link. + */ + headerInfo?: React.ReactNode; + + /** + * Content displayed before the header text. + */ + headerBefore?: React.ReactNode; } export namespace SplitPanelProps { @@ -58,7 +83,8 @@ export interface SplitPanelContentProps { isOpen?: boolean; splitPanelRef?: React.Ref; cappedSize: number; - panelHeaderId: string; + panelHeaderId?: string; + ariaLabel?: string; resizeHandle: React.ReactNode; header: React.ReactNode; children: React.ReactNode; diff --git a/src/split-panel/side.tsx b/src/split-panel/side.tsx index 7ac10e1add..1a61443e83 100644 --- a/src/split-panel/side.tsx +++ b/src/split-panel/side.tsx @@ -32,6 +32,7 @@ export function SplitPanelContentSide({ cappedSize, openButtonAriaLabel, panelHeaderId, + ariaLabel, onToggle, closeBehavior, }: SplitPanelContentSideProps) { @@ -64,6 +65,7 @@ export function SplitPanelContentSide({ }} onClick={() => !isOpen && onToggle()} aria-labelledby={panelHeaderId} + aria-label={ariaLabel} role="region" > {isOpen ? ( diff --git a/src/split-panel/styles.scss b/src/split-panel/styles.scss index c7a2f3f817..fd19463d29 100644 --- a/src/split-panel/styles.scss +++ b/src/split-panel/styles.scss @@ -21,11 +21,8 @@ $app-layout-drawer-width: calc(#{awsui.$space-layout-toggle-diameter} + 2 * #{aw background-color: awsui.$color-background-layout-panel-content; // should be above tools and navigation panels to avoid their shadows z-index: 840; - &-closed { - cursor: pointer; - &:not(.refresh) { - min-inline-size: constants.$sidebar-size-closed; - } + &-closed:not(.refresh) { + min-inline-size: constants.$sidebar-size-closed; } } @@ -73,7 +70,8 @@ $app-layout-drawer-width: calc(#{awsui.$space-layout-toggle-diameter} + 2 * #{aw &.drawer-closed { overflow: hidden; } - &.drawer-closed:hover { + &.drawer-closed.drawer-clickable:hover { + cursor: pointer; background: awsui.$color-background-layout-panel-hover; } & > .drawer-content-bottom > [aria-hidden='true'] { @@ -229,11 +227,8 @@ $app-layout-drawer-width: calc(#{awsui.$space-layout-toggle-diameter} + 2 * #{aw } .header { - display: flex; - flex: auto; - flex-direction: row; - align-items: flex-start; - justify-content: space-between; + $vertical-margin: calc(#{awsui.$space-scaled-xxs} + 1px); + inline-size: 100%; margin-block: awsui.$size-vertical-panel-icon-offset; margin-inline: 0; @@ -241,17 +236,87 @@ $app-layout-drawer-width: calc(#{awsui.$space-layout-toggle-diameter} + 2 * #{aw margin-block: constants.$toolbar-vertical-panel-icon-offset; } + &-main-row, + &-main-content { + display: flex; + } + + // Area containing the before-header slot, the header text, the info link, the actions slot + // and the built-in panel buttons. + &-main-row { + // Make sure that the panel buttons are always anchored to the block start, + // even if the main content (before-header slot, header text, actions) wraps into multiple lines. + align-items: flex-start; + } + + // Area containing the before-header slot, the header text, the info link and the actions slot + // --not the description nor the built-in panel buttons. + &-main-content { + flex: auto; + flex-direction: row; + column-gap: awsui.$space-scaled-xs; + row-gap: awsui.$space-scaled-xxs; + justify-content: space-between; + // Keep the actions slot always at the block start, + // even if the heading and the info slot wrap into multiple lines. + align-items: flex-start; + } + + // Header tag and info link + &-tag-and-info { + flex-grow: 1; + margin-block-start: $vertical-margin; + // The line height of the header text might not be respected in non-high pixel density screens + // unless it is set to a smaller value in its parent container. + line-height: awsui.$line-height-body-s; + &.with-description { + margin-block-end: awsui.$space-scaled-xxxs; + } + } + + &-tag.with-info, + &-text { + display: inline; + } + + // The h2 tag, which contains the elements in the `headerBefore` slot, if defined, and the header text + &-tag { + margin-block: 0; + } + + &-before-slot, &-text { @include styles.font-panel-header; - padding-block: 0; - padding-inline: 0; + } + + &-before-slot { + margin-block-start: calc(-1 * #{$vertical-margin}); + .header-tag.with-info > &, + &.with-header-text { + display: inline-block; + } + } + + &-before-slot.with-header-text, + &-tag.with-info { + margin-inline-end: awsui.$space-scaled-xs; + } + + &-actions-slot { + display: flex; + flex-shrink: 0; + align-items: center; + min-block-size: calc(#{awsui.$font-panel-header-line-height} + 2 * #{$vertical-margin}); + } + + &-description { + color: awsui.$color-text-heading-secondary; + @include styles.font-body-m; margin-block: 0; - margin-inline: 0; - margin-block-start: calc(#{awsui.$space-scaled-xxs} + 1px); } } -.header-actions { +.header-buttons { display: flex; flex-direction: row; justify-content: space-between; diff --git a/src/split-panel/test-classes/styles.scss b/src/split-panel/test-classes/styles.scss index d977bcc68e..37bd2ae173 100644 --- a/src/split-panel/test-classes/styles.scss +++ b/src/split-panel/test-classes/styles.scss @@ -4,6 +4,10 @@ */ .root, +.header-actions, +.header-before, +.header-description, +.header-info, .header-text, .open-button, .close-button, diff --git a/src/test-utils/dom/split-panel/index.ts b/src/test-utils/dom/split-panel/index.ts index b7a329f368..7c798cc34b 100644 --- a/src/test-utils/dom/split-panel/index.ts +++ b/src/test-utils/dom/split-panel/index.ts @@ -30,6 +30,22 @@ export default class SplitPanelWrapper extends ComponentWrapper { return this.findByClassName(testUtilStyles.slider); } + findHeaderActions(): ElementWrapper | null { + return this.findByClassName(testUtilStyles['header-actions']); + } + + findHeaderBefore(): ElementWrapper | null { + return this.findByClassName(testUtilStyles['header-before']); + } + + findHeaderDescription(): ElementWrapper | null { + return this.findByClassName(testUtilStyles['header-description']); + } + + findHeaderInfo(): ElementWrapper | null { + return this.findByClassName(testUtilStyles['header-info']); + } + /** * Returns the same panel if it's currently open in bottom position. If not, it returns null. * Use this method to assert the panel position.