diff --git a/pages/collection-preferences/reorder-content.page.tsx b/pages/collection-preferences/reorder-content.page.tsx index b2cca0f09e..1d2819c61c 100644 --- a/pages/collection-preferences/reorder-content.page.tsx +++ b/pages/collection-preferences/reorder-content.page.tsx @@ -33,7 +33,7 @@ const shortOptionsList = [ }, { id: 'id6', - label: 'Item 6', + label: 'ExtremelyLongLabelTextWithoutSpacesToVerifyThatItWrapsToTheNextLine', }, ]; diff --git a/src/collection-preferences/content-display/__integ__/content-reordering.test.ts b/src/collection-preferences/content-display/__integ__/content-reordering.test.ts index 219db14b6b..68a83355e2 100644 --- a/src/collection-preferences/content-display/__integ__/content-reordering.test.ts +++ b/src/collection-preferences/content-display/__integ__/content-reordering.test.ts @@ -4,11 +4,16 @@ import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; import createWrapper from '../../../../lib/components/test-utils/selectors'; import ContentDisplayPageObject from './pages/content-display-page'; -const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise, height = 1200) => { +const windowDimensions = { + width: 1200, + height: 1200, +}; + +const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise) => { return useBrowser(async browser => { const page = new ContentDisplayPageObject(browser); await browser.url('#/light/collection-preferences/reorder-content'); - await page.setWindowSize({ width: 1200, height }); + await page.setWindowSize(windowDimensions); await testFn(page); }); }; @@ -64,6 +69,41 @@ describe('Collection preferences - Content Display preference', () => { await expect(wrapper.findModal()).not.toBeNull(); }) ); + + describe('does not cause overflow when reaching the edge of the window', () => { + const testByDraggingToPosition = async (page: ContentDisplayPageObject, x: number, y: number) => { + const wrapper = createWrapper().findCollectionPreferences('.cp-1'); + await page.openCollectionPreferencesModal(wrapper); + const modal = wrapper.findModal(); + const modalContentSelector = wrapper.findModal().findContent().toSelector(); + const modalContentBox = await page.getBoundingBox(modalContentSelector); + + const dragHandleSelector = modal + .findContentDisplayPreference() + .findOptions() + .get(1) + .findDragHandle() + .toSelector(); + const dragHandleBox = await page.getBoundingBox(dragHandleSelector); + const delta = { x: x - dragHandleBox.right, y: y - dragHandleBox.bottom }; + await page.mouseDown(dragHandleSelector); + await page.mouseMove(Math.round(delta.x), Math.round(delta.y)); + await page.pause(100); + + const newModalContentBox = await page.getBoundingBox(modalContentSelector); + expect(newModalContentBox).toEqual(modalContentBox); + }; + + test( + 'horizontally', + setupTest(page => testByDraggingToPosition(page, windowDimensions.width, windowDimensions.height / 2)) + ); + + test( + 'vertically', + setupTest(page => testByDraggingToPosition(page, windowDimensions.width / 2, windowDimensions.height)) + ); + }); }); describe('reorders content with keyboard', () => { diff --git a/src/collection-preferences/content-display/sortable-item.scss b/src/collection-preferences/content-display/content-display-option.scss similarity index 50% rename from src/collection-preferences/content-display/sortable-item.scss rename to src/collection-preferences/content-display/content-display-option.scss index b6675bbff4..e4d6cfd30a 100644 --- a/src/collection-preferences/content-display/sortable-item.scss +++ b/src/collection-preferences/content-display/content-display-option.scss @@ -21,60 +21,54 @@ $border-radius: awsui.$border-radius-item; } } -.sortable-item-toggle { +.content-display-option-toggle { /* used in test-utils */ } -.sortable-item { - position: relative; -} - -.sortable-item-placeholder { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background: awsui.$color-drag-placeholder-hover; +.content-display-option-content { + @include styles.styles-reset; + display: flex; + align-items: flex-start; + padding: awsui.$space-xs awsui.$space-scaled-xs awsui.$space-xs 0; + background-color: awsui.$color-background-container-content; border-radius: $border-radius; } -.sortable-item-content { +.content-display-option { + list-style: none; + position: relative; border-top: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; - display: flex; - flex-wrap: nowrap; - justify-content: space-between; - align-items: flex-start; - padding-top: awsui.$space-xs; - padding-bottom: awsui.$space-xs; - padding-right: 0; - &:not(.draggable) { - padding-left: awsui.$space-scaled-l; - } - &.draggable { - padding-left: 0; - padding-right: awsui.$space-scaled-xs; - background-color: awsui.$color-background-container-content; + &:not(.placeholder).sorting { + @include animated; z-index: 1; - &.active { + } + &.placeholder { + > .content-display-option-content { position: relative; - z-index: 2; - box-shadow: awsui.$shadow-container-active; - border-radius: $border-radius; - } - &:not(.active).sorting { - @include animated; - } - @include focus-visible { - &.active { - @include animated; - @include styles.focus-highlight(0px); + &:after { + content: ' '; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: awsui.$color-drag-placeholder-hover; + border-radius: $border-radius; } } } } -.sortable-item-label { - padding-right: awsui.$space-l; +.content-display-option-label { flex-grow: 1; + @include styles.text-wrapping; + padding-right: awsui.$space-l; +} + +.drag-overlay { + box-shadow: awsui.$shadow-container-active; + border-radius: $border-radius; + @include focus-visible { + @include styles.focus-highlight(0px, $border-radius); + } } diff --git a/src/collection-preferences/content-display/content-display-option.tsx b/src/collection-preferences/content-display/content-display-option.tsx new file mode 100644 index 0000000000..e00da79ac9 --- /dev/null +++ b/src/collection-preferences/content-display/content-display-option.tsx @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import styles from '../styles.css.js'; +import DragHandle from '../../internal/drag-handle'; +import InternalToggle from '../../toggle/internal'; +import React, { ForwardedRef, forwardRef } from 'react'; +import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; +import { OptionWithVisibility } from './utils'; +import { useUniqueId } from '../../internal/hooks/use-unique-id'; + +const componentPrefix = 'content-display-option'; +export const getClassName = (suffix?: string) => styles[[componentPrefix, suffix].filter(Boolean).join('-')]; + +export interface ContentDisplayOptionProps { + dragHandleAriaLabel?: string; + listeners?: SyntheticListenerMap; + onToggle?: (option: OptionWithVisibility) => void; + option: OptionWithVisibility; +} + +const ContentDisplayOption = forwardRef( + ( + { dragHandleAriaLabel, listeners, onToggle, option }: ContentDisplayOptionProps, + ref: ForwardedRef + ) => { + const idPrefix = useUniqueId(componentPrefix); + const controlId = `${idPrefix}-control-${option.id}`; + + const dragHandleAttributes = { + ['aria-label']: [dragHandleAriaLabel, option.label].join(', '), + }; + + return ( +
+ + + +
+ onToggle && onToggle(option)} + disabled={option.alwaysVisible === true} + controlId={controlId} + /> +
+
+ ); + } +); + +export default ContentDisplayOption; diff --git a/src/collection-preferences/content-display/draggable-option.tsx b/src/collection-preferences/content-display/draggable-option.tsx new file mode 100644 index 0000000000..d285a2b825 --- /dev/null +++ b/src/collection-preferences/content-display/draggable-option.tsx @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { OptionWithVisibility } from './utils'; +import ContentDisplayOption, { getClassName } from './content-display-option'; +import clsx from 'clsx'; +import styles from '../styles.css.js'; + +export default function DraggableOption({ + dragHandleAriaLabel, + onKeyDown, + onToggle, + option, +}: { + dragHandleAriaLabel?: string; + onKeyDown?: (event: React.KeyboardEvent) => void; + onToggle: (option: OptionWithVisibility) => void; + option: OptionWithVisibility; +}) { + const { isDragging, isSorting, listeners, setNodeRef, transform } = useSortable({ + id: option.id, + }); + const style = { + transform: CSS.Translate.toString(transform), + }; + + const combinedListeners = { + ...listeners, + onKeyDown: (event: React.KeyboardEvent) => { + if (onKeyDown) { + onKeyDown(event); + } + if (listeners?.onKeyDown) { + listeners.onKeyDown(event); + } + }, + }; + + return ( +
  • + +
  • + ); +} diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index de5ed8f239..e8f7ea6063 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -6,11 +6,13 @@ import { useUniqueId } from '../../internal/hooks/use-unique-id'; import { CollectionPreferencesProps } from '../interfaces'; import styles from '../styles.css.js'; import { getSortedOptions, OptionWithVisibility } from './utils'; -import { DndContext } from '@dnd-kit/core'; +import { DndContext, DragOverlay } from '@dnd-kit/core'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { SortableItem } from './sortable-item'; +import DraggableOption from './draggable-option'; import useDragAndDropReorder from './use-drag-and-drop-reorder'; import useLiveAnnouncements from './use-live-announcements'; +import Portal from '../../internal/components/portal'; +import ContentDisplayOption from './content-display-option'; const componentPrefix = 'content-display'; @@ -52,6 +54,8 @@ export default function ContentDisplayPreference({ sortedOptions, }); + const activeOption = activeItem ? sortedOptions.find(({ id }) => id === activeItem) : null; + const announcements = useLiveAnnouncements({ isDragging: activeItem !== null, liveAnnouncementDndStarted, @@ -99,17 +103,37 @@ export default function ContentDisplayPreference({ role="list" > id)} strategy={verticalListSortingStrategy}> - {sortedOptions.map(option => ( - { + return ( + + ); + })} + + + + {/* Make sure that the drag overlay is above the modal + by assigning the z-index as inline style + so that it prevails over dnd-kit's inline z-index of 999 */} + {/* className is a documented prop of the DragOverlay component: + https://docs.dndkit.com/api-documentation/draggable/drag-overlay#class-name-and-inline-styles */ + /* eslint-disable-next-line react/forbid-component-props */} + + {activeOption && ( + - ))} - - + )} + + ); diff --git a/src/collection-preferences/content-display/sortable-item.tsx b/src/collection-preferences/content-display/sortable-item.tsx deleted file mode 100644 index 6ae71fbb01..0000000000 --- a/src/collection-preferences/content-display/sortable-item.tsx +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import clsx from 'clsx'; -import styles from '../styles.css.js'; -import DragHandle from '../../internal/drag-handle'; -import InternalToggle from '../../toggle/internal'; -import { useUniqueId } from '../../internal/hooks/use-unique-id'; -import { OptionWithVisibility } from './utils'; - -const componentPrefix = 'sortable-item'; -const getClassName = (suffix: string) => styles[`${componentPrefix}-${suffix}`]; - -export function SortableItem({ - dragHandleAriaLabel, - onKeyDown, - onToggle, - option, -}: { - dragHandleAriaLabel?: string; - onKeyDown?: (event: React.KeyboardEvent) => void; - onToggle: (option: OptionWithVisibility) => void; - option: OptionWithVisibility; -}) { - const { isDragging, isSorting, listeners, over, rect, setNodeRef, transform } = useSortable({ - id: option.id, - }); - const style = { - transform: CSS.Translate.toString(transform), - }; - - const idPrefix = useUniqueId(componentPrefix); - const controlId = `${idPrefix}-control-${option.id}`; - - const dragHandleAttributes = { - ['aria-label']: [dragHandleAriaLabel, option.label].join(', '), - }; - - const combinedListeners = { - ...listeners, - onKeyDown: (event: React.KeyboardEvent) => { - if (onKeyDown) { - onKeyDown(event); - } - if (listeners?.onKeyDown) { - listeners.onKeyDown(event); - } - }, - }; - - // The placeholder is rendered from within the dragged item, but is shown at the position of the displaced item. - // Therefore, we need to translate it by the right amount. - // Unfortunately we can't use dnd-kit's recommended approach of using a drag overlay - // because it renders out of place when drag and drop is used in our modal. - const placeholderOffsetY = - isDragging && over?.rect?.top !== undefined && rect.current?.top !== undefined - ? over.rect.top > rect.current?.top - ? over.rect.bottom - rect.current?.bottom - : over.rect.top - rect.current?.top - : undefined; - - const placeholderStyle = placeholderOffsetY ? { transform: `translateY(${placeholderOffsetY}px)` } : undefined; - - return ( -
  • - {isDragging &&
    } -
    - - - -
    - onToggle(option)} - disabled={option.alwaysVisible === true} - controlId={controlId} - /> -
    -
    -
  • - ); -} diff --git a/src/collection-preferences/content-display/styles.scss b/src/collection-preferences/content-display/styles.scss index 96805ac161..a572c7d095 100644 --- a/src/collection-preferences/content-display/styles.scss +++ b/src/collection-preferences/content-display/styles.scss @@ -6,12 +6,9 @@ @use '../../internal/styles' as styles; @use '../../internal/styles/tokens' as awsui; -@import 'sortable-item'; +@import 'content-display-option'; -.content-display, -.content-display-groups, -.content-display-group, -.content-display-option { +.content-display { /* used in test-utils */ } diff --git a/src/test-utils/dom/collection-preferences/content-display-preference.ts b/src/test-utils/dom/collection-preferences/content-display-preference.ts index ba88d8821b..30c31a5c7e 100644 --- a/src/test-utils/dom/collection-preferences/content-display-preference.ts +++ b/src/test-utils/dom/collection-preferences/content-display-preference.ts @@ -19,14 +19,14 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper { * Returns the text label displayed in the option item. */ findLabel(): ElementWrapper { - return this.findByClassName(styles['sortable-item-label'])!; + return this.findByClassName(styles['content-display-option-label'])!; } /** * Returns the visibility toggle for the option item. */ findVisibilityToggle(): ToggleWrapper { - return this.findComponent(`.${styles['sortable-item-toggle']}`, ToggleWrapper)!; + return this.findComponent(`.${styles['content-display-option-toggle']}`, ToggleWrapper)!; } }