Skip to content
Open
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
12 changes: 9 additions & 3 deletions packages/@react-aria/dnd/src/useDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEve
import {DragEvent, HTMLAttributes, version as ReactVersion, useEffect, useRef, useState} from 'react';
import * as DragManager from './DragManager';
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './constants';
import {getEventTarget, isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils';
import {getEventTarget, isVirtualClick, isVirtualPointerEvent, useDynamicDescription, useGlobalListeners} from '@react-aria/utils';
import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils';
// @ts-ignore
import intlMessages from '../intl/*.json';
Expand Down Expand Up @@ -69,6 +69,11 @@ const MESSAGES = {
}
};

const DESCRIPTION_KEYS = {
start: 'react-aria-dnd-drag-start',
end: 'react-aria-dnd-drag-end'
};

/**
* Handles drag interactions for an element, with support for traditional mouse and touch
* based drag and drop, in addition to full parity for keyboard and screen reader users.
Expand Down Expand Up @@ -302,9 +307,10 @@ export function useDrag(options: DragOptions): DragResult {
};

let modality = useDragModality();
let message = !isDragging ? MESSAGES[modality].start : MESSAGES[modality].end;
let messageType: 'start' | 'end' = !isDragging ? 'start' : 'end';
let message = MESSAGES[modality][messageType];

let descriptionProps = useDescription(stringFormatter.format(message));
let descriptionProps = useDynamicDescription(stringFormatter.format(message), DESCRIPTION_KEYS[messageType]);

let interactions: HTMLAttributes<HTMLElement> = {};
if (!hasDragButton) {
Expand Down
6 changes: 4 additions & 2 deletions packages/@react-aria/dnd/src/useVirtualDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {DOMAttributes} from 'react';
import * as DragManager from './DragManager';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {useDescription} from '@react-aria/utils';
import {useDragModality} from './utils';
import {useDynamicDescription} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';

interface VirtualDropResult {
Expand All @@ -29,11 +29,13 @@ const MESSAGES = {
virtual: 'dropDescriptionVirtual'
};

const DESCRIPTION_KEY = 'react-aria-dnd-drop';

export function useVirtualDrop(): VirtualDropResult {
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/dnd');
let modality = useDragModality();
let dragSession = DragManager.useDragSession();
let descriptionProps = useDescription(dragSession ? stringFormatter.format(MESSAGES[modality]) : '');
let descriptionProps = useDynamicDescription(dragSession ? stringFormatter.format(MESSAGES[modality]) : '', DESCRIPTION_KEY);

return {
dropProps: {
Expand Down
78 changes: 78 additions & 0 deletions packages/@react-aria/dnd/test/dnd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2511,6 +2511,84 @@ describe('useDrag and useDrop', function () {
expect(announce).toHaveBeenCalledWith('Drop canceled.');
});

it('should keep dynamic description ids stable while description text updates', async () => {
let tree = render(<>
<Draggable />
<Droppable />
</>);

let draggable = tree.getByText('Drag me');
let droppable = tree.getByText('Drop here');

fireEvent.focus(draggable);
let draggableDescriptionId = draggable.getAttribute('aria-describedby');
expect(document.getElementById(draggableDescriptionId)).toHaveTextContent('Click to start dragging');

fireEvent.click(draggable);
act(() => jest.runAllTimers());

expect(draggable).toHaveAttribute('data-dragging', 'true');
let dragEndDescriptionId = draggable.getAttribute('aria-describedby');
expect(dragEndDescriptionId).not.toBe(draggableDescriptionId);
expect(document.getElementById(dragEndDescriptionId)).toHaveTextContent('Dragging. Click to cancel drag.');

let dropDescriptionId = droppable.getAttribute('aria-describedby');
let dropDescriptionNode = document.getElementById(dropDescriptionId);
expect(dropDescriptionNode).toHaveTextContent('Click to drop.');

fireEvent.click(draggable);

expect(draggable).toHaveAttribute('data-dragging', 'false');
let restoredStartDescriptionId = draggable.getAttribute('aria-describedby');
expect(document.getElementById(restoredStartDescriptionId)).toHaveTextContent('Click to start dragging');
expect(droppable).not.toHaveAttribute('aria-describedby');
expect(document.getElementById(dropDescriptionId)).toBe(dropDescriptionNode);
expect(dropDescriptionNode).toHaveTextContent('Click to drop.');

fireEvent.click(draggable);
act(() => jest.runAllTimers());

let secondDragEndDescriptionId = draggable.getAttribute('aria-describedby');
expect(document.getElementById(secondDragEndDescriptionId)).toHaveTextContent('Dragging. Click to cancel drag.');
expect(droppable.getAttribute('aria-describedby')).toBe(dropDescriptionId);
expect(document.getElementById(dropDescriptionId)).toBe(dropDescriptionNode);
expect(dropDescriptionNode).toHaveTextContent('Click to drop.');

fireEvent.click(draggable);
});

it('should share drag start descriptions across many draggables', () => {
let tree = render(<>
<Draggable>Drag me 1</Draggable>
<Draggable>Drag me 2</Draggable>
<Draggable>Drag me 3</Draggable>
<Draggable>Drag me 4</Draggable>
<Draggable>Drag me 5</Draggable>
<Draggable>Drag me 6</Draggable>
<Draggable>Drag me 7</Draggable>
<Draggable>Drag me 8</Draggable>
<Draggable>Drag me 9</Draggable>
<Draggable>Drag me 10</Draggable>
</>);

let ids = [
tree.getByText('Drag me 1').getAttribute('aria-describedby'),
tree.getByText('Drag me 2').getAttribute('aria-describedby'),
tree.getByText('Drag me 3').getAttribute('aria-describedby'),
tree.getByText('Drag me 4').getAttribute('aria-describedby'),
tree.getByText('Drag me 5').getAttribute('aria-describedby'),
tree.getByText('Drag me 6').getAttribute('aria-describedby'),
tree.getByText('Drag me 7').getAttribute('aria-describedby'),
tree.getByText('Drag me 8').getAttribute('aria-describedby'),
tree.getByText('Drag me 9').getAttribute('aria-describedby'),
tree.getByText('Drag me 10').getAttribute('aria-describedby')
];

expect(new Set(ids).size).toBe(1);
expect(document.querySelectorAll('[id^="react-aria-description-"]')).toHaveLength(1);
expect(document.getElementById(ids[0])).toHaveTextContent('Click to start dragging');
});

it('should support clicking the original drag target to cancel drag (virtual pointer event)', async () => {
let tree = render(<>
<Draggable />
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export {getScrollParent} from './getScrollParent';
export {getScrollParents} from './getScrollParents';
export {isScrollable} from './isScrollable';
export {useViewportSize} from './useViewportSize';
export {useDescription} from './useDescription';
export {useDescription, useDynamicDescription} from './useDescription';
export {isMac, isIPhone, isIPad, isIOS, isAppleDevice, isWebKit, isChrome, isAndroid, isFirefox} from './platform';
export {useEvent} from './useEvent';
export {useValueEffect} from './useValueEffect';
Expand Down
95 changes: 88 additions & 7 deletions packages/@react-aria/utils/src/useDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,32 @@

import {AriaLabelingProps} from '@react-types/shared';
import {useLayoutEffect} from './useLayoutEffect';
import {useState} from 'react';
import {useRef, useState} from 'react';

let descriptionId = 0;
const descriptionNodes = new Map<string, {refCount: number, element: Element}>();
const descriptionNodes = new Map<string, {refCount: number, element: HTMLElement}>();
const dynamicDescriptionNodes = new Map<string, {refCount: number, element: HTMLElement}>();

function createDescriptionNode(id: string, description: string): HTMLElement {
let node = document.createElement('div');
node.id = id;
node.style.display = 'none';
node.textContent = description;
document.body.appendChild(node);
return node;
}

function getOrCreateDynamicDescriptionNode(descriptionKey: string) {
let desc = dynamicDescriptionNodes.get(descriptionKey);
if (!desc) {
let id = `react-aria-description-${descriptionId++}`;
Copy link
Member

Choose a reason for hiding this comment

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

in the case that multiple copies are loaded, this could create conflicting ids, better to use id generation like crypto.randomUUID

Copy link
Member

Choose a reason for hiding this comment

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

or put the id generation into the hooks and make use of useId

let node = createDescriptionNode(id, '');
desc = {refCount: 0, element: node};
dynamicDescriptionNodes.set(descriptionKey, desc);
}

return desc;
}

export function useDescription(description?: string): AriaLabelingProps {
let [id, setId] = useState<string | undefined>();
Expand All @@ -30,11 +52,7 @@ export function useDescription(description?: string): AriaLabelingProps {
let id = `react-aria-description-${descriptionId++}`;
setId(id);

let node = document.createElement('div');
node.id = id;
node.style.display = 'none';
node.textContent = description;
document.body.appendChild(node);
let node = createDescriptionNode(id, description);
desc = {refCount: 0, element: node};
descriptionNodes.set(description, desc);
} else {
Expand All @@ -54,3 +72,66 @@ export function useDescription(description?: string): AriaLabelingProps {
'aria-describedby': description ? id : undefined
};
}

/**
* Provides a stable `aria-describedby` id for descriptions that change over time.
* Unlike `useDescription`, this shares a hidden element by `descriptionKey`
* and updates its text content in place so many consumers can reuse one id.
*/
export function useDynamicDescription(description: string | undefined, descriptionKey: string): AriaLabelingProps {
let [id, setId] = useState<string | undefined>();
let subscriptionRef = useRef<{key: string, desc: {refCount: number, element: HTMLElement}} | null>(null);
let nodeRef = useRef<HTMLElement | null>(null);

useLayoutEffect(() => {
let subscription = subscriptionRef.current;
if (subscription && subscription.key !== descriptionKey) {
if (--subscription.desc.refCount === 0) {
subscription.desc.element.remove();
dynamicDescriptionNodes.delete(subscription.key);
}

subscriptionRef.current = null;
if (nodeRef.current === subscription.desc.element) {
nodeRef.current = null;
}
subscription = null;
}

if (!subscription && description) {
let desc = getOrCreateDynamicDescriptionNode(descriptionKey);
desc.refCount++;
subscriptionRef.current = {key: descriptionKey, desc};
nodeRef.current = desc.element;
setId(desc.element.id);
desc.element.textContent = description;
}
}, [descriptionKey, description]);

useLayoutEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

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

what is the purpose of this effect? i can't quite figure it out, maybe it should also be merged into the effect above?

if (description && nodeRef.current) {
nodeRef.current.textContent = description;
}
}, [description]);

useLayoutEffect(() => {
return () => {
Copy link
Member

Choose a reason for hiding this comment

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

if you move this up to be in the first useLayoutEffect, then you can de-duplicate the refCount removal, always handle it in the cleanup of that effect
It'll make it more readable as well because creation and cleanup associated will be right next to each other

let subscription = subscriptionRef.current;
if (subscription) {
if (--subscription.desc.refCount === 0) {
subscription.desc.element.remove();
dynamicDescriptionNodes.delete(subscription.key);
}

subscriptionRef.current = null;
if (nodeRef.current === subscription.desc.element) {
nodeRef.current = null;
}
}
};
}, []);

return {
'aria-describedby': description ? id : undefined
};
}
Loading
Loading