From 43d0d5aafc4327e982dd54846987d86d68827d57 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Wed, 24 Sep 2025 16:45:14 +0200 Subject: [PATCH 01/16] feat: Add UAP buttons to resizable table column headers --- .../components/drag-handle-wrapper/index.tsx | 86 +++--- .../drag-handle-wrapper/interfaces.ts | 1 + .../drag-handle-wrapper/styles.scss | 8 +- src/table/resizer/index.tsx | 291 +++++++++++------- src/table/resizer/styles.scss | 28 +- 5 files changed, 249 insertions(+), 165 deletions(-) diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle-wrapper/index.tsx index 00bbd83199..95646459d6 100644 --- a/src/internal/components/drag-handle-wrapper/index.tsx +++ b/src/internal/components/drag-handle-wrapper/index.tsx @@ -6,10 +6,9 @@ import clsx from 'clsx'; import { nodeContains } from '@cloudscape-design/component-toolkit/dom'; -import { getFirstFocusable } from '../focus-lock/utils'; import Tooltip from '../tooltip'; import DirectionButton from './direction-button'; -import { Direction, DragHandleWrapperProps } from './interfaces'; +import { DragHandleWrapperProps } from './interfaces'; import PortalOverlay from './portal-overlay'; import styles from './styles.css.js'; @@ -22,13 +21,14 @@ export default function DragHandleWrapper({ triggerMode = 'focus', initialShowButtons = false, controlledShowButtons = false, + wrapperClassName, hideButtonsOnDrag, clickDragThreshold, }: DragHandleWrapperProps) { const wrapperRef = useRef(null); const dragHandleRef = useRef(null); const [showTooltip, setShowTooltip] = useState(false); - const [showButtons, setShowButtons] = useState(initialShowButtons); + const [uncontrolledShowButtons, setUncontrolledShowButtons] = useState(initialShowButtons); const isPointerDown = useRef(false); const initialPointerPosition = useRef<{ x: number; y: number } | undefined>(); @@ -44,23 +44,23 @@ export default function DragHandleWrapper({ // 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 // handled in the "pointerup" listener instead. In cases where focus is moved - // to the button (by manually calling `.focus()`, the buttons should only appear) + // to the button (by manually calling `.focus()`), the buttons should only appear // if the action that triggered the focus move was the result of a keypress. if (document.body.dataset.awsuiFocusVisible && !nodeContains(wrapperRef.current, event.relatedTarget)) { setShowTooltip(false); if (triggerMode === 'focus') { - setShowButtons(true); + setUncontrolledShowButtons(true); } } }; const onWrapperFocusOut: 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 + // "focusout" is also triggered when the user switches to another tab, but // since it'll be returned when they switch back anyway, we exclude that // case by checking for `document.hasFocus()`. if (document.hasFocus() && !nodeContains(wrapperRef.current, event.relatedTarget)) { - setShowButtons(false); + setUncontrolledShowButtons(false); } }; @@ -87,7 +87,7 @@ export default function DragHandleWrapper({ ) { didPointerDrag.current = true; if (hideButtonsOnDrag) { - setShowButtons(false); + setUncontrolledShowButtons(false); } } }, @@ -115,7 +115,7 @@ export default function DragHandleWrapper({ if (isPointerDown.current && !didPointerDrag.current) { // The cursor didn't move much between "pointerdown" and "pointerup". // Handle this as a "click" instead of a "drag". - setShowButtons(true); + setUncontrolledShowButtons(true); } resetPointerDownState(); }, @@ -153,10 +153,10 @@ export default function DragHandleWrapper({ const onDragHandleKeyDown: React.KeyboardEventHandler = event => { // For accessibility reasons, pressing escape should always close the floating controls. if (event.key === 'Escape') { - setShowButtons(false); + setUncontrolledShowButtons(false); } else if (triggerMode === 'keyboard-activate' && (event.key === 'Enter' || event.key === ' ')) { // toggle buttons when Enter or space is pressed in 'keyboard-activate' triggerMode - setShowButtons(prevshowButtons => !prevshowButtons); + setUncontrolledShowButtons(prevshowButtons => !prevshowButtons); } else if ( event.key !== 'Alt' && event.key !== 'Control' && @@ -166,42 +166,40 @@ export default function DragHandleWrapper({ ) { // Pressing any other key will display the focus-visible ring around the // drag handle if it's in focus, so we should also show the buttons now. - setShowButtons(true); + setUncontrolledShowButtons(true); } }; - const onInternalDirectionClick = (direction: 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(); - } - onDirectionClick?.(direction); - }; - - const _showButtons = triggerMode === 'controlled' ? controlledShowButtons : showButtons; + const _showButtons = triggerMode === 'controlled' ? controlledShowButtons : uncontrolledShowButtons; return ( -
-
+ <> + {/* Wrapper for focus detection. The buttons are shown when any element inside this wrapper is + focused, either via the keyboard or a pointer press. The UAP buttons will never receive focus. */} +
+ {/* Wrapper for pointer detection. Determines whether or not the tooltip should be shown. */}
- {children} + {/* Position tracking wrapper used to position the tooltip and drag buttons accurately. + Its dimensions must match the inner button's dimensions. */} +
+ {children} +
+ + {!isDisabled && !_showButtons && showTooltip && tooltipText && ( + // Rendered in a portal but pointerenter/pointerleave events still propagate + // up the React DOM tree, which is why it's placed in this nested context. + setShowTooltip(false)} /> + )}
- - {!isDisabled && !_showButtons && showTooltip && tooltipText && ( - setShowTooltip(false)} /> - )}
@@ -210,7 +208,7 @@ export default function DragHandleWrapper({ show={!isDisabled && _showButtons} direction="block-start" state={directions['block-start']} - onClick={() => onInternalDirectionClick('block-start')} + onClick={() => onDirectionClick?.('block-start')} /> )} {directions['block-end'] && ( @@ -218,7 +216,7 @@ export default function DragHandleWrapper({ show={!isDisabled && _showButtons} direction="block-end" state={directions['block-end']} - onClick={() => onInternalDirectionClick('block-end')} + onClick={() => onDirectionClick?.('block-end')} /> )} {directions['inline-start'] && ( @@ -226,7 +224,7 @@ export default function DragHandleWrapper({ show={!isDisabled && _showButtons} direction="inline-start" state={directions['inline-start']} - onClick={() => onInternalDirectionClick('inline-start')} + onClick={() => onDirectionClick?.('inline-start')} /> )} {directions['inline-end'] && ( @@ -234,10 +232,10 @@ export default function DragHandleWrapper({ show={!isDisabled && _showButtons} direction="inline-end" state={directions['inline-end']} - onClick={() => onInternalDirectionClick('inline-end')} + onClick={() => onDirectionClick?.('inline-end')} /> )} -
+ ); } diff --git a/src/internal/components/drag-handle-wrapper/interfaces.ts b/src/internal/components/drag-handle-wrapper/interfaces.ts index 342eed4c07..abe18f9afb 100644 --- a/src/internal/components/drag-handle-wrapper/interfaces.ts +++ b/src/internal/components/drag-handle-wrapper/interfaces.ts @@ -10,6 +10,7 @@ export interface DragHandleWrapperProps { onDirectionClick?: (direction: Direction) => void; tooltipText?: string; children: React.ReactNode; + wrapperClassName?: string; triggerMode?: TriggerMode; initialShowButtons?: boolean; controlledShowButtons?: boolean; diff --git a/src/internal/components/drag-handle-wrapper/styles.scss b/src/internal/components/drag-handle-wrapper/styles.scss index 0015d1665d..3a230be414 100644 --- a/src/internal/components/drag-handle-wrapper/styles.scss +++ b/src/internal/components/drag-handle-wrapper/styles.scss @@ -11,9 +11,8 @@ $direction-button-wrapper-size: calc(#{awsui.$space-static-xl} + 2 * #{awsui.$sp $direction-button-size: awsui.$space-static-xl; $direction-button-offset: awsui.$space-static-xxs; -.drag-handle-wrapper { - position: relative; - display: inline-block; +.contents { + display: contents; } .portal-overlay { @@ -34,8 +33,7 @@ $direction-button-offset: awsui.$space-static-xxs; } .drag-handle { - position: relative; - display: flex; + display: inline-flex; } .direction-button-wrapper { diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index cee0f17b61..92a2108822 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -1,12 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { useStableCallback, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import { getIsRtl, getLogicalBoundingClientRect, getLogicalPageX } from '@cloudscape-design/component-toolkit/internal'; import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import DragHandleWrapper from '../../internal/components/drag-handle-wrapper'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { KeyCode } from '../../internal/keycode'; import handleKey, { isEventLike } from '../../internal/utils/handle-key'; @@ -53,7 +54,9 @@ export function Resizer({ const resizerToggleRef = useRef(null); const resizerSeparatorRef = useRef(null); + const [isPointerDown, setIsPointerDown] = useState(false); const [isDragging, setIsDragging] = useState(false); + const [showUapButtons, setShowUapButtons] = useState(false); const [isKeyboardDragging, setIsKeyboardDragging] = useState(false); const autoGrowTimeout = useRef | undefined>(); const [resizerHasFocus, setResizerHasFocus] = useState(false); @@ -64,44 +67,63 @@ export function Resizer({ setHeaderCellWidth(getHeaderWidth(resizerToggleRef.current)); }, []); - useEffect(() => { + const updateTrackerPosition = useCallback((newOffset: number) => { const elements = getResizerElements(resizerToggleRef.current); - const document = resizerToggleRef.current?.ownerDocument ?? window.document; - - if ((!isDragging && !resizerHasFocus) || !elements) { + if (!elements) { return; } - const { insetInlineStart: inlineStartEdge, insetInlineEnd: inlineEndEdge } = getLogicalBoundingClientRect( - elements.scrollParent - ); + const { insetInlineStart: scrollParentInsetInlineStart } = getLogicalBoundingClientRect(elements.table); + elements.tracker.style.insetBlockStart = getLogicalBoundingClientRect(elements.header).blockSize + 'px'; + // minus one pixel to offset the cell border + elements.tracker.style.insetInlineStart = newOffset - scrollParentInsetInlineStart - 1 + 'px'; + }, []); - const updateTrackerPosition = (newOffset: number) => { - const { insetInlineStart: scrollParentInsetInlineStart } = getLogicalBoundingClientRect(elements.table); - elements.tracker.style.insetBlockStart = getLogicalBoundingClientRect(elements.header).blockSize + 'px'; - // minus one pixel to offset the cell border - elements.tracker.style.insetInlineStart = newOffset - scrollParentInsetInlineStart - 1 + 'px'; - }; + const updateColumnWidth = useCallback( + (newWidth: number) => { + const elements = getResizerElements(resizerToggleRef.current); + if (!elements) { + return; + } - const updateColumnWidth = (newWidth: number) => { const { insetInlineEnd, inlineSize } = getLogicalBoundingClientRect(elements.header); const updatedWidth = newWidth < minWidth ? minWidth : newWidth; updateTrackerPosition(insetInlineEnd + updatedWidth - inlineSize); - if (newWidth >= minWidth) { - setHeaderCellWidth(newWidth); - } + setHeaderCellWidth(updatedWidth); + // callbacks must be the last calls in the handler, because they may cause an extra update onWidthUpdate(newWidth); - }; + }, + [minWidth, onWidthUpdate, updateTrackerPosition] + ); + + const resizeColumn = useCallback( + (offset: number) => { + const elements = getResizerElements(resizerToggleRef.current); + if (!elements) { + return; + } - const resizeColumn = (offset: number) => { + const { insetInlineStart: inlineStartEdge } = getLogicalBoundingClientRect(elements.scrollParent); if (offset > inlineStartEdge) { const cellLeft = getLogicalBoundingClientRect(elements.header).insetInlineStart; const newWidth = offset - cellLeft; // callbacks must be the last calls in the handler, because they may cause an extra update updateColumnWidth(newWidth); } - }; + }, + [updateColumnWidth] + ); + + useEffect(() => { + const elements = getResizerElements(resizerToggleRef.current); + const document = resizerToggleRef.current?.ownerDocument ?? window.document; + + if ((!isPointerDown && !resizerHasFocus) || !elements) { + return; + } + + const { insetInlineEnd: inlineEndEdge } = getLogicalBoundingClientRect(elements.scrollParent); const onAutoGrow = () => { const inlineSize = getLogicalBoundingClientRect(elements.header).inlineSize; @@ -111,7 +133,8 @@ export function Resizer({ elements.scrollParent.scrollLeft += AUTO_GROW_INCREMENT * (getIsRtl(elements.scrollParent) ? -1 : 1); }; - const onMouseMove = (event: MouseEvent) => { + const onPointerMove = (event: MouseEvent) => { + setIsDragging(true); clearTimeout(autoGrowTimeout.current); const offset = getLogicalPageX(event); if (offset > inlineEndEdge) { @@ -121,8 +144,13 @@ export function Resizer({ } }; - const onMouseUp = (event: MouseEvent) => { - resizeColumn(getLogicalPageX(event)); + const onPointerUp = (event: MouseEvent) => { + setIsPointerDown(false); + if (isDragging) { + resizeColumn(getLogicalPageX(event)); + } else { + setShowUapButtons(true); + } setIsDragging(false); onWidthUpdateCommit(); clearTimeout(autoGrowTimeout.current); @@ -139,29 +167,42 @@ export function Resizer({ handleKey(event, { onActivate: () => { setIsKeyboardDragging(false); + setShowUapButtons(false); resizerToggleRef.current?.focus(); }, onEscape: () => { setIsKeyboardDragging(false); + setShowUapButtons(false); resizerToggleRef.current?.focus(); }, - onInlineStart: () => updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize - 10), - onInlineEnd: () => updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize + 10), + onInlineStart: () => { + updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize - 10); + }, + onInlineEnd: () => { + updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize + 10); + }, }); } } } // Enter keyboard dragging mode - else if (event.keyCode === KeyCode.enter || event.keyCode === KeyCode.space) { - event.preventDefault(); - - if (isEventLike(event)) { - handleKey(event, { - onActivate: () => { - setIsKeyboardDragging(true); - resizerSeparatorRef.current?.focus(); - }, - }); + else { + if (event.keyCode === KeyCode.enter || event.keyCode === KeyCode.space) { + event.preventDefault(); + + if (isEventLike(event)) { + handleKey(event, { + onActivate: () => { + setShowUapButtons(true); + setIsKeyboardDragging(true); + resizerSeparatorRef.current?.focus(); + }, + }); + } + } else { + // Showing the UAP buttons when the button is only focused and not activated + // gives a false impression that you can resize with the arrow keys. + setShowUapButtons(false); } } }; @@ -169,12 +210,11 @@ export function Resizer({ updateTrackerPosition(getLogicalBoundingClientRect(elements.header).insetInlineEnd); const controller = new AbortController(); - if (isDragging) { + if (isPointerDown) { document.body.classList.add(styles['resize-active']); - document.addEventListener('mousemove', onMouseMove, { signal: controller.signal }); - document.addEventListener('mouseup', onMouseUp, { signal: controller.signal }); - } - if (resizerHasFocus) { + document.addEventListener('pointermove', onPointerMove, { signal: controller.signal }); + document.addEventListener('pointerup', onPointerUp, { signal: controller.signal }); + } else if (resizerHasFocus) { document.body.classList.add(styles['resize-active-with-focus']); elements.header.addEventListener('keydown', onKeyDown, { signal: controller.signal }); } @@ -188,79 +228,118 @@ export function Resizer({ document.body.classList.remove(styles['resize-active-with-focus']); controller.abort(); }; - }, [minWidth, isDragging, isKeyboardDragging, resizerHasFocus, onWidthUpdate, onWidthUpdateCommit]); + }, [ + minWidth, + isDragging, + isKeyboardDragging, + resizerHasFocus, + onWidthUpdate, + onWidthUpdateCommit, + updateTrackerPosition, + updateColumnWidth, + resizeColumn, + isPointerDown, + ]); const { tabIndex: resizerTabIndex } = useSingleTabStopNavigation(resizerToggleRef, { tabIndex }); return ( - <> -
); } diff --git a/src/table/resizer/styles.scss b/src/table/resizer/styles.scss index 4e781162d7..4074c6c3ee 100644 --- a/src/table/resizer/styles.scss +++ b/src/table/resizer/styles.scss @@ -16,6 +16,18 @@ $handle-width: awsui.$space-xl; $active-separator-width: 2px; +.resizer-wrapper { + inset-block: 0; + position: absolute; + inset-inline-end: calc(-1 * #{$handle-width} / 2); + inline-size: $handle-width; + z-index: 10; +} + +.resizer-button-wrapper { + block-size: 100%; +} + th:not(:last-child) > .divider, .divider-interactive { $gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); @@ -32,6 +44,11 @@ th:not(:last-child) > .divider, margin-inline: auto; border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-interactive-default; box-sizing: border-box; +} + +.divider-interactive { + $gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); + inset-inline-end: calc(#{$handle-width} / 2); &-disabled { border-inline-start-color: awsui.$color-border-divider-default; @@ -41,10 +58,6 @@ th:not(:last-child) > .divider, } } -th:last-child > .divider-interactive:not(.is-visual-refresh) { - inset-inline-end: calc(#{$handle-width} / 2); -} - .resizer { @include styles.styles-reset; border-block: none; @@ -52,10 +65,8 @@ th:last-child > .divider-interactive:not(.is-visual-refresh) { background: none; inset-block: 0; cursor: col-resize; - position: absolute; - inset-inline-end: calc(-1 * #{$handle-width} / 2); + block-size: 100%; inline-size: $handle-width; - z-index: 10; &:focus { outline: none; text-decoration: none; @@ -70,9 +81,6 @@ th:last-child > .divider-interactive:not(.is-visual-refresh) { &.has-focus { @include focus-visible.when-visible-unfocused { @include styles.focus-highlight(calc(#{awsui.$space-table-header-focus-outline-gutter} - 2px)); - & { - position: absolute; - } } } } From e5d613b2546eb4f3978b4a08e9ac1d246199df05 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Sep 2025 10:25:57 +0200 Subject: [PATCH 02/16] Fix tests. --- .../__tests__/columns-auto-resize.test.tsx | 33 ++--- .../__tests__/resizable-columns.test.tsx | 127 ++++++++---------- src/table/__tests__/utils/resize-actions.ts | 33 +++-- src/table/resizer/index.tsx | 28 ++-- src/table/resizer/styles.scss | 13 +- 5 files changed, 119 insertions(+), 115 deletions(-) diff --git a/src/table/__tests__/columns-auto-resize.test.tsx b/src/table/__tests__/columns-auto-resize.test.tsx index 297c8d619f..b28037f3f5 100644 --- a/src/table/__tests__/columns-auto-resize.test.tsx +++ b/src/table/__tests__/columns-auto-resize.test.tsx @@ -5,15 +5,12 @@ import { act, render } from '@testing-library/react'; import Table, { TableProps } from '../../../lib/components/table'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { fakeBoundingClientRect, fireMousedown, fireMouseMove, fireMouseup } from './utils/resize-actions'; +import { fakeBoundingClientRect, firePointerdown, firePointermove, firePointerup } from './utils/resize-actions'; let overflowParent: HTMLElement | null = null; jest.mock('../../../lib/components/internal/utils/scrollable-containers', () => ({ browserScrollbarSize: () => ({ width: 20, height: 20 }), getOverflowParents: jest.fn(() => { - overflowParent = document.createElement('div'); - overflowParent.style.width = '400px'; - overflowParent.getBoundingClientRect = fakeBoundingClientRect; return [overflowParent]; }), })); @@ -45,6 +42,12 @@ function tick() { }); } +beforeEach(() => { + overflowParent = document.createElement('div'); + overflowParent.style.width = '400px'; + overflowParent.getBoundingClientRect = fakeBoundingClientRect; +}); + afterEach(() => { jest.useRealTimers(); }); @@ -52,8 +55,8 @@ afterEach(() => { test('should not auto-grow when the cursor stops inside table container', () => { const { wrapper } = renderTable(); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(50); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(50); const widthBefore = wrapper.findColumnHeaders()[0].getElement().style.width; tick(); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: widthBefore }); @@ -63,8 +66,8 @@ test('should auto-grow the column width when the cursor moves out of table bound const onChange = jest.fn(); const { wrapper } = renderTable(
onChange(event.detail)} />); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(450); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(450); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); expect(overflowParent!.scrollLeft).toEqual(0); tick(); @@ -73,7 +76,7 @@ test('should auto-grow the column width when the cursor moves out of table bound tick(); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '160px' }); expect(overflowParent!.scrollLeft).toEqual(10); - fireMouseup(160); + firePointerup(160); tick(); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '160px' }); expect(onChange).toHaveBeenCalledTimes(1); @@ -83,12 +86,12 @@ test('should auto-grow the column width when the cursor moves out of table bound test('should cancel auto-grow when the cursor returns back into the container', () => { const { wrapper } = renderTable(
); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(450); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(450); tick(); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '155px' }); tick(); - fireMouseMove(390); + firePointermove(390); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '390px' }); tick(); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '390px' }); @@ -97,11 +100,11 @@ test('should cancel auto-grow when the cursor returns back into the container', test('should continue auto-growing when cursor moves and then stops outside of the container again', () => { const { wrapper } = renderTable(
); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(450); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(450); tick(); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '155px' }); - fireMouseMove(410); + firePointermove(410); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '155px' }); tick(); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '160px' }); diff --git a/src/table/__tests__/resizable-columns.test.tsx b/src/table/__tests__/resizable-columns.test.tsx index bbbff86ecd..df467d7591 100644 --- a/src/table/__tests__/resizable-columns.test.tsx +++ b/src/table/__tests__/resizable-columns.test.tsx @@ -10,7 +10,7 @@ import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; import Table, { TableProps } from '../../../lib/components/table'; import createWrapper, { TableWrapper } from '../../../lib/components/test-utils/dom'; -import { fakeBoundingClientRect, fireMousedown, fireMouseMove, fireMouseup } from './utils/resize-actions'; +import { fakeBoundingClientRect, firePointerdown, firePointermove, firePointerup } from './utils/resize-actions'; import resizerStyles from '../../../lib/components/table/resizer/styles.css.js'; @@ -85,10 +85,10 @@ test('should allow dragging a column only with the left mouse button', () => { const rightButton = 1; expect(hasGlobalResizeClass()).toEqual(false); - fireMousedown(wrapper.findColumnResizer(1)!, rightButton); + firePointerdown(wrapper.findColumnResizer(1)!, rightButton); expect(hasGlobalResizeClass()).toEqual(false); - fireMousedown(wrapper.findColumnResizer(1)!, leftButton); + firePointerdown(wrapper.findColumnResizer(1)!, leftButton); expect(hasGlobalResizeClass()).toEqual(true); }); @@ -113,34 +113,34 @@ test('should use the default width if it is not provided to a column and the col expect(wrapper.findColumnHeaders()[2].getElement()).toHaveStyle({ width: '120px' }); }); -test('should show the tracking line and activate resizer onMouseDown', () => { +test('should show the tracking line and activate resizer onPointerDown', () => { const { wrapper } = renderTable(
); expect(findActiveDivider(wrapper)).toBeNull(); expect(hasGlobalResizeClass()).toEqual(false); - fireMousedown(wrapper.findColumnResizer(1)!); + firePointerdown(wrapper.findColumnResizer(1)!); expect(findActiveDivider(wrapper)).not.toBeNull(); expect(hasGlobalResizeClass()).toEqual(true); - fireMouseup(150); + firePointerup(150); expect(findActiveDivider(wrapper)).toBeNull(); expect(hasGlobalResizeClass()).toEqual(false); }); -test('should attach event listeners to the body on mousedown and remove on mouseup', () => { +test('should attach event listeners to the body on pointerdown and remove on pointerup', () => { const { wrapper } = renderTable(
); jest.spyOn(document, 'addEventListener'); jest.spyOn(AbortController.prototype, 'abort'); expect(document.addEventListener).toHaveBeenCalledTimes(0); - fireMousedown(wrapper.findColumnResizer(1)!); + firePointerdown(wrapper.findColumnResizer(1)!); expect(document.addEventListener).toHaveBeenCalledTimes(2); - expect(document.addEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function), expect.any(Object)); - expect(document.addEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function), expect.any(Object)); + expect(document.addEventListener).toHaveBeenCalledWith('pointermove', expect.any(Function), expect.any(Object)); + expect(document.addEventListener).toHaveBeenCalledWith('pointerup', expect.any(Function), expect.any(Object)); expect(AbortController.prototype.abort).toHaveBeenCalledTimes(0); (document.addEventListener as jest.Mock).mockReset(); - fireMouseup(200); + firePointerup(200); expect(document.addEventListener).toHaveBeenCalledTimes(0); expect(AbortController.prototype.abort).toHaveBeenCalledTimes(1); }); @@ -154,17 +154,17 @@ test('should correctly handle a column with special character', () => { ], }; const { wrapper } = renderTable(
); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseup(150); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointerup(150); }); test('should resize column to grow', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(200); - fireMouseup(200); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(200); + firePointerup(200); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '200px' }); }); @@ -172,9 +172,9 @@ test('should resize column to shrink', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(130); - fireMouseup(130); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(130); + firePointerup(130); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '130px' }); }); @@ -182,9 +182,9 @@ test('should not allow to resize column below the min width', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(10); - fireMouseup(10); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(10); + firePointerup(10); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '80px' }); }); @@ -192,8 +192,8 @@ test('should to resize column beyond the screen bounds', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(-10); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(-10); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); }); @@ -205,9 +205,9 @@ test('should not allow to resize column below 120px if min width is not defined' const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(100); - fireMouseup(100); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(100); + firePointerup(100); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '120px' }); }); @@ -219,9 +219,9 @@ test('takes width as min width if it is less than 120px and min width is not set const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '100px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(100); - fireMouseup(100); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(100); + firePointerup(100); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '100px' }); }); @@ -229,23 +229,14 @@ test('should follow along each mouse move event', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(200); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(200); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '200px' }); - fireMouseMove(250); + firePointermove(250); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '250px' }); - fireMouseMove(200); + firePointermove(200); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '200px' }); - fireMouseup(200); - expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '200px' }); -}); - -test('should allow column resize with missing mousemove event', () => { - const { wrapper } = renderTable(
); - expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseup(200); + firePointerup(200); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '200px' }); }); @@ -253,9 +244,9 @@ test('should trigger the columnWidthsChange event after a column is resized', () const onChange = jest.fn(); const { wrapper } = renderTable(
onChange(event.detail)} />); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(100); - fireMouseup(100); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(100); + firePointerup(100); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({ widths: [100, 300] }); @@ -269,9 +260,9 @@ test('should provide the value for the last column when it was not defined', () const onChange = jest.fn(); const { wrapper } = renderTable(
onChange(event.detail)} />); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(100); - fireMouseup(100); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(100); + firePointerup(100); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({ widths: [100, 300, 120] }); @@ -290,9 +281,9 @@ test('should include hidden columns into the event detail', () => { }; const { wrapper } = renderTable(
onChange(event.detail)} />); - fireMousedown(wrapper.findColumnResizer(2)!); - fireMouseMove(140); - fireMouseup(140); + firePointerdown(wrapper.findColumnResizer(2)!); + firePointermove(140); + firePointerup(140); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({ widths: [150, 300, 120, 140] }); @@ -302,9 +293,9 @@ test('should update the value for the last column when it is resized', () => { const onChange = jest.fn(); const { wrapper } = renderTable(
onChange(event.detail)} />); - fireMousedown(wrapper.findColumnResizer(2)!); - fireMouseMove(400); - fireMouseup(400); + firePointerdown(wrapper.findColumnResizer(2)!); + firePointermove(400); + firePointerup(400); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({ widths: [150, 400] }); @@ -314,9 +305,9 @@ test('should not trigger if the previous and the current widths are the same', ( const onChange = jest.fn(); const { wrapper } = renderTable(
onChange(event.detail)} />); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(150); - fireMouseup(150); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(150); + firePointerup(150); expect(onChange).toHaveBeenCalledTimes(0); }); @@ -500,9 +491,9 @@ describe('resize in rtl', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(-200); - fireMouseup(-200); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(-200); + firePointerup(-200); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '200px' }); }); @@ -510,9 +501,9 @@ describe('resize in rtl', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(-130); - fireMouseup(-130); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(-130); + firePointerup(-130); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '130px' }); }); @@ -520,9 +511,9 @@ describe('resize in rtl', () => { const { wrapper } = renderTable(
); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '150px' }); - fireMousedown(wrapper.findColumnResizer(1)!); - fireMouseMove(-10); - fireMouseup(-10); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(-10); + firePointerup(-10); expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '80px' }); }); }); diff --git a/src/table/__tests__/utils/resize-actions.ts b/src/table/__tests__/utils/resize-actions.ts index 27562f14b1..99ff518c82 100644 --- a/src/table/__tests__/utils/resize-actions.ts +++ b/src/table/__tests__/utils/resize-actions.ts @@ -2,31 +2,28 @@ // SPDX-License-Identifier: Apache-2.0 import { act } from '@testing-library/react'; +import { PointerEventMock } from '../../../../lib/components/internal/utils/pointer-events-mock'; import { ElementWrapper } from '../../../../lib/components/test-utils/dom'; -export function fireMousedown(wrapper: ElementWrapper, button = 0) { +beforeAll(() => { + (window as any).PointerEvent ??= PointerEventMock; +}); + +export function firePointerdown(wrapper: ElementWrapper, button = 0) { act(() => { - wrapper.fireEvent(new MouseEvent('mousedown', { button, bubbles: true })); + wrapper.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button, bubbles: true })); }); } -function createMouseEvent(name: string, pageX: number) { - const event = new MouseEvent(name, { bubbles: true }); - // pageX is not supported in JSDOM - // https://github.com/jsdom/jsdom/issues/1911 - Object.defineProperty(event, 'pageX', { value: pageX }); - return event; -} - -export function fireMouseMove(pageX: number) { +export function firePointermove(pageX: number) { act(() => { - document.body.dispatchEvent(createMouseEvent('mousemove', pageX)); + document.body.dispatchEvent(createPointerEvent('pointermove', pageX)); }); } -export function fireMouseup(pageX: number) { +export function firePointerup(pageX: number) { act(() => { - document.body.dispatchEvent(createMouseEvent('mouseup', pageX)); + document.body.dispatchEvent(createPointerEvent('pointerup', pageX)); }); } @@ -40,3 +37,11 @@ export function fakeBoundingClientRect(this: HTMLElement): DOMRect { right: rect.left + width, }; } + +function createPointerEvent(name: string, pageX: number) { + const event = new PointerEvent(name, { pointerType: 'mouse', bubbles: true }); + // pageX is not supported in JSDOM + // https://github.com/jsdom/jsdom/issues/1911 + Object.defineProperty(event, 'pageX', { value: pageX }); + return event; +} diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 92a2108822..43aa218453 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -133,7 +133,7 @@ export function Resizer({ elements.scrollParent.scrollLeft += AUTO_GROW_INCREMENT * (getIsRtl(elements.scrollParent) ? -1 : 1); }; - const onPointerMove = (event: MouseEvent) => { + const onPointerMove = (event: PointerEvent) => { setIsDragging(true); clearTimeout(autoGrowTimeout.current); const offset = getLogicalPageX(event); @@ -144,14 +144,14 @@ export function Resizer({ } }; - const onPointerUp = (event: MouseEvent) => { + const onPointerUp = (event: PointerEvent) => { setIsPointerDown(false); if (isDragging) { resizeColumn(getLogicalPageX(event)); } else { + setIsDragging(false); setShowUapButtons(true); } - setIsDragging(false); onWidthUpdateCommit(); clearTimeout(autoGrowTimeout.current); }; @@ -223,24 +223,27 @@ export function Resizer({ } return () => { - clearTimeout(autoGrowTimeout.current); document.body.classList.remove(styles['resize-active']); document.body.classList.remove(styles['resize-active-with-focus']); controller.abort(); }; }, [ - minWidth, isDragging, isKeyboardDragging, + isPointerDown, resizerHasFocus, - onWidthUpdate, onWidthUpdateCommit, - updateTrackerPosition, - updateColumnWidth, resizeColumn, - isPointerDown, + updateColumnWidth, + updateTrackerPosition, ]); + useEffect(() => { + if (isDragging) { + return () => clearTimeout(autoGrowTimeout.current); + } + }, [isDragging]); + const { tabIndex: resizerTabIndex } = useSingleTabStopNavigation(resizerToggleRef, { tabIndex }); return ( @@ -276,9 +279,10 @@ export function Resizer({ isVisualRefresh && styles['is-visual-refresh'] )} onPointerDown={event => { - if (event.button === 0) { - setIsPointerDown(true); + if (event.pointerType === 'mouse' && event.button !== 0) { + return; } + setIsPointerDown(true); }} onClick={() => { // Prevent mouse drag activation and activate keyboard dragging for VO+Space click. @@ -312,7 +316,7 @@ export function Resizer({ .divider, .divider-interactive { $gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); inset-inline-end: calc(#{$handle-width} / 2); +} - &-disabled { - border-inline-start-color: awsui.$color-border-divider-default; - } - &-active { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; - } +.divider-disabled { + border-inline-start-color: awsui.$color-border-divider-default; +} + +.divider-active { + border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; } .resizer { From c3df57e4ebe4366561db1c74006ca83a30871fa1 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Sep 2025 10:53:08 +0200 Subject: [PATCH 03/16] Restore implicit z-index behavior relied on by split panel. --- src/internal/components/drag-handle-wrapper/styles.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/components/drag-handle-wrapper/styles.scss b/src/internal/components/drag-handle-wrapper/styles.scss index 3a230be414..6d940e01fa 100644 --- a/src/internal/components/drag-handle-wrapper/styles.scss +++ b/src/internal/components/drag-handle-wrapper/styles.scss @@ -33,6 +33,7 @@ $direction-button-offset: awsui.$space-static-xxs; } .drag-handle { + position: relative; display: inline-flex; } From 6b6117d12429497374a61c1280d1e66aff685378 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Sep 2025 12:54:09 +0200 Subject: [PATCH 04/16] Fix isDragging stuck to true after drag ends. --- .../components/drag-handle-wrapper/index.tsx | 14 +++++++------- src/table/resizer/index.tsx | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle-wrapper/index.tsx index 95646459d6..bd350c1bf9 100644 --- a/src/internal/components/drag-handle-wrapper/index.tsx +++ b/src/internal/components/drag-handle-wrapper/index.tsx @@ -170,7 +170,7 @@ export default function DragHandleWrapper({ } }; - const _showButtons = triggerMode === 'controlled' ? controlledShowButtons : uncontrolledShowButtons; + const showButtons = triggerMode === 'controlled' ? controlledShowButtons : uncontrolledShowButtons; return ( <> @@ -194,7 +194,7 @@ export default function DragHandleWrapper({ {children} - {!isDisabled && !_showButtons && showTooltip && tooltipText && ( + {!isDisabled && !showButtons && showTooltip && tooltipText && ( // Rendered in a portal but pointerenter/pointerleave events still propagate // up the React DOM tree, which is why it's placed in this nested context. setShowTooltip(false)} /> @@ -202,10 +202,10 @@ export default function DragHandleWrapper({ - + {directions['block-start'] && ( onDirectionClick?.('block-start')} @@ -213,7 +213,7 @@ export default function DragHandleWrapper({ )} {directions['block-end'] && ( onDirectionClick?.('block-end')} @@ -221,7 +221,7 @@ export default function DragHandleWrapper({ )} {directions['inline-start'] && ( onDirectionClick?.('inline-start')} @@ -229,7 +229,7 @@ export default function DragHandleWrapper({ )} {directions['inline-end'] && ( onDirectionClick?.('inline-end')} diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 43aa218453..c6c4e9fd41 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -134,6 +134,7 @@ export function Resizer({ }; const onPointerMove = (event: PointerEvent) => { + // TODO: Only set it to true after a certain number of pixels travelled? setIsDragging(true); clearTimeout(autoGrowTimeout.current); const offset = getLogicalPageX(event); @@ -147,9 +148,9 @@ export function Resizer({ const onPointerUp = (event: PointerEvent) => { setIsPointerDown(false); if (isDragging) { + setIsDragging(false); resizeColumn(getLogicalPageX(event)); } else { - setIsDragging(false); setShowUapButtons(true); } onWidthUpdateCommit(); From 9d2d3b9dbdc65b9bf4f342a9369c40b73844882f Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Sep 2025 15:34:21 +0200 Subject: [PATCH 05/16] Add test coverage. --- .../drag-handle-wrapper/direction-button.tsx | 2 +- .../components/drag-handle-wrapper/index.tsx | 8 +- .../__tests__/resizable-columns.test.tsx | 120 ++++++++++++++++++ src/table/resizer/index.tsx | 18 +-- src/test-utils/dom/internal/drag-handle.ts | 12 +- 5 files changed, 143 insertions(+), 17 deletions(-) diff --git a/src/internal/components/drag-handle-wrapper/direction-button.tsx b/src/internal/components/drag-handle-wrapper/direction-button.tsx index ccc98e12e5..609eb8091a 100644 --- a/src/internal/components/drag-handle-wrapper/direction-button.tsx +++ b/src/internal/components/drag-handle-wrapper/direction-button.tsx @@ -50,7 +50,7 @@ export default function DirectionButton({ direction, state, show, onClick }: Dir styles['direction-button'], state === 'disabled' && styles['direction-button-disabled'], testUtilsStyles[`direction-button-${direction}`], - transitionState !== 'exited' && testUtilsStyles['direction-button-visible'] + !['exiting', 'exited'].includes(transitionState) && testUtilsStyles['direction-button-visible'] )} onClick={state !== 'disabled' ? onClick : undefined} // This prevents focus from being lost to `document.body` on diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle-wrapper/index.tsx index bd350c1bf9..e12c80c96c 100644 --- a/src/internal/components/drag-handle-wrapper/index.tsx +++ b/src/internal/components/drag-handle-wrapper/index.tsx @@ -12,6 +12,7 @@ import { DragHandleWrapperProps } from './interfaces'; import PortalOverlay from './portal-overlay'; import styles from './styles.css.js'; +import testUtilsStyles from './test-classes/styles.css.js'; export default function DragHandleWrapper({ directions, @@ -176,7 +177,12 @@ export default function DragHandleWrapper({ <> {/* Wrapper for focus detection. The buttons are shown when any element inside this wrapper is focused, either via the keyboard or a pointer press. The UAP buttons will never receive focus. */} -
+
{/* Wrapper for pointer detection. Determines whether or not the tooltip should be shown. */}
{ jest.restoreAllMocks(); }); @@ -517,3 +522,118 @@ describe('resize in rtl', () => { expect(wrapper.findColumnHeaders()[0].getElement()).toHaveStyle({ width: '80px' }); }); }); + +describe('UAP buttons', () => { + // Makes the drag buttons (which are positioned in a portal) easier to find if there's only one set. + const singleColumnDefinition = [{ id: 'id', header: 'Id', cell: (item: any) => item.id, width: 150, minWidth: 80 }]; + + let mockWidth = 150; + + const originalBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + beforeEach(() => { + HTMLElement.prototype.getBoundingClientRect = function () { + const rect = originalBoundingClientRect.apply(this); + if (this.tagName === 'TH') { + rect.width = mockWidth; + } + return rect; + }; + }); + + afterEach(() => { + mockWidth = 150; + HTMLElement.prototype.getBoundingClientRect = originalBoundingClientRect; + }); + + test('hides UAP buttons by default', () => { + renderTable(
); + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeNull(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeNull(); + }); + + test('shows UAP buttons when clicked', () => { + const { wrapper } = renderTable(
); + firePointerdown(wrapper.findColumnResizer(1)!); + firePointerup(0); + + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeTruthy(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeTruthy(); + }); + + test('shows UAP buttons when activated with keyboard', () => { + const { wrapper } = renderTable(
); + + wrapper.findColumnResizer(1)!.focus(); + wrapper.findColumnResizer(1)!.keydown(KeyCode.enter); + + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeTruthy(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeTruthy(); + }); + + test('hides UAP buttons when Escape is pressed after a pointer interaction', () => { + const { wrapper } = renderTable(
); + + wrapper.findColumnResizer(1)!.click(); + wrapper.findColumnResizer(1)!.focus(); + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeTruthy(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeTruthy(); + + wrapper.findColumnResizer(1)!.keydown(KeyCode.escape); + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeNull(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeNull(); + }); + + test('hides UAP buttons when arrow keys are pressed before the resizer button is activated', () => { + const { wrapper } = renderTable(
); + + wrapper.findColumnResizer(1)!.click(); + wrapper.findColumnResizer(1)!.focus(); + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeTruthy(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeTruthy(); + + wrapper.findColumnResizer(1)!.keydown(KeyCode.left); + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeNull(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeNull(); + }); + + test('does not show UAP buttons when the pointer is moved between pointerdown and pointerup', () => { + const { wrapper } = renderTable(
); + + firePointerdown(wrapper.findColumnResizer(1)!); + firePointermove(200); + firePointerup(200); + + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeNull(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeNull(); + }); + + test('resizes the column when inline-start button is pressed', async () => { + const onChange = jest.fn(); + const { wrapper } = renderTable( +
onChange(event.detail)} + /> + ); + + wrapper.findColumnResizer(1)!.click(); + findDragHandle().findVisibleDirectionButtonInlineStart()!.click(); + await waitFor(() => expect(onChange).toHaveBeenCalledWith({ widths: [130] })); + }); + + test('resizes the column when inline-end button is pressed', async () => { + const onChange = jest.fn(); + const { wrapper } = renderTable( +
onChange(event.detail)} + /> + ); + + wrapper.findColumnResizer(1)!.click(); + findDragHandle().findVisibleDirectionButtonInlineEnd()!.click(); + await waitFor(() => expect(onChange).toHaveBeenCalledWith({ widths: [170] })); + }); +}); diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index c6c4e9fd41..6c7273f344 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -185,10 +185,9 @@ export function Resizer({ }); } } - } - // Enter keyboard dragging mode - else { + } else { if (event.keyCode === KeyCode.enter || event.keyCode === KeyCode.space) { + // Enter keyboard dragging mode event.preventDefault(); if (isEventLike(event)) { @@ -266,9 +265,11 @@ export function Resizer({ } if (direction === 'inline-start') { - updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize - 10); + updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize - 20); + requestAnimationFrame(onWidthUpdateCommit); } else if (direction === 'inline-end') { - updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize + 10); + updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize + 20); + requestAnimationFrame(onWidthUpdateCommit); } }} > @@ -289,6 +290,7 @@ export function Resizer({ // Prevent mouse drag activation and activate keyboard dragging for VO+Space click. setIsPointerDown(false); setIsDragging(false); + setShowUapButtons(true); setResizerHasFocus(true); setIsKeyboardDragging(true); resizerSeparatorRef.current?.focus(); @@ -333,11 +335,9 @@ export function Resizer({ aria-valuenow={headerCellWidth} data-focus-id={focusId} onBlur={event => { - setResizerHasFocus(false); - if (isKeyboardDragging) { - setIsKeyboardDragging(false); - } + setIsKeyboardDragging(false); if (event.relatedTarget !== resizerToggleRef.current) { + setResizerHasFocus(false); setShowUapButtons(false); } onWidthUpdateCommit(); diff --git a/src/test-utils/dom/internal/drag-handle.ts b/src/test-utils/dom/internal/drag-handle.ts index bdf4e4c33f..a50c45de71 100644 --- a/src/test-utils/dom/internal/drag-handle.ts +++ b/src/test-utils/dom/internal/drag-handle.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; +import { ComponentWrapper, createWrapper, 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'; @@ -9,29 +9,29 @@ export default class DragHandleWrapper extends ComponentWrapper { static rootSelector: string = dragHandleStyles.root; findAllVisibleDirectionButtons(): Array | null { - return this.findAll(`.${dragHandleWrapperStyles['direction-button-visible']}`); + return createWrapper().findAll(`.${dragHandleWrapperStyles['direction-button-visible']}`); } findVisibleDirectionButtonBlockStart(): ElementWrapper | null { - return this.find( + return createWrapper().find( `.${dragHandleWrapperStyles['direction-button-block-start']}.${dragHandleWrapperStyles['direction-button-visible']}` ); } findVisibleDirectionButtonBlockEnd(): ElementWrapper | null { - return this.find( + return createWrapper().find( `.${dragHandleWrapperStyles['direction-button-block-end']}.${dragHandleWrapperStyles['direction-button-visible']}` ); } findVisibleDirectionButtonInlineStart(): ElementWrapper | null { - return this.find( + return createWrapper().find( `.${dragHandleWrapperStyles['direction-button-inline-start']}.${dragHandleWrapperStyles['direction-button-visible']}` ); } findVisibleDirectionButtonInlineEnd(): ElementWrapper | null { - return this.find( + return createWrapper().find( `.${dragHandleWrapperStyles['direction-button-inline-end']}.${dragHandleWrapperStyles['direction-button-visible']}` ); } From 508d672d0a94b92fc5927f08b594f632dcdebd75 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Sep 2025 15:52:25 +0200 Subject: [PATCH 06/16] Fix divider-disabled styles. --- src/table/resizer/styles.scss | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/table/resizer/styles.scss b/src/table/resizer/styles.scss index b654802427..0773a280b6 100644 --- a/src/table/resizer/styles.scss +++ b/src/table/resizer/styles.scss @@ -15,6 +15,7 @@ $handle-width: awsui.$space-xl; $active-separator-width: 2px; +$block-gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); .resizer-wrapper { inset-block: 0; @@ -30,8 +31,6 @@ $active-separator-width: 2px; th:not(:last-child) > .divider, .divider-interactive { - $gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); - position: absolute; outline: none; pointer-events: none; @@ -39,24 +38,24 @@ th:not(:last-child) > .divider, inset-block-end: 0; inset-block-start: 0; min-block-size: awsui.$line-height-heading-xs; - max-block-size: calc(100% - #{$gap}); + max-block-size: calc(100% - #{$block-gap}); margin-block: auto; margin-inline: auto; border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-interactive-default; box-sizing: border-box; } -.divider-interactive { - $gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); - inset-inline-end: calc(#{$handle-width} / 2); -} - -.divider-disabled { - border-inline-start-color: awsui.$color-border-divider-default; +th:not(:last-child) > .divider { + &-disabled { + border-inline-start-color: awsui.$color-border-divider-default; + } + &-active { + border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; + } } -.divider-active { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; +.divider-interactive { + inset-inline-end: calc(#{$handle-width} / 2); } .resizer { From 2c4fd89176bb8a8672920633e43ff582d4e383bd Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Sep 2025 16:11:18 +0200 Subject: [PATCH 07/16] Little more test coverage. --- src/table/__tests__/resizable-columns.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/table/__tests__/resizable-columns.test.tsx b/src/table/__tests__/resizable-columns.test.tsx index 13f21a7b56..603f82b620 100644 --- a/src/table/__tests__/resizable-columns.test.tsx +++ b/src/table/__tests__/resizable-columns.test.tsx @@ -596,6 +596,19 @@ describe('UAP buttons', () => { expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeNull(); }); + test('hides UAP buttons when the button is blurred before the resizer button is activated', () => { + const { wrapper } = renderTable(
); + + wrapper.findColumnResizer(1)!.click(); + wrapper.findColumnResizer(1)!.focus(); + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeTruthy(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeTruthy(); + + wrapper.findColumnResizer(1)!.blur(); + expect(findDragHandle().findVisibleDirectionButtonInlineStart()).toBeNull(); + expect(findDragHandle().findVisibleDirectionButtonInlineEnd()).toBeNull(); + }); + test('does not show UAP buttons when the pointer is moved between pointerdown and pointerup', () => { const { wrapper } = renderTable(
); From e7b04f682a0622253d2b86899cfe415f8ed81429 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 25 Sep 2025 17:16:00 +0200 Subject: [PATCH 08/16] Fix divider-active styles. --- src/table/resizer/styles.scss | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/table/resizer/styles.scss b/src/table/resizer/styles.scss index 0773a280b6..4a3745e68b 100644 --- a/src/table/resizer/styles.scss +++ b/src/table/resizer/styles.scss @@ -45,19 +45,18 @@ th:not(:last-child) > .divider, box-sizing: border-box; } -th:not(:last-child) > .divider { - &-disabled { - border-inline-start-color: awsui.$color-border-divider-default; - } - &-active { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; - } +th:not(:last-child) > .divider-disabled { + border-inline-start-color: awsui.$color-border-divider-default; } .divider-interactive { inset-inline-end: calc(#{$handle-width} / 2); } +.divider-active { + border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; +} + .resizer { @include styles.styles-reset; border-block: none; From 55c916b4f24c6a37031a24f9c14bb49b349422ad Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 26 Sep 2025 11:12:10 +0200 Subject: [PATCH 09/16] Add i18nStrings for the resize tooltip. --- pages/table/resizable-columns.page.tsx | 1 + pages/table/sticky-columns.page.tsx | 2 ++ src/table/header-cell/index.tsx | 5 +++++ src/table/interfaces.tsx | 1 + src/table/internal.tsx | 1 + src/table/resizer/index.tsx | 3 +++ src/table/thead.tsx | 3 +++ 7 files changed, 16 insertions(+) diff --git a/pages/table/resizable-columns.page.tsx b/pages/table/resizable-columns.page.tsx index 1fafeedb3f..4e571d7022 100644 --- a/pages/table/resizable-columns.page.tsx +++ b/pages/table/resizable-columns.page.tsx @@ -220,6 +220,7 @@ export default function App() { columnDisplay={withColumnIds ? columnDisplay : undefined} selectionType={withSelection ? 'single' : undefined} items={items} + ariaLabels={{ resizerTooltipText: 'Drag or select to resize', resizerRoleDescription: 'resize button' }} wrapLines={wrapLines} sortingColumn={sorting?.sortingColumn} sortingDescending={sorting?.isDescending} diff --git a/pages/table/sticky-columns.page.tsx b/pages/table/sticky-columns.page.tsx index c94d7e149e..2645b8a976 100644 --- a/pages/table/sticky-columns.page.tsx +++ b/pages/table/sticky-columns.page.tsx @@ -157,6 +157,8 @@ const ariaLabels: TableProps['ariaLabels'] = { return `${item.name} is ${isItemSelected ? '' : 'not'} selected`; }, tableLabel: 'Demo table', + resizerTooltipText: 'Drag or select to resize', + resizerRoleDescription: 'resize button', }; const selectionTypeOptions = [{ value: 'none' }, { value: 'single' }, { value: 'multi' }]; diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index 7977cf9b1a..b696bd2581 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -46,6 +46,7 @@ export interface TableHeaderCellProps { focusedComponent?: null | string; tableRole: TableRole; resizerRoleDescription?: string; + resizerTooltipText?: string; isExpandable?: boolean; hasDynamicContent?: boolean; variant: TableProps.Variant; @@ -76,6 +77,7 @@ export function TableHeaderCell({ cellRef, tableRole, resizerRoleDescription, + resizerTooltipText, isExpandable, hasDynamicContent, variant, @@ -208,6 +210,9 @@ export function TableHeaderCell({ ariaLabelledby={headerId} minWidth={typeof column.minWidth === 'string' ? parseInt(column.minWidth) : column.minWidth} roleDescription={i18n('ariaLabels.resizerRoleDescription', resizerRoleDescription)} + // TODO: Replace with this when strings are available + // tooltipText={i18n('ariaLabels.resizerTooltipText', resizerTooltipText)} + tooltipText={resizerTooltipText} /> ) : ( diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index c34dea251e..6955444e2b 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -497,6 +497,7 @@ export namespace TableProps { selectionGroupLabel?: string; tableLabel?: string; resizerRoleDescription?: string; + resizerTooltipText?: string; // do not use to prevent overly strict validation on consumer end // it works, practically, we are only interested in `id` and `header` properties only activateEditLabel?: (column: ColumnDefinition, item: T) => string; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index a55bac76ab..e9f4e5611d 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -399,6 +399,7 @@ const InternalTable = React.forwardRef( }, singleSelectionHeaderAriaLabel: ariaLabels?.selectionGroupLabel, resizerRoleDescription: ariaLabels?.resizerRoleDescription, + resizerTooltipText: ariaLabels?.resizerTooltipText, stripedRows, stickyState, selectionColumnId, diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 6c7273f344..71c757e7cb 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -25,6 +25,7 @@ interface ResizerProps { focusId?: string; showFocusRing?: boolean; roleDescription?: string; + tooltipText?: string; } const AUTO_GROW_START_TIME = 10; @@ -44,6 +45,7 @@ export function Resizer({ showFocusRing, focusId, roleDescription, + tooltipText, }: ResizerProps) { onWidthUpdate = useStableCallback(onWidthUpdate); onWidthUpdateCommit = useStableCallback(onWidthUpdateCommit); @@ -258,6 +260,7 @@ export function Resizer({ triggerMode="controlled" controlledShowButtons={showUapButtons} wrapperClassName={styles['resizer-button-wrapper']} + tooltipText={tooltipText} onDirectionClick={direction => { const elements = getResizerElements(resizerToggleRef.current); if (!elements) { diff --git a/src/table/thead.tsx b/src/table/thead.tsx index bd33ea50ed..626184eff8 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -36,6 +36,7 @@ export interface TheadProps { stuck?: boolean; singleSelectionHeaderAriaLabel?: string; resizerRoleDescription?: string; + resizerTooltipText?: string; stripedRows?: boolean; stickyState: StickyColumnsModel; selectionColumnId: PropertyKey; @@ -73,6 +74,7 @@ const Thead = React.forwardRef( onFocusedComponentChange, tableRole, resizerRoleDescription, + resizerTooltipText, isExpandable, setLastUserAction, }: TheadProps, @@ -143,6 +145,7 @@ const Thead = React.forwardRef( cellRef={node => setCell(sticky, columnId, node)} tableRole={tableRole} resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} // Expandable option is only applicable to the first data column of the table. // When present, the header content receives extra padding to match the first offset in the data cells. isExpandable={colIndex === 0 && isExpandable} From 95b2b21be458877f949ff94b2089052d780cacb7 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 26 Sep 2025 11:27:15 +0200 Subject: [PATCH 10/16] Update documenter. --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 60a02c7ed3..091f0b6909 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -23200,6 +23200,11 @@ You can use the first argument of type \`SelectionState\` to access the current "optional": true, "type": "string", }, + { + "name": "resizerTooltipText", + "optional": true, + "type": "string", + }, { "name": "selectionGroupLabel", "optional": true, From 9f449781dcaa976ebb2fdd9a1ce6a7d6ee034e24 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 26 Sep 2025 12:30:58 +0200 Subject: [PATCH 11/16] Add description to the table's ariaLabels property. --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 1 + src/table/interfaces.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 091f0b6909..e6e088e55e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -23075,6 +23075,7 @@ You can use the first argument of type \`SelectionState\` to access the current * \`tableLabel\` (string) - Provides an alternative text for the table. If you use a header for this table, you may reuse the string to provide a caption-like description. For example, tableLabel=Instances will be announced as 'Instances table'. * \`resizerRoleDescription\` (string) - Provides role description for table column resizer buttons. +* \`resizerTooltipText\` (string) - Provides text for the table column resizer tooltip. * \`activateEditLabel\` (EditableColumnDefinition, Item) => string - Specifies an alternative text for the edit button in editable cells. * \`cancelEditLabel\` (EditableColumnDefinition) => string - diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 6955444e2b..91233879d4 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -193,6 +193,7 @@ export interface TableProps extends BaseComponentProps { * * `tableLabel` (string) - Provides an alternative text for the table. If you use a header for this table, you may reuse the string * to provide a caption-like description. For example, tableLabel=Instances will be announced as 'Instances table'. * * `resizerRoleDescription` (string) - Provides role description for table column resizer buttons. + * * `resizerTooltipText` (string) - Provides text for the table column resizer tooltip. * * `activateEditLabel` (EditableColumnDefinition, Item) => string - * Specifies an alternative text for the edit button in editable cells. * * `cancelEditLabel` (EditableColumnDefinition) => string - From 3a32cf5520ee756dbafd2365e737a2609357b3d5 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Wed, 1 Oct 2025 14:54:07 +0200 Subject: [PATCH 12/16] Fix screenshot diffs. --- src/table/resizer/index.tsx | 2 +- src/table/resizer/styles.scss | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 71c757e7cb..552b12b0e6 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -249,7 +249,7 @@ export function Resizer({ const { tabIndex: resizerTabIndex } = useSingleTabStopNavigation(resizerToggleRef, { tabIndex }); return ( -
+
&:has(.divider-interactive):not(.is-visual-refresh) { + inset-inline-end: 0; + } } .resizer-button-wrapper { From c1336e4bb157f1cd66bb93c2a328cc6ea74d0ff4 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Wed, 8 Oct 2025 03:39:57 +0200 Subject: [PATCH 13/16] Add checks to hide arrows if obscured behind sticky columns. --- .../drag-handle-wrapper/motion.scss | 20 ++-- src/table/resizer/index.tsx | 96 +++++++++++++++++-- src/table/resizer/resizer-lookup.ts | 8 +- 3 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/internal/components/drag-handle-wrapper/motion.scss b/src/internal/components/drag-handle-wrapper/motion.scss index 4751dd3dda..74059f1e33 100644 --- a/src/internal/components/drag-handle-wrapper/motion.scss +++ b/src/internal/components/drag-handle-wrapper/motion.scss @@ -71,12 +71,20 @@ #{custom-props.$dragHandleAnimationBlockOffset}: -20px; } -.direction-button-wrapper-inline-start, -.direction-button-wrapper-inline-end.direction-button-wrapper-rtl { - #{custom-props.$dragHandleAnimationInlineOffset}: 20px; +.direction-button-wrapper-inline-start { + @include styles.with-direction('ltr') { + #{custom-props.$dragHandleAnimationInlineOffset}: 20px; + } + @include styles.with-direction('rtl') { + #{custom-props.$dragHandleAnimationInlineOffset}: -20px; + } } -.direction-button-wrapper-inline-end, -.direction-button-wrapper-inline-start.direction-button-wrapper-rtl { - #{custom-props.$dragHandleAnimationInlineOffset}: -20px; +.direction-button-wrapper-inline-end { + @include styles.with-direction('ltr') { + #{custom-props.$dragHandleAnimationInlineOffset}: -20px; + } + @include styles.with-direction('rtl') { + #{custom-props.$dragHandleAnimationInlineOffset}: 20px; + } } diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 552b12b0e6..627fa276f3 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -11,6 +11,7 @@ import DragHandleWrapper from '../../internal/components/drag-handle-wrapper'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { KeyCode } from '../../internal/keycode'; import handleKey, { isEventLike } from '../../internal/utils/handle-key'; +import { scrollElementIntoView } from '../../internal/utils/scrollable-containers'; import { DEFAULT_COLUMN_WIDTH } from '../use-column-widths'; import { getHeaderWidth, getResizerElements } from './resizer-lookup'; @@ -53,12 +54,14 @@ export function Resizer({ const isVisualRefresh = useVisualRefresh(); const separatorId = useUniqueId(); - const resizerToggleRef = useRef(null); - const resizerSeparatorRef = useRef(null); + const positioningWrapperRef = useRef(null); + const resizerToggleRef = useRef(null); + const resizerSeparatorRef = useRef(null); const [isPointerDown, setIsPointerDown] = useState(false); const [isDragging, setIsDragging] = useState(false); const [showUapButtons, setShowUapButtons] = useState(false); + const [resizerObscured, setResizerObscured] = useState(false); const [isKeyboardDragging, setIsKeyboardDragging] = useState(false); const autoGrowTimeout = useRef | undefined>(); const [resizerHasFocus, setResizerHasFocus] = useState(false); @@ -69,6 +72,70 @@ export function Resizer({ setHeaderCellWidth(getHeaderWidth(resizerToggleRef.current)); }, []); + const isObscured = useCallback(() => { + const elements = getResizerElements(resizerToggleRef.current); + if (!elements || !positioningWrapperRef.current) { + return false; + } + + let scrollPaddingInlineStart = 0; + let scrollPaddingInlineEnd = 0; + + // Calculate size of the headers at the exact moment of the call to deal + // with auto-width columns and cached sticky column state. + elements.allHeaders.forEach(header => { + const { inlineSize } = getLogicalBoundingClientRect(header); + if (header.style.insetInlineStart) { + scrollPaddingInlineStart += inlineSize; + } + if (header.style.insetInlineEnd) { + scrollPaddingInlineEnd += inlineSize; + } + }); + + const { insetInlineStart: scrollParentInsetInlineStart, insetInlineEnd: scrollParentInsetInlineEnd } = + getLogicalBoundingClientRect(elements.scrollParent); + const { insetInlineStart, insetInlineEnd, inlineSize } = getLogicalBoundingClientRect(elements.header); + const relativeInsetInlineStart = insetInlineStart - scrollParentInsetInlineStart; + const relativeInsetInlineEnd = scrollParentInsetInlineEnd - insetInlineEnd; + const isSticky = !!elements.header.style.insetInlineStart || !!elements.header.style.insetInlineEnd; + + return ( + // Is positioningWrapper obscured behind the left edge of scrollParent? + Math.ceil(relativeInsetInlineStart + inlineSize) < (isSticky ? 0 : scrollPaddingInlineStart) || + // Is positioningWrapper obscured behind the right edge of scrollParent? + Math.ceil(relativeInsetInlineEnd) < (isSticky ? 0 : scrollPaddingInlineEnd) + ); + }, []); + + const scrollIntoViewIfNeeded = useCallback(() => { + if (resizerSeparatorRef.current && isObscured()) { + scrollElementIntoView(resizerSeparatorRef.current); + } + }, [isObscured]); + + useEffect(() => { + if (!showUapButtons) { + setResizerObscured(false); + return; + } + + const elements = getResizerElements(resizerToggleRef.current); + if (!elements) { + return; + } + + const onScroll = () => setResizerObscured(isObscured()); + elements.scrollParent.addEventListener('scroll', onScroll); + return () => elements.scrollParent.removeEventListener('scroll', onScroll); + }, [isObscured, showUapButtons]); + + useEffect(() => { + if (showUapButtons) { + setResizerObscured(isObscured()); + } + }, [headerCellWidth, isObscured, showUapButtons]); + const updateTrackerPosition = useCallback((newOffset: number) => { const elements = getResizerElements(resizerToggleRef.current); if (!elements) { @@ -180,9 +247,11 @@ export function Resizer({ }, onInlineStart: () => { updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize - 10); + scrollIntoViewIfNeeded(); }, onInlineEnd: () => { updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize + 10); + scrollIntoViewIfNeeded(); }, }); } @@ -234,6 +303,7 @@ export function Resizer({ isKeyboardDragging, isPointerDown, resizerHasFocus, + scrollIntoViewIfNeeded, onWidthUpdateCommit, resizeColumn, updateColumnWidth, @@ -249,18 +319,22 @@ export function Resizer({ const { tabIndex: resizerTabIndex } = useSingleTabStopNavigation(resizerToggleRef, { tabIndex }); return ( -
+
minWidth ? 'active' : 'disabled', - 'inline-end': 'active', + 'inline-start': resizerObscured ? undefined : headerCellWidth > minWidth ? 'active' : 'disabled', + 'inline-end': resizerObscured ? undefined : 'active', }} triggerMode="controlled" - controlledShowButtons={showUapButtons} + controlledShowButtons={showUapButtons && !resizerObscured} wrapperClassName={styles['resizer-button-wrapper']} tooltipText={tooltipText} + data-obscured={resizerObscured} onDirectionClick={direction => { const elements = getResizerElements(resizerToggleRef.current); if (!elements) { @@ -269,10 +343,16 @@ export function Resizer({ if (direction === 'inline-start') { updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize - 20); - requestAnimationFrame(onWidthUpdateCommit); + requestAnimationFrame(() => { + onWidthUpdateCommit(); + scrollIntoViewIfNeeded(); + }); } else if (direction === 'inline-end') { updateColumnWidth(getLogicalBoundingClientRect(elements.header).inlineSize + 20); - requestAnimationFrame(onWidthUpdateCommit); + requestAnimationFrame(() => { + onWidthUpdateCommit(); + scrollIntoViewIfNeeded(); + }); } }} > diff --git a/src/table/resizer/resizer-lookup.ts b/src/table/resizer/resizer-lookup.ts index 6a25c6920f..1899848604 100644 --- a/src/table/resizer/resizer-lookup.ts +++ b/src/table/resizer/resizer-lookup.ts @@ -14,7 +14,7 @@ export function getResizerElements(resizerElement: null | HTMLElement) { return null; } - const header = findUpUntil(resizerElement, element => element.tagName.toLowerCase() === 'th'); + const header = findUpUntil(resizerElement, element => element.tagName === 'TH'); if (!header) { return null; } @@ -24,11 +24,13 @@ export function getResizerElements(resizerElement: null | HTMLElement) { return null; } - const table = tableRoot.querySelector(`table`); + const table = tableRoot.querySelector(`table`); if (!table) { return null; } + const allHeaders = tableRoot.querySelectorAll(`thead th`); + // tracker is rendered inside table wrapper to align with its size const tracker = tableRoot.querySelector(`.${resizerStyles.tracker}`); if (!tracker) { @@ -40,7 +42,7 @@ export function getResizerElements(resizerElement: null | HTMLElement) { return null; } - return { header, table, tracker, scrollParent }; + return { header, table, allHeaders, tracker, scrollParent }; } export function getHeaderWidth(resizerElement: null | HTMLElement): number { From 900f0d9503b52d0b468076fce9afcd09d7251162 Mon Sep 17 00:00:00 2001 From: Johannes Weber Date: Thu, 9 Oct 2025 14:23:56 +0200 Subject: [PATCH 14/16] chore: Address PR feedback - remove TODO --- src/table/resizer/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 627fa276f3..20b1bcf7fd 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -203,7 +203,6 @@ export function Resizer({ }; const onPointerMove = (event: PointerEvent) => { - // TODO: Only set it to true after a certain number of pixels travelled? setIsDragging(true); clearTimeout(autoGrowTimeout.current); const offset = getLogicalPageX(event); From 22fc78ddf06cbdd00aa684ae424107c82c265a5e Mon Sep 17 00:00:00 2001 From: Johannes Weber Date: Thu, 9 Oct 2025 16:53:42 +0200 Subject: [PATCH 15/16] chore: Add more unit tests --- .../__tests__/resizable-columns.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/table/__tests__/resizable-columns.test.tsx b/src/table/__tests__/resizable-columns.test.tsx index 603f82b620..8c56241989 100644 --- a/src/table/__tests__/resizable-columns.test.tsx +++ b/src/table/__tests__/resizable-columns.test.tsx @@ -523,6 +523,39 @@ describe('resize in rtl', () => { }); }); +describe('Auto-grow behavior when dragging beyond scroll parent edge', () => { + test('triggers auto-grow timeout when pointer moves beyond right edge', () => { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const { wrapper } = renderTable(
); + + firePointerdown(wrapper.findColumnResizer(1)!); + + // Move pointer beyond the right edge (inlineEndEdge is 400 from fakeBoundingClientRect) + // This triggers the onPointerMove timeout in resizer/index.tsx + firePointermove(450); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + + firePointerup(450); + }); + + test('clears auto-grow timeout when pointer moves back within bounds', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const { wrapper } = renderTable(
); + + firePointerdown(wrapper.findColumnResizer(1)!); + + // Move pointer beyond the right edge to trigger auto-grow (inlineEndEdge is 400 from fakeBoundingClientRect) + firePointermove(450); + + // Move back within bounds - should clear the timeout + firePointermove(200); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + firePointerup(200); + }); +}); + describe('UAP buttons', () => { // Makes the drag buttons (which are positioned in a portal) easier to find if there's only one set. const singleColumnDefinition = [{ id: 'id', header: 'Id', cell: (item: any) => item.id, width: 150, minWidth: 80 }]; From e701a6b8f385fc3aab4807584482c8ed89b1c2a3 Mon Sep 17 00:00:00 2001 From: Johannes Weber Date: Thu, 9 Oct 2025 17:56:29 +0200 Subject: [PATCH 16/16] chore: Add more unit tests for Resizer component --- src/table/resizer/__tests__/index.test.tsx | 75 ++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/table/resizer/__tests__/index.test.tsx diff --git a/src/table/resizer/__tests__/index.test.tsx b/src/table/resizer/__tests__/index.test.tsx new file mode 100644 index 0000000000..2ec330a783 --- /dev/null +++ b/src/table/resizer/__tests__/index.test.tsx @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { Resizer } from '../../../../lib/components/table/resizer'; +import * as resizerLookup from '../../../../lib/components/table/resizer/resizer-lookup'; +import InternalDragHandleWrapper from '../../../../lib/components/test-utils/dom/internal/drag-handle'; + +function findDragHandle() { + return new InternalDragHandleWrapper(document.body); +} + +describe('Resizer component', () => { + const mockOnWidthUpdate = jest.fn(); + const mockOnWidthUpdateCommit = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('resizeColumn handles null getResizerElements', () => { + jest.spyOn(resizerLookup, 'getResizerElements').mockReturnValue(null); + + const { container } = render( +
+ + + + + +
+ +
+ ); + + const resizerButton = container.querySelector('button'); + + fireEvent.pointerDown(resizerButton!, { button: 0 }); + // pointerMove calls resizeColumn internally + fireEvent.pointerMove(document, { clientX: 200 }); + // pointerUp also calls resizeColumn + fireEvent.pointerUp(document, { clientX: 200 }); + + // If resizeColumn didn't return early, it would call updateColumnWidth -> onWidthUpdate) + expect(mockOnWidthUpdate).not.toHaveBeenCalled(); + }); + + test('onDirectionClick handles null getResizerElements', () => { + jest.spyOn(resizerLookup, 'getResizerElements').mockReturnValue(null); + + const { container } = render( + + + + + + +
+ +
+ ); + + const resizerButton = container.querySelector('button'); + fireEvent.click(resizerButton!); + + const dragHandle = findDragHandle(); + fireEvent.click(dragHandle.findVisibleDirectionButtonInlineStart()!.getElement()); + fireEvent.click(dragHandle.findVisibleDirectionButtonInlineEnd()!.getElement()); + + // Callbacks are not called when getResizerElements returns null (updateColumnWidth -> onWidthUpdate) + expect(mockOnWidthUpdate).not.toHaveBeenCalled(); + expect(mockOnWidthUpdateCommit).not.toHaveBeenCalled(); + }); +});