From 2f01948454c36b2a1fcca2cd5a67212f478ddab7 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Mon, 14 Jul 2025 17:13:18 +0200 Subject: [PATCH 1/2] chore: Split presentational and logical parts of internal drag handle component --- .../test-utils-selectors.test.tsx.snap | 10 +- src/app-layout/utils/use-keyboard-events.ts | 4 +- .../__tests__/resizable-box.test.tsx | 3 +- .../drag-handle-wrapper/interfaces.ts | 17 --- .../drag-handle-wrapper/styles.scss | 110 -------------- .../test-classes/styles.scss | 32 ----- .../__tests__/drag-handle-button.test.tsx | 2 +- .../__tests__/drag-handle-wrapper.test.tsx | 40 +++--- .../__tests__/portal-overlay.test.tsx | 4 +- .../drag-handle/{ => components}/button.tsx | 28 +++- .../components}/direction-button.tsx | 18 +-- .../components}/portal-overlay.tsx | 12 +- .../drag-handle/components/wrapper.tsx | 89 ++++++++++++ .../hooks/use-default-drag-behavior.tsx} | 134 +++++++----------- src/internal/components/drag-handle/index.tsx | 29 ++-- .../components/drag-handle/interfaces.ts | 13 +- .../motion.scss | 0 .../components/drag-handle/styles.scss | 100 ++++++++++++- .../drag-handle/test-classes/styles.scss | 24 ++++ src/split-panel/__tests__/utils.test.tsx | 6 +- src/test-utils/dom/internal/drag-handle.ts | 11 +- 21 files changed, 357 insertions(+), 329 deletions(-) delete mode 100644 src/internal/components/drag-handle-wrapper/interfaces.ts delete mode 100644 src/internal/components/drag-handle-wrapper/styles.scss delete mode 100644 src/internal/components/drag-handle-wrapper/test-classes/styles.scss rename src/internal/components/{drag-handle-wrapper => drag-handle}/__tests__/drag-handle-wrapper.test.tsx (94%) rename src/internal/components/{drag-handle-wrapper => drag-handle}/__tests__/portal-overlay.test.tsx (97%) rename src/internal/components/drag-handle/{ => components}/button.tsx (77%) rename src/internal/components/{drag-handle-wrapper => drag-handle/components}/direction-button.tsx (81%) rename src/internal/components/{drag-handle-wrapper => drag-handle/components}/portal-overlay.tsx (94%) create mode 100644 src/internal/components/drag-handle/components/wrapper.tsx rename src/internal/components/{drag-handle-wrapper/index.tsx => drag-handle/hooks/use-default-drag-behavior.tsx} (65%) rename src/internal/components/{drag-handle-wrapper => drag-handle}/motion.scss (100%) 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 fa4e018c6d..77cb377d0f 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 @@ -359,11 +359,11 @@ exports[`test-utils selectors 1`] = ` "awsui_control_1wepg", "awsui_description_1p2cx", "awsui_description_1wepg", - "awsui_direction-button-block-end_8k1rt", - "awsui_direction-button-block-start_8k1rt", - "awsui_direction-button-inline-end_8k1rt", - "awsui_direction-button-inline-start_8k1rt", - "awsui_direction-button-visible_8k1rt", + "awsui_direction-button-block-end_1om0h", + "awsui_direction-button-block-start_1om0h", + "awsui_direction-button-inline-end_1om0h", + "awsui_direction-button-inline-start_1om0h", + "awsui_direction-button-visible_1om0h", "awsui_disabled_15o6u", "awsui_dropdown_qwoo0", "awsui_expand-toggle_1xe88", diff --git a/src/app-layout/utils/use-keyboard-events.ts b/src/app-layout/utils/use-keyboard-events.ts index cc69da42b5..a8e538b23c 100644 --- a/src/app-layout/utils/use-keyboard-events.ts +++ b/src/app-layout/utils/use-keyboard-events.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Direction } from '../../internal/components/drag-handle-wrapper/interfaces'; +import { DragHandleProps } from '../../internal/components/drag-handle/interfaces'; import handleKey from '../../internal/utils/handle-key'; import { SizeControlProps } from './interfaces'; @@ -25,7 +25,7 @@ const getCurrentSize = (panelRef?: React.RefObject) => { export const useKeyboardEvents = ({ position, onResize, panelRef }: SizeControlProps) => { return { - onDirectionClick: (direction: Direction) => { + onDirectionClick: (direction: DragHandleProps.Direction) => { let currentSize: number; const { panelHeight, panelWidth } = getCurrentSize(panelRef); diff --git a/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx b/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx index 612883c412..7008cc077e 100644 --- a/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx +++ b/src/code-editor/resizable-box/__tests__/resizable-box.test.tsx @@ -7,7 +7,6 @@ import { ResizableBox, ResizeBoxProps } from '../../../../lib/components/code-ed import { PointerEventMock } from '../../../../lib/components/internal/utils/pointer-events-mock'; import dragHandleStyles from '../../../../lib/components/internal/components/drag-handle/styles.css.js'; -import dragHandleWrapperStyles from '../../../../lib/components/internal/components/drag-handle-wrapper/styles.css.js'; import styles from '../../../../lib/components/code-editor/resizable-box/styles.selectors.js'; const defaultProps: ResizeBoxProps = { @@ -27,7 +26,7 @@ function findHandle() { function findDirectionButton(direction: 'block-start' | 'block-end') { return document.querySelector( - `.${dragHandleWrapperStyles[`direction-button-wrapper-${direction}`]} .${dragHandleWrapperStyles['direction-button']}` + `.${dragHandleStyles[`direction-button-wrapper-${direction}`]} .${dragHandleStyles['direction-button']}` )!; } diff --git a/src/internal/components/drag-handle-wrapper/interfaces.ts b/src/internal/components/drag-handle-wrapper/interfaces.ts deleted file mode 100644 index 35af929565..0000000000 --- a/src/internal/components/drag-handle-wrapper/interfaces.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -export type Direction = 'block-start' | 'block-end' | 'inline-start' | 'inline-end'; -export type DirectionState = 'active' | 'disabled'; -export type TriggerMode = 'focus' | 'keyboard-activate'; - -export interface DragHandleWrapperProps { - directions: Partial>; - onDirectionClick?: (direction: Direction) => void; - tooltipText?: string; - children: React.ReactNode; - triggerMode?: TriggerMode; - initialShowButtons?: boolean; - hideButtonsOnDrag: boolean; - clickDragThreshold: number; -} diff --git a/src/internal/components/drag-handle-wrapper/styles.scss b/src/internal/components/drag-handle-wrapper/styles.scss deleted file mode 100644 index 0015d1665d..0000000000 --- a/src/internal/components/drag-handle-wrapper/styles.scss +++ /dev/null @@ -1,110 +0,0 @@ -/* - Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - SPDX-License-Identifier: Apache-2.0 -*/ - -@use '../../styles' as styles; -@use '../../styles/tokens' as awsui; -@use './motion'; - -$direction-button-wrapper-size: calc(#{awsui.$space-static-xl} + 2 * #{awsui.$space-static-xxs}); -$direction-button-size: awsui.$space-static-xl; -$direction-button-offset: awsui.$space-static-xxs; - -.drag-handle-wrapper { - position: relative; - display: inline-block; -} - -.portal-overlay { - position: absolute; - inset-block-start: 0; - inset-inline-start: 0; - - // Since the overlay takes up the exact width/height of the element below it, this prevents - // any clicks on this element from occluding clicks on the element below. - pointer-events: none; - - // Similar to the expandToViewport dropdown, this needs to be higher than modal's z-index. - z-index: 7000; -} - -.portal-overlay-contents { - pointer-events: auto; -} - -.drag-handle { - position: relative; - display: flex; -} - -.direction-button-wrapper { - position: absolute; - block-size: $direction-button-size; - inline-size: $direction-button-size; - padding-block: $direction-button-offset; - padding-inline: $direction-button-offset; -} - -.direction-button-wrapper-hidden { - display: none; -} - -.direction-button-wrapper-block-start { - inset-block-start: calc(-1 * $direction-button-wrapper-size); - inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); -} - -.direction-button-wrapper-block-end { - inset-block-end: calc(-1 * $direction-button-wrapper-size); - inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); -} - -.direction-button-wrapper-inline-start { - inset-inline-start: calc(-1 * $direction-button-wrapper-size); - inset-block-start: calc(50% - $direction-button-wrapper-size / 2); -} - -.direction-button-wrapper-inline-end { - inset-inline-end: calc(-1 * $direction-button-wrapper-size); - inset-block-start: calc(50% - $direction-button-wrapper-size / 2); -} - -.direction-button { - position: absolute; - border-width: 0; - cursor: pointer; - display: inline-block; - box-sizing: border-box; - - // This skips the browser waiting for a double-tap interaction before activating. - // False positive - this isn't supported in Safari Desktop but is supported on iOS. - // stylelint-disable-next-line plugin/no-unsupported-browser-features - touch-action: manipulation; - - inline-size: $direction-button-size; - block-size: $direction-button-size; - padding-block: awsui.$space-xxs; - padding-inline: awsui.$space-xxs; - border-start-start-radius: 50%; - border-start-end-radius: 50%; - border-end-start-radius: 50%; - border-end-end-radius: 50%; - background-color: awsui.$color-background-direction-button-default; - color: awsui.$color-text-direction-button-default; - box-shadow: awsui.$shadow-dropdown; - - &:not(.direction-button-disabled):hover { - background-color: awsui.$color-background-direction-button-hover; - } - - &:not(.direction-button-disabled):active { - background-color: awsui.$color-background-direction-button-active; - } -} - -.direction-button-disabled { - cursor: default; - background-color: awsui.$color-background-direction-button-disabled; - color: awsui.$color-text-direction-button-disabled; -} diff --git a/src/internal/components/drag-handle-wrapper/test-classes/styles.scss b/src/internal/components/drag-handle-wrapper/test-classes/styles.scss deleted file mode 100644 index b5ea159c4c..0000000000 --- a/src/internal/components/drag-handle-wrapper/test-classes/styles.scss +++ /dev/null @@ -1,32 +0,0 @@ -/* - Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - SPDX-License-Identifier: Apache-2.0 -*/ - -.root { - /* used in test-utils */ -} - -.direction-button { - /* used in test-utils */ -} - -.direction-button-visible { - /* used in test-utils */ -} - -.direction-button-block-start { - /* used in test-utils */ -} - -.direction-button-block-end { - /* used in test-utils */ -} - -.direction-button-inline-start { - /* used in test-utils */ -} - -.direction-button-inline-end { - /* used in test-utils */ -} diff --git a/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx b/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx index 13d915ed12..6cfb06ce74 100644 --- a/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx +++ b/src/internal/components/drag-handle/__tests__/drag-handle-button.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import DragHandleButton from '../../../../../lib/components/internal/components/drag-handle/button.js'; +import DragHandleButton from '../../../../../lib/components/internal/components/drag-handle/components/button.js'; import styles from '../../../../../lib/components/internal/components/drag-handle/styles.css.js'; diff --git a/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx b/src/internal/components/drag-handle/__tests__/drag-handle-wrapper.test.tsx similarity index 94% rename from src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx rename to src/internal/components/drag-handle/__tests__/drag-handle-wrapper.test.tsx index 7ec379fce7..70a186efe4 100644 --- a/src/internal/components/drag-handle-wrapper/__tests__/drag-handle-wrapper.test.tsx +++ b/src/internal/components/drag-handle/__tests__/drag-handle-wrapper.test.tsx @@ -4,14 +4,12 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; -import DragHandleWrapper from '../../../../../lib/components/internal/components/drag-handle-wrapper'; -import { - Direction, - DragHandleWrapperProps, -} from '../../../../../lib/components/internal/components/drag-handle-wrapper/interfaces'; +import DragHandleWrapper from '../../../../../lib/components/internal/components/drag-handle/components/wrapper'; +import { useDefaultDragBehavior } from '../../../../../lib/components/internal/components/drag-handle/hooks/use-default-drag-behavior'; +import { DragHandleProps } from '../../../../../lib/components/internal/components/drag-handle/interfaces'; import { PointerEventMock } from '../../../../../lib/components/internal/utils/pointer-events-mock'; -import styles from '../../../../../lib/components/internal/components/drag-handle-wrapper/styles.css.js'; +import styles from '../../../../../lib/components/internal/components/drag-handle/styles.css.js'; import tooltipStyles from '../../../../../lib/components/internal/components/tooltip/styles.css.js'; beforeAll(() => { @@ -23,7 +21,7 @@ afterEach(() => { jest.restoreAllMocks(); }); -function getDirectionButton(direction: Direction) { +function getDirectionButton(direction: DragHandleProps.Direction) { return document.querySelector( `.${styles[`direction-button-wrapper-${direction}`]} .${styles['direction-button']}` ); @@ -37,7 +35,7 @@ const DIRECTION_BUTTON_HIDDEN_CLASSES = [ styles['direction-button-wrapper-hidden'], ]; -function expectDirectionButtonToBeHidden(direction: Direction) { +function expectDirectionButtonToBeHidden(direction: DragHandleProps.Direction) { expect( DIRECTION_BUTTON_HIDDEN_CLASSES.some(className => getDirectionButton(direction)!.parentElement!.classList.contains(className) @@ -45,7 +43,7 @@ function expectDirectionButtonToBeHidden(direction: Direction) { ).toBe(true); } -function expectDirectionButtonToBeVisible(direction: Direction) { +function expectDirectionButtonToBeVisible(direction: DragHandleProps.Direction) { expect( !DIRECTION_BUTTON_HIDDEN_CLASSES.some(className => getDirectionButton(direction)!.parentElement!.classList.contains(className) @@ -53,20 +51,28 @@ function expectDirectionButtonToBeVisible(direction: Direction) { ).toBe(true); } -function renderDragHandle(props: Partial>) { - const mergedProps: Omit = { - directions: {}, - hideButtonsOnDrag: false, - clickDragThreshold: 3, +function DragHandleWrapperWithLogic(props: Partial>) { + const mergedProps = { ...props, + directions: props.directions ?? {}, + hideButtonsOnDrag: props.hideButtonsOnDrag ?? false, + clickDragThreshold: props.clickDragThreshold ?? 3, + triggerMode: props.triggerMode ?? 'focus', + initialShowButtons: props.initialShowButtons ?? false, }; - const { container } = render( - + const { wrapperProps } = useDefaultDragBehavior(mergedProps); + + return ( + ); +} + +function renderDragHandle(props: Partial>) { + const { container } = render(); return { dragHandle: container.querySelector('#drag-button')!, @@ -486,7 +492,7 @@ test("doesn't call onDirectionClick when disabled direction button is pressed", expect(onDirectionClick).not.toHaveBeenCalled(); }); -describe('initialinitialShowButtons property', () => { +describe('initialShowButtons property', () => { test('shows direction buttons initially when initialShowButtons=true', () => { renderDragHandle({ directions: { 'block-start': 'active', 'block-end': 'active' }, diff --git a/src/internal/components/drag-handle-wrapper/__tests__/portal-overlay.test.tsx b/src/internal/components/drag-handle/__tests__/portal-overlay.test.tsx similarity index 97% rename from src/internal/components/drag-handle-wrapper/__tests__/portal-overlay.test.tsx rename to src/internal/components/drag-handle/__tests__/portal-overlay.test.tsx index 5b28eb99d1..3c12fd2385 100644 --- a/src/internal/components/drag-handle-wrapper/__tests__/portal-overlay.test.tsx +++ b/src/internal/components/drag-handle/__tests__/portal-overlay.test.tsx @@ -4,9 +4,9 @@ import React, { createRef } from 'react'; import { render, waitFor } from '@testing-library/react'; -import PortalOverlay from '../../../../../lib/components/internal/components/drag-handle-wrapper/portal-overlay.js'; +import PortalOverlay from '../../../../../lib/components/internal/components/drag-handle/components/portal-overlay.js'; -import styles from '../../../../../lib/components/internal/components/drag-handle-wrapper/styles.css.js'; +import styles from '../../../../../lib/components/internal/components/drag-handle/styles.css.js'; let isRtl = false; diff --git a/src/internal/components/drag-handle/button.tsx b/src/internal/components/drag-handle/components/button.tsx similarity index 77% rename from src/internal/components/drag-handle/button.tsx rename to src/internal/components/drag-handle/components/button.tsx index 79ff1d9513..9f4d91c47c 100644 --- a/src/internal/components/drag-handle/button.tsx +++ b/src/internal/components/drag-handle/components/button.tsx @@ -5,13 +5,27 @@ import clsx from 'clsx'; import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; -import { IconProps } from '../../../icon/interfaces'; -import InternalIcon from '../../../icon/internal'; -import { DragHandleProps } from './interfaces'; -import { ResizeIcon } from './resize-icon'; +import { IconProps } from '../../../../icon/interfaces'; +import InternalIcon from '../../../../icon/internal'; +import { DragHandleProps } from '../interfaces'; +import { ResizeIcon } from '../resize-icon'; -import styles from './styles.css.js'; -import testUtilsStyles from './test-classes/styles.css.js'; +import styles from '../styles.css.js'; +import testUtilsStyles from '../test-classes/styles.css.js'; + +interface DragHandleButtonProps { + variant?: DragHandleProps.Variant; + size?: DragHandleProps.Size; + disabled?: boolean; + ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedby?: string; + ariaValue?: DragHandleProps.AriaValue; + active?: boolean; + className?: string; + onPointerDown?: React.PointerEventHandler; + onKeyDown?: React.KeyboardEventHandler; +} const DragHandleButton = forwardRef( ( @@ -27,7 +41,7 @@ const DragHandleButton = forwardRef( disabled, onPointerDown, onKeyDown, - }: DragHandleProps, + }: DragHandleButtonProps, ref: React.Ref ) => { const dragHandleRefObject = useRef(null); diff --git a/src/internal/components/drag-handle-wrapper/direction-button.tsx b/src/internal/components/drag-handle/components/direction-button.tsx similarity index 81% rename from src/internal/components/drag-handle-wrapper/direction-button.tsx rename to src/internal/components/drag-handle/components/direction-button.tsx index ccc98e12e5..6af231438f 100644 --- a/src/internal/components/drag-handle-wrapper/direction-button.tsx +++ b/src/internal/components/drag-handle/components/direction-button.tsx @@ -4,18 +4,18 @@ import React from 'react'; import clsx from 'clsx'; -import { IconProps } from '../../../icon/interfaces'; -import InternalIcon from '../../../icon/internal'; -import { Transition } from '../transition'; -import { Direction, DirectionState } from './interfaces'; +import { IconProps } from '../../../../icon/interfaces'; +import InternalIcon from '../../../../icon/internal'; +import { Transition } from '../../transition'; +import { DragHandleProps } from '../interfaces'; -import styles from './styles.css.js'; -import testUtilsStyles from './test-classes/styles.css.js'; +import styles from '../styles.css.js'; +import testUtilsStyles from '../test-classes/styles.css.js'; // Mapping from CSS logical property direction to icon name. The icon component // already flips the left/right icons automatically based on RTL, so we don't // need to do anything special. -const ICON_LOGICAL_PROPERTY_MAP: Record = { +const ICON_LOGICAL_PROPERTY_MAP: Record = { 'block-start': 'arrow-up', 'block-end': 'arrow-down', 'inline-start': 'arrow-left', @@ -23,8 +23,8 @@ const ICON_LOGICAL_PROPERTY_MAP: Record = { }; interface DirectionButtonProps { - direction: Direction; - state: DirectionState; + direction: DragHandleProps.Direction; + state: DragHandleProps.DirectionState; onClick: React.MouseEventHandler; show: boolean; } diff --git a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx b/src/internal/components/drag-handle/components/portal-overlay.tsx similarity index 94% rename from src/internal/components/drag-handle-wrapper/portal-overlay.tsx rename to src/internal/components/drag-handle/components/portal-overlay.tsx index 6eed51168f..dbf48ea00e 100644 --- a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx +++ b/src/internal/components/drag-handle/components/portal-overlay.tsx @@ -9,17 +9,15 @@ import { Portal, } from '@cloudscape-design/component-toolkit/internal'; -import styles from './styles.css.js'; +import styles from '../styles.css.js'; -export default function PortalOverlay({ - track, - isDisabled, - children, -}: { +interface PortalOverlayProps { track: React.RefObject; isDisabled: boolean; children: React.ReactNode; -}) { +} + +export default function PortalOverlay({ track, isDisabled, children }: PortalOverlayProps) { const ref = useRef(null); const [container, setContainer] = useState(null); diff --git a/src/internal/components/drag-handle/components/wrapper.tsx b/src/internal/components/drag-handle/components/wrapper.tsx new file mode 100644 index 0000000000..629dfb6314 --- /dev/null +++ b/src/internal/components/drag-handle/components/wrapper.tsx @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { HTMLAttributes, useRef } from 'react'; + +import Tooltip from '../../tooltip'; +import { DragHandleProps } from '../interfaces'; +import DirectionButton from './direction-button'; +import PortalOverlay from './portal-overlay'; + +import styles from '../styles.css.js'; + +export interface DragHandleWrapperProps { + tooltipText?: string; + directions: Partial>; + children: React.ReactNode; + + showButtons: boolean; + showTooltip: boolean; + + nativeAttributes?: HTMLAttributes; + onDirectionClick?: (direction: DragHandleProps.Direction) => void; + onTooltipDismiss?: () => void; +} + +function DragHandleWrapper( + { + directions, + tooltipText, + children, + onDirectionClick, + onTooltipDismiss, + showButtons, + showTooltip, + nativeAttributes, + }: DragHandleWrapperProps, + ref: React.Ref +) { + const dragHandleRef = useRef(null); + + return ( + <> +
+
+ {children} +
+ + {showTooltip && } +
+ + + {directions['block-start'] && ( + onDirectionClick?.('block-start')} + /> + )} + {directions['block-end'] && ( + onDirectionClick?.('block-end')} + /> + )} + {directions['inline-start'] && ( + onDirectionClick?.('inline-start')} + /> + )} + {directions['inline-end'] && ( + onDirectionClick?.('inline-end')} + /> + )} + + + ); +} + +export default React.forwardRef(DragHandleWrapper); diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle/hooks/use-default-drag-behavior.tsx similarity index 65% rename from src/internal/components/drag-handle-wrapper/index.tsx rename to src/internal/components/drag-handle/hooks/use-default-drag-behavior.tsx index 0c93adf2d4..f057a584c0 100644 --- a/src/internal/components/drag-handle-wrapper/index.tsx +++ b/src/internal/components/drag-handle/hooks/use-default-drag-behavior.tsx @@ -1,31 +1,41 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef, useState } from 'react'; -import clsx from 'clsx'; +import { useEffect, useRef, useState } from 'react'; import { nodeContains } from '@cloudscape-design/component-toolkit/dom'; +import { getFirstFocusable } from '@cloudscape-design/component-toolkit/internal'; + +import { DragHandleWrapperProps } from '../components/wrapper'; +import { DragHandleProps } from '../interfaces'; + +interface UseDefaultDragBehaviorProps { + directions: Partial>; + triggerMode: DragHandleProps.TriggerMode; + hideButtonsOnDrag: boolean; + clickDragThreshold: number; + initialShowButtons: boolean; + onDirectionClick?: (direction: DragHandleProps.Direction) => void; +} -import { getFirstFocusable } from '../focus-lock/utils'; -import Tooltip from '../tooltip'; -import DirectionButton from './direction-button'; -import { Direction, DragHandleWrapperProps } from './interfaces'; -import PortalOverlay from './portal-overlay'; - -import styles from './styles.css.js'; +interface UseDefaultDragBehaviorResult { + wrapperProps: Required< + Pick< + DragHandleWrapperProps, + 'directions' | 'showButtons' | 'onDirectionClick' | 'showTooltip' | 'onTooltipDismiss' | 'nativeAttributes' + > & { ref: React.Ref } + >; +} -export default function DragHandleWrapper({ +export function useDefaultDragBehavior({ directions, - tooltipText, - children, - onDirectionClick, - triggerMode = 'focus', - initialShowButtons = false, + triggerMode, hideButtonsOnDrag, clickDragThreshold, -}: DragHandleWrapperProps) { + initialShowButtons, + onDirectionClick, +}: UseDefaultDragBehaviorProps): UseDefaultDragBehaviorResult { const wrapperRef = useRef(null); - const dragHandleRef = useRef(null); const [showTooltip, setShowTooltip] = useState(false); const [showButtons, setShowButtons] = useState(initialShowButtons); @@ -35,10 +45,10 @@ export default function DragHandleWrapper({ // The tooltip ("Drag or select to move/resize") shouldn't show if clicking // on the handle wouldn't do anything. - const isDisabled = + const isImplicitlyDisabled = !directions['block-start'] && !directions['block-end'] && !directions['inline-start'] && !directions['inline-end']; - const onWrapperFocusIn: React.FocusEventHandler = event => { + const onFocus: React.FocusEventHandler = event => { // The drag handle is focused when it's either tabbed to, or the pointer // is pressed on it. We exclude handling the pointer press in this handler, // since it could be the start of a drag event - the pointer stuff is @@ -53,7 +63,7 @@ export default function DragHandleWrapper({ } }; - const onWrapperFocusOut: React.FocusEventHandler = event => { + const onBlur: React.FocusEventHandler = event => { // Close the directional buttons when the focus leaves the drag handle. // "focusout" is also triggered when the user leaves the current tab, but // since it'll be returned when they switch back anyway, we exclude that @@ -124,7 +134,7 @@ export default function DragHandleWrapper({ return () => controller.abort(); }, [clickDragThreshold, hideButtonsOnDrag]); - const onHandlePointerDown: React.PointerEventHandler = event => { + const onPointerDown: React.PointerEventHandler = event => { // Tooltip behavior: the tooltip should appear on hover, but disappear when // the pointer starts dragging (having the tooltip get in the way while // you're trying to drag upwards is annoying). Additionally, the tooltip @@ -140,16 +150,17 @@ export default function DragHandleWrapper({ // Tooltip behavior: the tooltip should stay open when the cursor moves // from the drag handle into the tooltip content itself. This is why the // handler is set on the wrapper for both the drag handle and the tooltip. - const onTooltipGroupPointerEnter: React.PointerEventHandler = () => { + const onPointerEnter: React.PointerEventHandler = () => { if (!isPointerDown.current) { setShowTooltip(true); } }; - const onTooltipGroupPointerLeave: React.PointerEventHandler = () => { + + const onPointerLeave: React.PointerEventHandler = () => { setShowTooltip(false); }; - const onDragHandleKeyDown: React.KeyboardEventHandler = event => { + const onKeyDown: React.KeyboardEventHandler = event => { // For accessibility reasons, pressing escape should always close the floating controls. if (event.key === 'Escape') { setShowButtons(false); @@ -169,72 +180,25 @@ export default function DragHandleWrapper({ } }; - const onInternalDirectionClick = (direction: Direction) => { + const onInternalDirectionClick = (direction: DragHandleProps.Direction) => { // Move focus back to the wrapper on click. This prevents focus from staying // on an aria-hidden control, and allows future keyboard events to be handled // cleanly using the drag handle's own handlers. - if (dragHandleRef.current) { - getFirstFocusable(dragHandleRef.current)?.focus(); + if (wrapperRef.current) { + getFirstFocusable(wrapperRef.current)?.focus(); } onDirectionClick?.(direction); }; - return ( -
-
-
- {children} -
- - {!isDisabled && !showButtons && showTooltip && tooltipText && ( - setShowTooltip(false)} /> - )} -
- - - {directions['block-start'] && ( - onInternalDirectionClick('block-start')} - /> - )} - {directions['block-end'] && ( - onInternalDirectionClick('block-end')} - /> - )} - {directions['inline-start'] && ( - onInternalDirectionClick('inline-start')} - /> - )} - {directions['inline-end'] && ( - onInternalDirectionClick('inline-end')} - /> - )} - -
- ); + return { + wrapperProps: { + ref: wrapperRef, + directions, + showButtons: !isImplicitlyDisabled && showButtons, + showTooltip: !isImplicitlyDisabled && !showButtons && showTooltip, + onDirectionClick: onInternalDirectionClick, + onTooltipDismiss: () => setShowTooltip(false), + nativeAttributes: { onFocus, onBlur, onKeyDown, onPointerDown, onPointerEnter, onPointerLeave }, + }, + }; } diff --git a/src/internal/components/drag-handle/index.tsx b/src/internal/components/drag-handle/index.tsx index a6a2bcee31..eb86eca5a5 100644 --- a/src/internal/components/drag-handle/index.tsx +++ b/src/internal/components/drag-handle/index.tsx @@ -3,8 +3,9 @@ import React, { forwardRef } from 'react'; import { getBaseProps } from '../../base-component'; -import DragHandleWrapper from '../drag-handle-wrapper'; -import DragHandleButton from './button'; +import DragHandleButton from './components/button'; +import DragHandleWrapper from './components/wrapper'; +import { useDefaultDragBehavior } from './hooks/use-default-drag-behavior'; import { DragHandleProps } from './interfaces'; export { DragHandleProps }; @@ -24,8 +25,8 @@ const InternalDragHandle = forwardRef( onPointerDown, onKeyDown, onDirectionClick, - triggerMode, - initialShowButtons, + triggerMode = 'focus', + initialShowButtons = false, hideButtonsOnDrag = false, clickDragThreshold = 3, active, @@ -34,17 +35,17 @@ const InternalDragHandle = forwardRef( ref: React.Ref ) => { const baseProps = getBaseProps(rest); + const { wrapperProps } = useDefaultDragBehavior({ + directions, + triggerMode, + initialShowButtons, + hideButtonsOnDrag, + clickDragThreshold, + onDirectionClick, + }); return ( - + ); diff --git a/src/internal/components/drag-handle/interfaces.ts b/src/internal/components/drag-handle/interfaces.ts index 724d29c91b..3bcea5c73e 100644 --- a/src/internal/components/drag-handle/interfaces.ts +++ b/src/internal/components/drag-handle/interfaces.ts @@ -1,12 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { - Direction as WrapperDirection, - DirectionState as WrapperDirectionState, - TriggerMode, -} from '../drag-handle-wrapper/interfaces'; - export interface DragHandleProps { variant?: DragHandleProps.Variant; size?: DragHandleProps.Size; @@ -23,7 +17,7 @@ export interface DragHandleProps { tooltipText?: string; directions?: Partial>; onDirectionClick?: (direction: DragHandleProps.Direction) => void; - triggerMode?: TriggerMode; + triggerMode?: DragHandleProps.TriggerMode; initialShowButtons?: boolean; /** * Hide the UAP buttons when dragging is active. @@ -39,8 +33,9 @@ export interface DragHandleProps { export namespace DragHandleProps { export type Variant = 'drag-indicator' | 'resize-area' | 'resize-horizontal' | 'resize-vertical'; - export type Direction = WrapperDirection; - export type DirectionState = WrapperDirectionState; + export type Direction = 'block-start' | 'block-end' | 'inline-start' | 'inline-end'; + export type DirectionState = 'active' | 'disabled'; + export type TriggerMode = 'focus' | 'keyboard-activate'; export type Size = 'small' | 'normal'; diff --git a/src/internal/components/drag-handle-wrapper/motion.scss b/src/internal/components/drag-handle/motion.scss similarity index 100% rename from src/internal/components/drag-handle-wrapper/motion.scss rename to src/internal/components/drag-handle/motion.scss diff --git a/src/internal/components/drag-handle/styles.scss b/src/internal/components/drag-handle/styles.scss index 1a386c968e..bb6d14f89a 100644 --- a/src/internal/components/drag-handle/styles.scss +++ b/src/internal/components/drag-handle/styles.scss @@ -3,9 +3,15 @@ SPDX-License-Identifier: Apache-2.0 */ +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; @use '../../styles' as styles; @use '../../styles/tokens' as awsui; -@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; + +@use './motion'; + +$direction-button-wrapper-size: calc(#{awsui.$space-static-xl} + 2 * #{awsui.$space-static-xxs}); +$direction-button-size: awsui.$space-static-xl; +$direction-button-offset: awsui.$space-static-xxs; .handle { appearance: none; @@ -85,3 +91,95 @@ transform: rotate(90deg); } } + +.portal-overlay { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + + // Since the overlay takes up the exact width/height of the element below it, this prevents + // any clicks on this element from occluding clicks on the element below. + pointer-events: none; + + // Similar to the expandToViewport dropdown, this needs to be higher than modal's z-index. + z-index: 7000; +} + +.portal-overlay-contents { + pointer-events: auto; +} + +.drag-handle { + display: contents; +} + +.direction-button-wrapper { + position: absolute; + block-size: $direction-button-size; + inline-size: $direction-button-size; + padding-block: $direction-button-offset; + padding-inline: $direction-button-offset; +} + +.direction-button-wrapper-hidden { + display: none; +} + +.direction-button-wrapper-block-start { + inset-block-start: calc(-1 * $direction-button-wrapper-size); + inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button-wrapper-block-end { + inset-block-end: calc(-1 * $direction-button-wrapper-size); + inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button-wrapper-inline-start { + inset-inline-start: calc(-1 * $direction-button-wrapper-size); + inset-block-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button-wrapper-inline-end { + inset-inline-end: calc(-1 * $direction-button-wrapper-size); + inset-block-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button { + position: absolute; + border-width: 0; + cursor: pointer; + display: inline-block; + box-sizing: border-box; + + // This skips the browser waiting for a double-tap interaction before activating. + // False positive - this isn't supported in Safari Desktop but is supported on iOS. + // stylelint-disable-next-line plugin/no-unsupported-browser-features + touch-action: manipulation; + + inline-size: $direction-button-size; + block-size: $direction-button-size; + padding-block: awsui.$space-xxs; + padding-inline: awsui.$space-xxs; + border-start-start-radius: 50%; + border-start-end-radius: 50%; + border-end-start-radius: 50%; + border-end-end-radius: 50%; + background-color: awsui.$color-background-direction-button-default; + color: awsui.$color-text-direction-button-default; + box-shadow: awsui.$shadow-dropdown; + + &:not(.direction-button-disabled):hover { + background-color: awsui.$color-background-direction-button-hover; + } + + &:not(.direction-button-disabled):active { + background-color: awsui.$color-background-direction-button-active; + } +} + +.direction-button-disabled { + cursor: default; + background-color: awsui.$color-background-direction-button-disabled; + color: awsui.$color-text-direction-button-disabled; +} diff --git a/src/internal/components/drag-handle/test-classes/styles.scss b/src/internal/components/drag-handle/test-classes/styles.scss index 5a54f6dcc3..b5ea159c4c 100644 --- a/src/internal/components/drag-handle/test-classes/styles.scss +++ b/src/internal/components/drag-handle/test-classes/styles.scss @@ -6,3 +6,27 @@ .root { /* used in test-utils */ } + +.direction-button { + /* used in test-utils */ +} + +.direction-button-visible { + /* used in test-utils */ +} + +.direction-button-block-start { + /* used in test-utils */ +} + +.direction-button-block-end { + /* used in test-utils */ +} + +.direction-button-inline-start { + /* used in test-utils */ +} + +.direction-button-inline-end { + /* used in test-utils */ +} diff --git a/src/split-panel/__tests__/utils.test.tsx b/src/split-panel/__tests__/utils.test.tsx index d285ed4214..f96efddced 100644 --- a/src/split-panel/__tests__/utils.test.tsx +++ b/src/split-panel/__tests__/utils.test.tsx @@ -3,7 +3,7 @@ import { fireEvent } from '@testing-library/react'; import { useKeyboardEvents } from '../../app-layout/utils/use-keyboard-events'; -import { Direction } from '../../internal/components/drag-handle-wrapper/interfaces'; +import { DragHandleProps } from '../../internal/components/drag-handle/interfaces'; import { KeyCode } from '../../internal/keycode'; const sizeControlProps: any = { @@ -56,7 +56,7 @@ describe('useKeyboardEvents.onKeyDown, bottom position', () => { }); describe('useKeyboardEvents.onDirectionClick, bottom position', () => { - let onDirectionClick: (direction: Direction) => void; + let onDirectionClick: (direction: DragHandleProps.Direction) => void; beforeEach(() => { ({ onDirectionClick } = useKeyboardEvents({ ...sizeControlProps, position: 'bottom' })); @@ -112,7 +112,7 @@ describe('useKeyboardEvents.onKeyDown, side position', () => { }); describe('useKeyboardEvents.onDirectionClick, side position', () => { - let onDirectionClick: (direction: Direction) => void; + let onDirectionClick: (direction: DragHandleProps.Direction) => void; beforeEach(() => { ({ onDirectionClick } = useKeyboardEvents({ ...sizeControlProps, position: 'side' })); diff --git a/src/test-utils/dom/internal/drag-handle.ts b/src/test-utils/dom/internal/drag-handle.ts index bdf4e4c33f..569f206fa8 100644 --- a/src/test-utils/dom/internal/drag-handle.ts +++ b/src/test-utils/dom/internal/drag-handle.ts @@ -3,36 +3,35 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; import dragHandleStyles from '../../../internal/components/drag-handle/test-classes/styles.selectors.js'; -import dragHandleWrapperStyles from '../../../internal/components/drag-handle-wrapper/test-classes/styles.selectors.js'; export default class DragHandleWrapper extends ComponentWrapper { static rootSelector: string = dragHandleStyles.root; findAllVisibleDirectionButtons(): Array | null { - return this.findAll(`.${dragHandleWrapperStyles['direction-button-visible']}`); + return this.findAll(`.${dragHandleStyles['direction-button-visible']}`); } findVisibleDirectionButtonBlockStart(): ElementWrapper | null { return this.find( - `.${dragHandleWrapperStyles['direction-button-block-start']}.${dragHandleWrapperStyles['direction-button-visible']}` + `.${dragHandleStyles['direction-button-block-start']}.${dragHandleStyles['direction-button-visible']}` ); } findVisibleDirectionButtonBlockEnd(): ElementWrapper | null { return this.find( - `.${dragHandleWrapperStyles['direction-button-block-end']}.${dragHandleWrapperStyles['direction-button-visible']}` + `.${dragHandleStyles['direction-button-block-end']}.${dragHandleStyles['direction-button-visible']}` ); } findVisibleDirectionButtonInlineStart(): ElementWrapper | null { return this.find( - `.${dragHandleWrapperStyles['direction-button-inline-start']}.${dragHandleWrapperStyles['direction-button-visible']}` + `.${dragHandleStyles['direction-button-inline-start']}.${dragHandleStyles['direction-button-visible']}` ); } findVisibleDirectionButtonInlineEnd(): ElementWrapper | null { return this.find( - `.${dragHandleWrapperStyles['direction-button-inline-end']}.${dragHandleWrapperStyles['direction-button-visible']}` + `.${dragHandleStyles['direction-button-inline-end']}.${dragHandleStyles['direction-button-visible']}` ); } } From 4251a6d5baebf6834ba8943ec8a5738586a020e8 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 15 Jul 2025 16:56:09 +0200 Subject: [PATCH 2/2] Abandon display-contents approach, just allow className override through nativeAttributes. --- .../drag-handle/components/wrapper.tsx | 45 +++++++++---------- .../components/drag-handle/styles.scss | 8 ++-- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/internal/components/drag-handle/components/wrapper.tsx b/src/internal/components/drag-handle/components/wrapper.tsx index 629dfb6314..c31421601e 100644 --- a/src/internal/components/drag-handle/components/wrapper.tsx +++ b/src/internal/components/drag-handle/components/wrapper.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React, { HTMLAttributes, useRef } from 'react'; +import clsx from 'clsx'; import Tooltip from '../../tooltip'; import { DragHandleProps } from '../interfaces'; @@ -23,32 +24,32 @@ export interface DragHandleWrapperProps { onTooltipDismiss?: () => void; } -function DragHandleWrapper( - { - directions, - tooltipText, - children, - onDirectionClick, - onTooltipDismiss, - showButtons, - showTooltip, - nativeAttributes, - }: DragHandleWrapperProps, - ref: React.Ref -) { - const dragHandleRef = useRef(null); +export default function DragHandleWrapper({ + directions, + tooltipText, + children, + onDirectionClick, + onTooltipDismiss, + showButtons, + showTooltip, + nativeAttributes, +}: DragHandleWrapperProps) { + const trackRef = useRef(null); return ( <> -
-
- {children} -
- - {showTooltip && } + {/* Rendered using "display: inline", but can be restyled. Mostly serves as a container for listening to events. */} +
+ {children} + {/* We want events like onPointerEnter/onPointerLeave to include the tooltip. */} + {showTooltip && }
- + {directions['block-start'] && ( ); } - -export default React.forwardRef(DragHandleWrapper); diff --git a/src/internal/components/drag-handle/styles.scss b/src/internal/components/drag-handle/styles.scss index bb6d14f89a..afe853ad31 100644 --- a/src/internal/components/drag-handle/styles.scss +++ b/src/internal/components/drag-handle/styles.scss @@ -92,6 +92,10 @@ $direction-button-offset: awsui.$space-static-xxs; } } +.drag-handle-wrapper { + display: inline; +} + .portal-overlay { position: absolute; inset-block-start: 0; @@ -109,10 +113,6 @@ $direction-button-offset: awsui.$space-static-xxs; pointer-events: auto; } -.drag-handle { - display: contents; -} - .direction-button-wrapper { position: absolute; block-size: $direction-button-size;