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
10 changes: 8 additions & 2 deletions packages/@react-aria/dnd/src/useDrag.ts
Original file line number Diff line number Diff line change
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 = useDescription(stringFormatter.format(message), DESCRIPTION_KEYS[messageType]);

let interactions: HTMLAttributes<HTMLElement> = {};
if (!hasDragButton) {
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/dnd/src/useVirtualDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = useDescription(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
106 changes: 82 additions & 24 deletions packages/@react-aria/utils/src/useDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,101 @@

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}>();

export function useDescription(description?: string): AriaLabelingProps {
interface DescriptionNode {
refCount: number,
element: HTMLElement
}

interface DescriptionSubscription {
key: string,
node: DescriptionNode,
nodes: Map<string, DescriptionNode>
}

const descriptionNodes = new Map<string, DescriptionNode>();
const dynamicDescriptionNodes = new Map<string, DescriptionNode>();

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 getOrCreateDescriptionNode(nodes: Map<string, DescriptionNode>, descriptionKey: string, description: string) {
let desc = nodes.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, description);
desc = {refCount: 0, element: node};
nodes.set(descriptionKey, desc);
}

return desc;
}

function cleanupDescriptionSubscription(subscription: DescriptionSubscription, nodeRef: {current: HTMLElement | null}) {
if (--subscription.node.refCount === 0) {
subscription.node.element.remove();
subscription.nodes.delete(subscription.key);
}

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

/**
* Provides an `aria-describedby` reference to a shared hidden description node.
* By default, descriptions are shared by exact text. If `descriptionKey` is provided,
* a stable node is shared by key and its text content updates in place as the
* description changes.
*/
export function useDescription(description?: string, descriptionKey?: string): AriaLabelingProps {
let [id, setId] = useState<string | undefined>();
let subscriptionRef = useRef<DescriptionSubscription | null>(null);
let nodeRef = useRef<HTMLElement | null>(null);
let isDynamic = descriptionKey != null;

useLayoutEffect(() => {
if (!description) {
return;
let subscription = subscriptionRef.current;
let key = descriptionKey ?? description;
let nodes = isDynamic ? dynamicDescriptionNodes : descriptionNodes;
if (subscription && (subscription.key !== key || subscription.nodes !== nodes)) {
cleanupDescriptionSubscription(subscription, nodeRef);
subscriptionRef.current = null;
subscription = null;
}

if (!subscription && description && key) {
let node = getOrCreateDescriptionNode(nodes, key, isDynamic ? '' : description);
node.refCount++;
subscription = {key, node, nodes};
subscriptionRef.current = subscription;
nodeRef.current = node.element;
setId(node.element.id);
}

let desc = descriptionNodes.get(description);
if (!desc) {
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);
desc = {refCount: 0, element: node};
descriptionNodes.set(description, desc);
} else {
setId(desc.element.id);
if (isDynamic && description && nodeRef.current) {
nodeRef.current.textContent = description;
}
}, [description, descriptionKey, isDynamic]);

desc.refCount++;
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

if (desc && --desc.refCount === 0) {
desc.element.remove();
descriptionNodes.delete(description);
let subscription = subscriptionRef.current;
if (subscription) {
cleanupDescriptionSubscription(subscription, nodeRef);
subscriptionRef.current = null;
}
};
}, [description]);
}, []);

