Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pages/table/resizable-columns.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions pages/table/sticky-columns.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ const ariaLabels: TableProps<ExtendedInstance>['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' }];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down Expand Up @@ -23200,6 +23201,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 53 additions & 49 deletions src/internal/components/drag-handle-wrapper/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ 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';
import testUtilsStyles from './test-classes/styles.css.js';

export default function DragHandleWrapper({
directions,
Expand All @@ -22,13 +22,14 @@ export default function DragHandleWrapper({
triggerMode = 'focus',
initialShowButtons = false,
controlledShowButtons = false,
wrapperClassName,
hideButtonsOnDrag,
clickDragThreshold,
}: DragHandleWrapperProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLDivElement | null>(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>();
Expand All @@ -44,23 +45,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);
}
};

Expand All @@ -87,7 +88,7 @@ export default function DragHandleWrapper({
) {
didPointerDrag.current = true;
if (hideButtonsOnDrag) {
setShowButtons(false);
setUncontrolledShowButtons(false);
}
}
},
Expand Down Expand Up @@ -115,7 +116,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();
},
Expand Down Expand Up @@ -153,10 +154,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' &&
Expand All @@ -166,78 +167,81 @@ 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 (
<div
className={clsx(styles['drag-handle-wrapper'], _showButtons && styles['drag-handle-wrapper-open'])}
ref={wrapperRef}
onFocus={onWrapperFocusIn}
onBlur={onWrapperFocusOut}
>
<div onPointerEnter={onTooltipGroupPointerEnter} onPointerLeave={onTooltipGroupPointerLeave}>
<>
{/* 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. */}
<div
className={clsx(testUtilsStyles.root, styles.contents)}
ref={wrapperRef}
onFocus={onWrapperFocusIn}
onBlur={onWrapperFocusOut}
>
{/* Wrapper for pointer detection. Determines whether or not the tooltip should be shown. */}
<div
className={styles['drag-handle']}
ref={dragHandleRef}
onPointerDown={onHandlePointerDown}
onKeyDown={onDragHandleKeyDown}
className={styles.contents}
onPointerEnter={onTooltipGroupPointerEnter}
onPointerLeave={onTooltipGroupPointerLeave}
>
{children}
{/* Position tracking wrapper used to position the tooltip and drag buttons accurately.
Its dimensions must match the inner button's dimensions. */}
<div
className={clsx(styles['drag-handle'], wrapperClassName)}
ref={dragHandleRef}
onPointerDown={onHandlePointerDown}
onKeyDown={onDragHandleKeyDown}
>
{children}
</div>

{!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.
<Tooltip trackRef={dragHandleRef} value={tooltipText} onDismiss={() => setShowTooltip(false)} />
)}
</div>

{!isDisabled && !_showButtons && showTooltip && tooltipText && (
<Tooltip trackRef={dragHandleRef} value={tooltipText} onDismiss={() => setShowTooltip(false)} />
)}
</div>

<PortalOverlay track={dragHandleRef} isDisabled={!_showButtons}>
<PortalOverlay track={dragHandleRef} isDisabled={!showButtons}>
{directions['block-start'] && (
<DirectionButton
show={!isDisabled && _showButtons}
show={!isDisabled && showButtons}
direction="block-start"
state={directions['block-start']}
onClick={() => onInternalDirectionClick('block-start')}
onClick={() => onDirectionClick?.('block-start')}
/>
)}
{directions['block-end'] && (
<DirectionButton
show={!isDisabled && _showButtons}
show={!isDisabled && showButtons}
direction="block-end"
state={directions['block-end']}
onClick={() => onInternalDirectionClick('block-end')}
onClick={() => onDirectionClick?.('block-end')}
/>
)}
{directions['inline-start'] && (
<DirectionButton
show={!isDisabled && _showButtons}
show={!isDisabled && showButtons}
direction="inline-start"
state={directions['inline-start']}
onClick={() => onInternalDirectionClick('inline-start')}
onClick={() => onDirectionClick?.('inline-start')}
/>
)}
{directions['inline-end'] && (
<DirectionButton
show={!isDisabled && _showButtons}
show={!isDisabled && showButtons}
direction="inline-end"
state={directions['inline-end']}
onClick={() => onInternalDirectionClick('inline-end')}
onClick={() => onDirectionClick?.('inline-end')}
/>
)}
</PortalOverlay>
</div>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface DragHandleWrapperProps {
onDirectionClick?: (direction: Direction) => void;
tooltipText?: string;
children: React.ReactNode;
wrapperClassName?: string;
triggerMode?: TriggerMode;
initialShowButtons?: boolean;
controlledShowButtons?: boolean;
Expand Down
20 changes: 14 additions & 6 deletions src/internal/components/drag-handle-wrapper/motion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
7 changes: 3 additions & 4 deletions src/internal/components/drag-handle-wrapper/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, the drag handle wrapper had three nested divs I had to contend with styling them all. Since two of those divs only exist for event handling/capture reasons, I can safely just set them to display: contents and only worry about styling one div.

}

.portal-overlay {
Expand All @@ -35,7 +34,7 @@ $direction-button-offset: awsui.$space-static-xxs;

.drag-handle {
position: relative;
display: flex;
display: inline-flex;
}

.direction-button-wrapper {
Expand Down
Loading
Loading