diff --git a/package-lock.json b/package-lock.json index 7722f25527..50684c60cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "@types/glob": "^7.2.0", "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", - "@types/node": "^18.18.5", + "@types/node": "^20.17.14", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-router": "^5.1.18", @@ -3936,10 +3936,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.18.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.5.tgz", - "integrity": "sha512-4slmbtwV59ZxitY4ixUZdy1uRLf9eSIvBWPQxNjhHYWEtn0FryfKpyS2cvADYXTayWdKEIsJengncrVvkI4I6A==", - "dev": true + "version": "20.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", + "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", @@ -4512,6 +4516,23 @@ } } }, + "node_modules/@wdio/types/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@wdio/types/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@wdio/utils": { "version": "7.33.0", "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-7.33.0.tgz", @@ -7280,6 +7301,16 @@ "integrity": "sha512-9rTIZ4ZjWwalCPiaY+kPiALLfOKgAz5CTi/Zb1L+qSZ8PH3zVo1T8JcgXIIqg1iM3pZ6hF+n9xO5r2jZ/SF+jg==", "dev": true }, + "node_modules/devtools/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/devtools/node_modules/debug": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", @@ -7361,6 +7392,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/devtools/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/devtools/node_modules/ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -18922,6 +18960,13 @@ "fastest-levenshtein": "^1.0.7" } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.0", "dev": true, @@ -19250,6 +19295,23 @@ "node": ">=12.0.0" } }, + "node_modules/webdriver/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/webdriver/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/webdriverio": { "version": "7.36.0", "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-7.36.0.tgz", @@ -19288,6 +19350,16 @@ "node": ">=12.0.0" } }, + "node_modules/webdriverio/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/webdriverio/node_modules/minimatch": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-6.2.0.tgz", @@ -19303,6 +19375,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/webdriverio/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index e53b6ddc03..0ef5be69d1 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@types/glob": "^7.2.0", "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", - "@types/node": "^18.18.5", + "@types/node": "^20.17.14", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-router": "^5.1.18", diff --git a/pages/multiselect/constants.ts b/pages/multiselect/constants.ts index 21b4d782f4..0c7ba8e221 100644 --- a/pages/multiselect/constants.ts +++ b/pages/multiselect/constants.ts @@ -11,3 +11,25 @@ export const deselectAriaLabel = (option: MultiselectProps.Option) => { const label = option?.value || option?.label; return label ? `Deselect ${label}` : 'no label'; }; + +export const getInlineAriaLabel = (selectedOptions: MultiselectProps.Options) => { + let label; + + if (selectedOptions.length === 0) { + label = 0; + } + + if (selectedOptions.length === 1) { + label = selectedOptions[0].label; + } + + if (selectedOptions.length === 2) { + label = `${selectedOptions[0].label} and ${selectedOptions[1].label}`; + } + + if (selectedOptions.length > 2) { + label = `${selectedOptions[0].label}, ${selectedOptions[1].label}, and ${selectedOptions.length - 2} more`; + } + + return label + ' selected'; +}; diff --git a/pages/multiselect/multiselect.test.page.tsx b/pages/multiselect/multiselect.test.page.tsx index 5a19befb3d..fbbf1c107f 100644 --- a/pages/multiselect/multiselect.test.page.tsx +++ b/pages/multiselect/multiselect.test.page.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import Box from '~components/box'; import Multiselect, { MultiselectProps } from '~components/multiselect'; -import { deselectAriaLabel, i18nStrings } from './constants'; +import { deselectAriaLabel, getInlineAriaLabel, i18nStrings } from './constants'; const _selectedOptions1 = [ { @@ -113,6 +113,7 @@ const options2 = [ ], }, ]; + export default function MultiselectPage() { const [selectedOptions1, setSelectedOptions1] = React.useState(_selectedOptions1); const [selectedOptions2, setSelectedOptions2] = React.useState(_selectedOptions1); @@ -235,14 +236,14 @@ export default function MultiselectPage() { Test: Inline tokens
{ setSelectedOptions7(event.detail.selectedOptions); }} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index ab8b24e452..4eee33ef02 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -11259,6 +11259,12 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "optional": true, "type": "string", }, + { + "description": "Shows tokens inside the trigger instead of below it.", + "name": "inlineTokens", + "optional": true, + "type": "boolean", + }, { "description": "Overrides the invalidation state. Usually the invalid state comes from the parent \`FormField\`component, diff --git a/src/attribute-editor/row.tsx b/src/attribute-editor/row.tsx index 9e401d49ef..09dc829f67 100644 --- a/src/attribute-editor/row.tsx +++ b/src/attribute-editor/row.tsx @@ -107,7 +107,7 @@ export const Row = React.memo( errorIconAriaLabel: i18nStrings.errorIconAriaLabel, warningIconAriaLabel: i18nStrings.warningIconAriaLabel, }} - __hideLabel={index !== 0 && layout.rows.length === 1} + __hideLabel={index !== 0 && removeButtonOnSameLine} controlId={defIndex === 0 ? firstControlId : undefined} > {render(item, index, control)} diff --git a/src/internal/components/dropdown/__tests__/dropdown-position.test.ts b/src/internal/components/dropdown/__tests__/dropdown-position.test.ts new file mode 100644 index 0000000000..897d918723 --- /dev/null +++ b/src/internal/components/dropdown/__tests__/dropdown-position.test.ts @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { applyDropdownPositionRelativeToViewport } from '../../../../../lib/components/internal/components/dropdown/dropdown-position'; + +describe('applyDropdownPositionRelativeToViewport', () => { + const triggerRect = { + blockSize: 50, + inlineSize: 100, + insetBlockStart: 100, + insetInlineStart: 100, + insetBlockEnd: 150, + insetInlineEnd: 200, + }; + + const baseDropdownPosition = { + blockSize: '100px', + inlineSize: '100px', + insetInlineStart: '100px', + dropBlockStart: false, + dropInlineStart: false, + }; + + test("sets block end when the dropdown is anchored to the trigger's block start (expands up)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: { ...baseDropdownPosition, dropBlockStart: true }, + isMobile: false, + }); + expect(dropdownElement.style.insetBlockEnd).toBeTruthy(); + expect(dropdownElement.style.insetBlockStart).toBeFalsy(); + }); + + test("aligns block start with the trigger's block end when the dropdown is anchored to the trigger's block end (expands down)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.insetBlockEnd).toBeFalsy(); + expect(dropdownElement.style.insetBlockStart).toEqual(`${triggerRect.insetBlockEnd}px`); + }); + + test("aligns inline start with the trigger's inline start when the dropdown is anchored to the trigger's inline start (anchored from the left in LTR)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.insetInlineStart).toEqual(`${triggerRect.insetInlineStart}px`); + }); + + test("sets inline end when the dropdown is anchored to the trigger's inline start (anchored from the right in LTR)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: { ...baseDropdownPosition, dropInlineStart: true }, + isMobile: false, + }); + expect(dropdownElement.style.insetInlineStart).toBeTruthy(); + }); + + test('uses fixed position on desktop', () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.position).toEqual('fixed'); + }); + + test('uses absolute position on mobile', () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: true, + }); + expect(dropdownElement.style.position).toEqual('absolute'); + }); +}); diff --git a/src/internal/components/dropdown/dropdown-fit-handler.ts b/src/internal/components/dropdown/dropdown-fit-handler.ts index d856fd7cc1..0144763e18 100644 --- a/src/internal/components/dropdown/dropdown-fit-handler.ts +++ b/src/internal/components/dropdown/dropdown-fit-handler.ts @@ -4,6 +4,7 @@ import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolk import { getBreakpointValue } from '../../breakpoints'; import { BoundingBox, getOverflowParentDimensions, getOverflowParents } from '../../utils/scrollable-containers'; +import { LogicalDOMRect } from './dropdown-position'; import styles from './styles.css.js'; @@ -361,7 +362,7 @@ export const calculatePosition = ( isMobile: boolean, minWidth?: number, stretchBeyondTriggerWidth?: boolean -): [DropdownPosition, DOMRect] => { +): [DropdownPosition, LogicalDOMRect] => { // cleaning previously assigned values, // so that they are not reused in case of screen resize and similar events verticalContainerElement.style.maxBlockSize = ''; @@ -393,6 +394,6 @@ export const calculatePosition = ( isMobile, stretchBeyondTriggerWidth, }); - const triggerBox = triggerElement.getBoundingClientRect(); + const triggerBox = getLogicalBoundingClientRect(triggerElement); return [position, triggerBox]; }; diff --git a/src/internal/components/dropdown/dropdown-position.ts b/src/internal/components/dropdown/dropdown-position.ts new file mode 100644 index 0000000000..3ad3aaf2c0 --- /dev/null +++ b/src/internal/components/dropdown/dropdown-position.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DropdownPosition } from './dropdown-fit-handler'; + +export interface LogicalDOMRect { + blockSize: number; + inlineSize: number; + insetBlockStart: number; + insetBlockEnd: number; + insetInlineStart: number; + insetInlineEnd: number; +} + +// Applies its position to the dropdown element when expandToViewport is set to true. +export function applyDropdownPositionRelativeToViewport({ + position, + dropdownElement, + triggerRect, + isMobile, +}: { + position: DropdownPosition; + dropdownElement: HTMLElement; + triggerRect: LogicalDOMRect; + isMobile: boolean; +}) { + // Fixed positions are not respected in iOS when the virtual keyboard is being displayed. + // For this reason we use absolute positioning in mobile. + const useAbsolutePositioning = isMobile; + + // Since when using expandToViewport=true the dropdown is attached to the root of the body, + // the same coordinates can be used for fixed or absolute position, + // except when using absolute position we need to take into account the scroll position of the body itself. + const verticalScrollOffset = useAbsolutePositioning ? document.documentElement.scrollTop : 0; + const horizontalScrollOffset = useAbsolutePositioning ? document.documentElement.scrollLeft : 0; + + dropdownElement.style.position = useAbsolutePositioning ? 'absolute' : 'fixed'; + + if (position.dropBlockStart) { + dropdownElement.style.insetBlockEnd = `calc(100% - ${verticalScrollOffset + triggerRect.insetBlockStart}px)`; + } else { + dropdownElement.style.insetBlockStart = `${verticalScrollOffset + triggerRect.insetBlockEnd}px`; + } + if (position.dropInlineStart) { + dropdownElement.style.insetInlineStart = `calc(${horizontalScrollOffset + triggerRect.insetInlineEnd}px - ${position.inlineSize})`; + } else { + dropdownElement.style.insetInlineStart = `${horizontalScrollOffset + triggerRect.insetInlineStart}px`; + } +} diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index 9a449914fd..124a84d054 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -26,6 +26,7 @@ import { hasEnoughSpaceToStretchBeyondTriggerWidth, InteriorDropdownPosition, } from './dropdown-fit-handler'; +import { applyDropdownPositionRelativeToViewport, LogicalDOMRect } from './dropdown-position'; import { DropdownProps } from './interfaces'; import styles from './styles.css.js'; @@ -196,7 +197,7 @@ const Dropdown = ({ const setDropdownPosition = ( position: DropdownPosition | InteriorDropdownPosition, - triggerBox: DOMRect, + triggerBox: LogicalDOMRect, target: HTMLDivElement, verticalContainer: HTMLDivElement ) => { @@ -233,17 +234,12 @@ const Dropdown = ({ // Position normal overflow dropdowns with fixed positioning relative to viewport if (expandToViewport && !interior) { - target.style.position = 'fixed'; - if (position.dropBlockStart) { - target.style.insetBlockEnd = `calc(100% - ${triggerBox.top}px)`; - } else { - target.style.insetBlockStart = `${triggerBox.bottom}px`; - } - if (position.dropInlineStart) { - target.style.insetInlineStart = `calc(${triggerBox.right}px - ${position.inlineSize})`; - } else { - target.style.insetInlineStart = `${triggerBox.left}px`; - } + applyDropdownPositionRelativeToViewport({ + position, + dropdownElement: target, + triggerRect: triggerBox, + isMobile, + }); // Keep track of the initial dropdown position and direction. // Dropdown direction doesn't need to change as the user scrolls, just needs to stay attached to the trigger. fixedPosition.current = position; @@ -390,21 +386,13 @@ const Dropdown = ({ return; } const updateDropdownPosition = () => { - if (triggerRef.current && dropdownRef.current && verticalContainerRef.current) { - const triggerRect = getLogicalBoundingClientRect(triggerRef.current); - const target = dropdownRef.current; - if (fixedPosition.current) { - if (fixedPosition.current.dropBlockStart) { - dropdownRef.current.style.insetBlockEnd = `calc(100% - ${triggerRect.insetBlockStart}px)`; - } else { - target.style.insetBlockStart = `${triggerRect.insetBlockEnd}px`; - } - if (fixedPosition.current.dropInlineStart) { - target.style.insetInlineStart = `calc(${triggerRect.insetInlineEnd}px - ${fixedPosition.current.inlineSize})`; - } else { - target.style.insetInlineStart = `${triggerRect.insetInlineStart}px`; - } - } + if (triggerRef.current && dropdownRef.current && verticalContainerRef.current && fixedPosition.current) { + applyDropdownPositionRelativeToViewport({ + position: fixedPosition.current, + dropdownElement: dropdownRef.current, + triggerRect: getLogicalBoundingClientRect(triggerRef.current), + isMobile, + }); } }; @@ -416,7 +404,7 @@ const Dropdown = ({ return () => { controller.abort(); }; - }, [open, expandToViewport]); + }, [open, expandToViewport, isMobile]); const referrerId = useUniqueId(); diff --git a/src/multiselect/__tests__/multiselect.test.tsx b/src/multiselect/__tests__/multiselect.test.tsx index 00962a04df..b82eb7c0dd 100644 --- a/src/multiselect/__tests__/multiselect.test.tsx +++ b/src/multiselect/__tests__/multiselect.test.tsx @@ -650,10 +650,10 @@ test('Trigger receives focus when autofocus is true', () => { expect(document.activeElement).toBe(wrapper.findTrigger().getElement()); }); -describe('With inline tokens (private API)', () => { +describe('With inline tokens', () => { it('can render inline tokens', () => { const { wrapper } = renderMultiselect( - + ); // Trigger contains token labels and the number of selected items @@ -666,7 +666,7 @@ describe('With inline tokens (private API)', () => { it('shows placeholder when no items are selected', () => { const { wrapper } = renderMultiselect( - + ); expect(wrapper.findTrigger().getElement()).toHaveTextContent('Choose something'); @@ -677,7 +677,7 @@ describe('With inline tokens (private API)', () => { { value: '1', label: 'First', description: 'description', tags: ['tag'], labelTag: 'label' }, ]; const { wrapper } = renderMultiselect( - + ); expect(wrapper.findTrigger().getElement()).toHaveTextContent('First'); @@ -689,7 +689,7 @@ describe('With inline tokens (private API)', () => { it('shows multiple selected options inline', () => { const { wrapper } = renderMultiselect( diff --git a/src/multiselect/interfaces.ts b/src/multiselect/interfaces.ts index 7b4cf65540..5ebe51e7ee 100644 --- a/src/multiselect/interfaces.ts +++ b/src/multiselect/interfaces.ts @@ -23,6 +23,10 @@ export interface MultiselectProps extends BaseSelectProps { * Only use this if the selected options are displayed elsewhere on the page. */ hideTokens?: boolean; + /** + * Shows tokens inside the trigger instead of below it. + */ + inlineTokens?: boolean; /** * Specifies an `aria-label` for the token deselection button. * @i18n diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index f2ed2cf44b..72fc1a3b34 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -28,7 +28,7 @@ type InternalMultiselectProps = SomeRequired< MultiselectProps, 'options' | 'selectedOptions' | 'filteringType' | 'statusType' | 'keepOpen' | 'hideTokens' > & - InternalBaseComponentProps & { inlineTokens?: boolean }; + InternalBaseComponentProps; const InternalMultiselect = React.forwardRef( ( diff --git a/src/top-navigation/styles.scss b/src/top-navigation/styles.scss index 99740f00f5..b79520aabf 100644 --- a/src/top-navigation/styles.scss +++ b/src/top-navigation/styles.scss @@ -10,6 +10,7 @@ .top-navigation { @include styles.styles-reset; background: awsui.$color-background-container-content; + border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; > .padding-box { display: flex;