return {
'aria-describedby': description ? id : undefined
Expand Down
145 changes: 145 additions & 0 deletions packages/@react-aria/utils/test/useDescription.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {actHook as act, renderHook} from '@react-spectrum/test-utils-internal';
import {useDescription} from '../src/useDescription';

describe('useDescription', () => {
it('should return an id if description is provided', () => {
let {result} = renderHook(() => useDescription('Test description'));
expect(result.current['aria-describedby']).toMatch(/^react-aria-description-\d+$/);
});

it('should return undefined if no description is provided', () => {
let {result} = renderHook(() => useDescription());
expect(result.current['aria-describedby']).toBeUndefined();
});

it('should reuse the same id for the same description', () => {
let {result: result1} = renderHook(() => useDescription('Test description'));
let {result: result2} = renderHook(() => useDescription('Test description'));
expect(result1.current['aria-describedby']).toBe(result2.current['aria-describedby']);
});

it('should create a new id for a new description', () => {
let {result: result1} = renderHook(() => useDescription('Test description 1'));
let {result: result2} = renderHook(() => useDescription('Test description 2'));
expect(result1.current['aria-describedby']).not.toBe(result2.current['aria-describedby']);
});

it('should clean up description node on unmount', () => {
let {result, unmount} = renderHook(() => useDescription('Test description'));
let id = result.current['aria-describedby'];
expect(document.getElementById(id!)).not.toBeNull();
unmount();
expect(document.getElementById(id!)).toBeNull();
});

it('should not clean up if other components are using the same description', () => {
let {result: result1, unmount: unmount1} = renderHook(() => useDescription('Test description'));
let {unmount: unmount2} = renderHook(() => useDescription('Test description'));
let id = result1.current['aria-describedby'];
expect(document.getElementById(id!)).not.toBeNull();
unmount1();
expect(document.getElementById(id!)).not.toBeNull();
unmount2();
expect(document.getElementById(id!)).toBeNull();
});
});

describe('useDescription with a description key', () => {
it('should return an id if description is provided', () => {
let {result} = renderHook(() => useDescription('Test description', 'dynamic-1'));
expect(result.current['aria-describedby']).toMatch(/^react-aria-description-\d+$/);
});

it('should return undefined if no description is provided', () => {
let {result} = renderHook(() => useDescription(undefined, 'dynamic-2'));
expect(result.current['aria-describedby']).toBeUndefined();
});

it('should reuse the same id for the same description key', () => {
let {result: result1} = renderHook(() => useDescription('Test description', 'shared-key'));
let {result: result2} = renderHook(() => useDescription('Test description', 'shared-key'));
expect(result1.current['aria-describedby']).toBe(result2.current['aria-describedby']);
});

it('should create a new id for a different description key', () => {
let {result: result1} = renderHook(() => useDescription('Test description', 'dynamic-3'));
let {result: result2} = renderHook(() => useDescription('Test description', 'dynamic-4'));
expect(result1.current['aria-describedby']).not.toBe(result2.current['aria-describedby']);
});

it('should keep the same id and update text content when description changes for the same key', () => {
let {result, rerender} = renderHook(
({description}: {description?: string}) => useDescription(description, 'dynamic-5'),
{initialProps: {description: 'Test description 1'}}
);

let id = result.current['aria-describedby'];
let node = document.getElementById(id!);
expect(node?.textContent).toBe('Test description 1');

act(() => {
rerender({description: 'Test description 2'});
});

expect(result.current['aria-describedby']).toBe(id);
expect(document.getElementById(id!)).toBe(node);
expect(node?.textContent).toBe('Test description 2');
});

it('should keep the same id for the lifetime of the component', () => {
let {result, rerender} = renderHook(
({description}: {description?: string}) => useDescription(description, 'dynamic-6'),
{initialProps: {description: 'Test description'}}
);

let id = result.current['aria-describedby'];
let node = document.getElementById(id!);

act(() => {
rerender({description: ''});
});

expect(result.current['aria-describedby']).toBeUndefined();
expect(document.getElementById(id!)).toBe(node);
expect(node?.textContent).toBe('Test description');

act(() => {
rerender({description: 'Updated description'});
});

expect(result.current['aria-describedby']).toBe(id);
expect(document.getElementById(id!)).toBe(node);
expect(node?.textContent).toBe('Updated description');
});

it('should not clean up if other components are using the same description key', () => {
let {result: result1, unmount: unmount1} = renderHook(() => useDescription('Test description', 'shared-cleanup'));
let {unmount: unmount2} = renderHook(() => useDescription('Test description', 'shared-cleanup'));
let id = result1.current['aria-describedby'];
expect(document.getElementById(id!)).not.toBeNull();
unmount1();
expect(document.getElementById(id!)).not.toBeNull();
unmount2();
expect(document.getElementById(id!)).toBeNull();
});

it('should clean up description node on unmount', () => {
let {result, unmount} = renderHook(() => useDescription('Test description', 'dynamic-7'));
let id = result.current['aria-describedby'];
expect(document.getElementById(id!)).not.toBeNull();
unmount();
expect(document.getElementById(id!)).toBeNull();
});
});
Loading