diff --git a/packages/@react-aria/dnd/src/useDrag.ts b/packages/@react-aria/dnd/src/useDrag.ts index d91c5b2dc6c..3cb9a78ed87 100644 --- a/packages/@react-aria/dnd/src/useDrag.ts +++ b/packages/@react-aria/dnd/src/useDrag.ts @@ -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. @@ -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 = {}; if (!hasDragButton) { diff --git a/packages/@react-aria/dnd/src/useVirtualDrop.ts b/packages/@react-aria/dnd/src/useVirtualDrop.ts index b74897d3cba..8c98a6f2f25 100644 --- a/packages/@react-aria/dnd/src/useVirtualDrop.ts +++ b/packages/@react-aria/dnd/src/useVirtualDrop.ts @@ -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: { diff --git a/packages/@react-aria/dnd/test/dnd.test.js b/packages/@react-aria/dnd/test/dnd.test.js index 36f984c5179..f070673ec82 100644 --- a/packages/@react-aria/dnd/test/dnd.test.js +++ b/packages/@react-aria/dnd/test/dnd.test.js @@ -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(<> + + + ); + + 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(<> + Drag me 1 + Drag me 2 + Drag me 3 + Drag me 4 + Drag me 5 + Drag me 6 + Drag me 7 + Drag me 8 + Drag me 9 + Drag me 10 + ); + + 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(<> diff --git a/packages/@react-aria/utils/src/useDescription.ts b/packages/@react-aria/utils/src/useDescription.ts index 01f953e9c32..b9ce2b8a215 100644 --- a/packages/@react-aria/utils/src/useDescription.ts +++ b/packages/@react-aria/utils/src/useDescription.ts @@ -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(); -export function useDescription(description?: string): AriaLabelingProps { +interface DescriptionNode { + refCount: number, + element: HTMLElement +} + +interface DescriptionSubscription { + key: string, + node: DescriptionNode, + nodes: Map +} + +const descriptionNodes = new Map(); +const dynamicDescriptionNodes = new Map(); + +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, descriptionKey: string, description: string) { + let desc = nodes.get(descriptionKey); + if (!desc) { + let id = `react-aria-description-${descriptionId++}`; + 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(); + let subscriptionRef = useRef(null); + let nodeRef = useRef(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 () => { - 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 diff --git a/packages/@react-aria/utils/test/useDescription.test.tsx b/packages/@react-aria/utils/test/useDescription.test.tsx new file mode 100644 index 00000000000..d7e2174a84a --- /dev/null +++ b/packages/@react-aria/utils/test/useDescription.test.tsx @@ -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(); + }); +});