From 91d9aed83f13e3f54abe7131521189b6d0b84805 Mon Sep 17 00:00:00 2001 From: Ardit Tirana <126591992+ardittirana@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:21:21 +0200 Subject: [PATCH 1/8] fix(table): floor fractional table width so columns don't overflow (#10238) * fix(table): floor fractional table width so columns don't overflow calculateColumnSizes relies on cascadeRounding, which assumes the target column sizes sum to an integer. With a fractional table width (e.g. a percentage-based size) that invariant breaks and the rounded widths can exceed the available width by 1px, producing an unexpected horizontal scrollbar. Flooring the available width keeps the sum integral; integer widths are unchanged. Adds a regression test to TableUtils. Fixes #9448. * Keep columns flush with a fractional table width Address review: instead of flooring the available width (which left a sub-pixel gap between the last column and the table edge), assign the leftover fraction to the last column inside cascadeRounding so the column widths sum exactly to the available width. Integer widths are unchanged. * Fix failing resize tests; keep integer widths whole The previous revision added the fractional remainder inside cascadeRounding using `fpTotal - intTotal`, but floating-point accumulation could leave a tiny residue on the last column even for integer table widths, breaking tests that assume whole-number widths (e.g. tableResizingTests). Revert cascadeRounding to its integer-only form and instead split the available width in calculateColumnSizes: distribute the floored (whole-pixel) width across the columns, then add the leftover fraction to the last column. Integer widths are now exactly whole numbers again, and a fractional width (e.g. 1000.5 -> [500, 500.5]) keeps the columns flush with the table edge. * Add test for fractional table widths with fp rounding error Covers a table width like 1000.7 where the leftover fraction lands on the last column. The columns still sum exactly to the available width and all but the last column are whole numbers; the last column is within one ULP of the expected fractional value. * use number string manipulation to make exact math --------- Co-authored-by: Rob Snow --- .../react-stately/src/table/TableUtils.ts | 16 +++++++++- .../test/table/TableUtils.test.js | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/react-stately/src/table/TableUtils.ts b/packages/react-stately/src/table/TableUtils.ts index 89e4f64794a..7c33a4e94fa 100644 --- a/packages/react-stately/src/table/TableUtils.ts +++ b/packages/react-stately/src/table/TableUtils.ts @@ -113,6 +113,10 @@ export function calculateColumnSizes( getDefaultWidth?: (index: number) => ColumnSize | null | undefined, getDefaultMinWidth?: (index: number) => ColumnSize | null | undefined ): number[] { + let originalWidth = availableWidth; + let flooredWidth = Math.floor(availableWidth); + let hasFractionalWidth = availableWidth - flooredWidth > 0; + availableWidth = flooredWidth; let hasNonFrozenItems = false; let flexItems: FlexItem[] = columns.map((column, index) => { let width: ColumnSize = ( @@ -254,7 +258,17 @@ export function calculateColumnSizes( }); } - return cascadeRounding(flexItems); + let columnSizes = cascadeRounding(flexItems); + + // Give the leftover sub-pixel width to the last column so the columns sum + // exactly to the (possibly fractional) available width. + if (hasFractionalWidth && columnSizes.length > 0) { + let tableFractionalWidth = originalWidth.toString().split('.')[1]; + let columnWidth = columnSizes[columnSizes.length - 1].toString(); + columnSizes[columnSizes.length - 1] = Number(columnWidth + '.' + tableFractionalWidth); + } + + return columnSizes; } function cascadeRounding(flexItems: FlexItem[]): number[] { diff --git a/packages/react-stately/test/table/TableUtils.test.js b/packages/react-stately/test/table/TableUtils.test.js index 89cde046afb..324eafc48b4 100644 --- a/packages/react-stately/test/table/TableUtils.test.js +++ b/packages/react-stately/test/table/TableUtils.test.js @@ -111,6 +111,38 @@ describe('TableUtils', () => { ); expect(widths).toStrictEqual([133, 134, 533]); }); + + it('keeps columns flush with a fractional table width', () => { + let tableWidth = 1000.5; + let widths = calculateColumnSizes( + tableWidth, + [ + {key: 'name', width: '1fr'}, + {key: 'type', width: '1fr'} + ], + new Map(), + () => 150, + () => 50 + ); + expect(widths).toStrictEqual([500, 500.5]); + expect(widths.reduce((a, b) => a + b, 0)).toBe(tableWidth); + }); + + it('handles js fp rounding errors', () => { + let tableWidth = 1000.7; + let widths = calculateColumnSizes( + tableWidth, + [ + {key: 'name', width: '1fr'}, + {key: 'type', width: '1fr'} + ], + new Map(), + () => 150, + () => 50 + ); + expect(widths).toStrictEqual([500, 500.7]); + expect(widths.reduce((a, b) => a + b, 0)).toBe(tableWidth); + }); }); describe('table column layout', () => { From 9a054740f75934777e1519dacd30b379ef0a2f07 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 26 Jun 2026 08:38:53 +1000 Subject: [PATCH 2/8] fix: shadcn registry build (#10263) --- scripts/buildRegistry.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/buildRegistry.mjs b/scripts/buildRegistry.mjs index 4abaa480811..8fa8025d6b0 100644 --- a/scripts/buildRegistry.mjs +++ b/scripts/buildRegistry.mjs @@ -153,7 +153,14 @@ function analyzeDeps(file, type) { : '@/registry/react-aria/ui/' + source.slice(2); } } else { - dependencies.add(source); + // Use the bare package name (e.g. `react-aria-components`) rather than the + // deep import path (`react-aria-components/Button`). npm treats `owner/repo` + // specifiers as GitHub shorthand, so passing deep paths to `shadcn add` (which + // runs `npm install `) would fail to resolve the dependency. + let pkg = source.startsWith('@') + ? source.split('/').slice(0, 2).join('/') + : source.split('/')[0]; + dependencies.add(pkg); } } } From 052ac7bc7d90ca5a2864daad36e7b7ee35833060 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 25 Jun 2026 15:39:30 -0700 Subject: [PATCH 3/8] fix: make sure trackpad taps properly fire press events consistently (#10225) * fix: make sure trackpad taps are not considered as virtual clicks * didnt save? --- .../react-spectrum/test/list/ListView.test.js | 10 +++ .../react-spectrum/test/table/TableTests.js | 10 +++ .../react-aria/src/utils/isVirtualEvent.ts | 3 +- packages/react-aria/test/dnd/dnd.test.js | 6 ++ .../test/interactions/usePress.test.js | 85 +++++++++++++++++++ 5 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/@adobe/react-spectrum/test/list/ListView.test.js b/packages/@adobe/react-spectrum/test/list/ListView.test.js index 1ba27cab1a2..3f096884cb0 100644 --- a/packages/@adobe/react-spectrum/test/list/ListView.test.js +++ b/packages/@adobe/react-spectrum/test/list/ListView.test.js @@ -1225,6 +1225,11 @@ describe('ListView', function () { }); it("should support single tap to perform row selection with screen reader if onAction isn't provided", async function () { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'Android'); + let tree = renderSelectionList({ onSelectionChange, selectionMode: 'multiple', @@ -1281,6 +1286,11 @@ describe('ListView', function () { }); it('should support single tap to perform onAction with screen reader', async function () { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'Android'); + let tree = renderSelectionList({ onSelectionChange, selectionMode: 'multiple', diff --git a/packages/@adobe/react-spectrum/test/table/TableTests.js b/packages/@adobe/react-spectrum/test/table/TableTests.js index 25e502c6bdd..c1649a821fa 100644 --- a/packages/@adobe/react-spectrum/test/table/TableTests.js +++ b/packages/@adobe/react-spectrum/test/table/TableTests.js @@ -3414,6 +3414,11 @@ export let tableTests = () => { describe('needs pointerEvents', function () { installPointerEvent(); it("should support single tap to perform row selection with screen reader if onAction isn't provided", function () { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'Android'); + let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange, selectionStyle: 'highlight'}); @@ -3482,6 +3487,11 @@ export let tableTests = () => { }); it('should support single tap to perform onAction with screen reader', function () { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest + .spyOn(navigator, 'userAgent', 'get') + .mockImplementation(() => 'Android'); + let onSelectionChange = jest.fn(); let onAction = jest.fn(); let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); diff --git a/packages/react-aria/src/utils/isVirtualEvent.ts b/packages/react-aria/src/utils/isVirtualEvent.ts index 77053781f1e..29dc6ffaf36 100644 --- a/packages/react-aria/src/utils/isVirtualEvent.ts +++ b/packages/react-aria/src/utils/isVirtualEvent.ts @@ -48,7 +48,8 @@ export function isVirtualPointerEvent(event: PointerEvent): boolean { // Talkback double tap from Windows Firefox touch screen press return ( (!isAndroid() && event.width === 0 && event.height === 0) || - (event.width === 1 && + (isAndroid() && + event.width === 1 && event.height === 1 && event.pressure === 0 && event.detail === 0 && diff --git a/packages/react-aria/test/dnd/dnd.test.js b/packages/react-aria/test/dnd/dnd.test.js index 848278cc8a3..20295155174 100644 --- a/packages/react-aria/test/dnd/dnd.test.js +++ b/packages/react-aria/test/dnd/dnd.test.js @@ -2882,6 +2882,9 @@ describe('useDrag and useDrop', function () { }); it('should support clicking the original drag target to cancel drag (virtual pointer event)', async () => { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android'); + let tree = render( <> @@ -2942,6 +2945,9 @@ describe('useDrag and useDrop', function () { }); it('should support double tapping the drop target to complete drag (virtual pointer event)', async () => { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android'); + let onDrop = jest.fn(); let tree = render( <> diff --git a/packages/react-aria/test/interactions/usePress.test.js b/packages/react-aria/test/interactions/usePress.test.js index 4aa431633c2..2ac25bcc4d8 100644 --- a/packages/react-aria/test/interactions/usePress.test.js +++ b/packages/react-aria/test/interactions/usePress.test.js @@ -1218,6 +1218,9 @@ describe('usePress', function () { }); it('should detect Android TalkBack double tap', function () { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android'); + let events = []; let addEvent = e => events.push(e); let res = render( @@ -1311,6 +1314,45 @@ describe('usePress', function () { ]); }); + it('should fire if pressure is 0 but is not android', function () { + let events = []; + let addEvent = e => events.push(e); + let res = render( + addEvent({type: e.type, target: e.target})} + /> + ); + + let el = res.getByText('test'); + fireEvent( + el, + pointerEvent('pointerdown', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + }) + ); + fireEvent( + el, + pointerEvent('pointerup', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + }) + ); + expect(events).not.toEqual([]); + }); + it('should not fire press/click events for disabled elements', function () { let events = []; let addEvent = e => events.push(e); @@ -5038,6 +5080,9 @@ describe('usePress', function () { }); it('should detect Android TalkBack double tap', function () { + // oxlint-disable-next-line no-unused-vars + using uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android'); + const shadowRoot = setupShadowDOMTest({onPressChange: null}); const el = shadowRoot.getElementById('testElement'); @@ -5111,6 +5156,46 @@ describe('usePress', function () { ]); }); + it('should fire if pressure is 0 but is not android', function () { + let events = []; + let addEvent = e => events.push(e); + let res = render( + addEvent({type: e.type, target: e.target})} + /> + ); + + let el = res.getByText('test'); + fireEvent( + el, + pointerEvent('pointerdown', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + }) + ); + fireEvent( + el, + pointerEvent('pointerup', { + pointerId: 1, + width: 1, + height: 1, + pressure: 0, + detail: 0, + pointerType: 'mouse' + }) + ); + + expect(events).not.toEqual([]); + }); + it('should not fire press/click events for disabled elements', function () { const shadowRoot = setupShadowDOMTest({ isDisabled: true, From ba923ddf8732e0a9c056c2d67f900e71cfe94d74 Mon Sep 17 00:00:00 2001 From: John Costa Date: Thu, 25 Jun 2026 16:14:26 -0700 Subject: [PATCH 4/8] fix(dialog): add aria-describedby support for alertdialog role (#9924) * fix(dialog): add aria-describedby support for alertdialog role AlertDialog was rendering with role="alertdialog" but without aria-describedby, which the WAI-ARIA spec recommends for alertdialogs to reference the alert message content. Changes: - useDialog hook: generate a content ID via useSlotId when role is "alertdialog", return contentProps with the ID, and set aria-describedby on the dialog element - V3 Dialog: spread contentProps onto the content slot so the Content element receives the generated ID - RAC Dialog: destructure contentProps (available for user composition) The aria-describedby is only auto-wired for alertdialog role. Regular dialogs are unaffected. Users can override via the aria-describedby prop. Fixes #9916 * fix: wire contentProps via TextContext for RAC alertdialog aria-describedby * test: add RAC integration test for alertdialog aria-describedby via Text slot * fix: sort TextContext import alphabetically in RAC Dialog * update tests, add warning to RAC * add describedby to s2 alert dialog * fix test * refactor, add tests, fix image labels in examples * fix lint * fix: allow aria-describedby override in v3 AlertDialog Extends SpectrumAlertDialogProps with AriaLabelingProps and passes {labelable: true} to filterDOMProps so user-supplied aria-describedby flows through to the inner Dialog instead of being stripped. Mirrors the S2 AlertDialog treatment already merged by snowystinger, and aligns v3 AlertDialog with regular v3 Dialog, which has these props transitively via AriaDialogProps. Adds a test verifying the override reaches the rendered alertdialog element. * fix(useDialog): drop contentId when aria-describedby is user-provided If the consumer supplies their own aria-describedby on an alertdialog, useDialog no longer generates a slot id for contentProps. This mirrors the existing handling of titleId vs aria-label and prevents downstream wrappers (e.g. RAC Dialog's TextContext for the description slot) from receiving an id no one references. Adds a test covering the override path. * fix(s2): wire aria-describedby on S2 AlertDialog via explicit contentId Generates a contentId in S2 AlertDialog and passes it to both the underlying Dialog (as aria-describedby) and to (as id). This puts the aria-describedby target directly on the Content element rather than on the Text slot wrapper, and keeps the wiring contained within AlertDialog instead of relying on the RAC TextContext path. A consumer-supplied aria-describedby continues to take precedence; in that case we omit the generated id from so we don't add an unused id. * fix(s2): import useId from react-aria/useId, not 'react' React.useId is React 18+ only, breaking test-16 and test-17 in CI. Every other S2 file uses the polyfilled useId from react-aria/useId. * fix some merge changes * move warning down to hook and remove extra code from s2 * test: use renamed DialogTester.getDialog() API after merge main renamed the DialogTester getters to methods (get dialog() -> getDialog()). The alertdialog aria-describedby test still used the old .dialog getter, which now returns undefined and fails the assertions. Match the getDialog() API already used elsewhere in this file. Co-Authored-By: Claude Opus 4.8 * make more forgiving since spec says "should" --------- Co-authored-by: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Co-authored-by: Rob Snow Co-authored-by: Claude Opus 4.8 --- .../react-spectrum/src/dialog/AlertDialog.tsx | 6 +- .../react-spectrum/src/dialog/Dialog.tsx | 6 +- .../test/dialog/AlertDialog.test.js | 33 +++++ .../@react-spectrum/s2/src/AlertDialog.tsx | 8 +- packages/@react-spectrum/s2/src/Dialog.tsx | 33 +++-- .../s2/test/AlertDialog.test.tsx | 85 +++++++++++++ .../s2/test/StandardDialog.test.tsx | 120 ++++++++++++++++++ .../dev/s2-docs/pages/react-aria/Modal.mdx | 1 + packages/dev/s2-docs/pages/s2/Dialog.mdx | 4 +- packages/react-aria-components/src/Dialog.tsx | 12 +- .../react-aria-components/test/Dialog.test.js | 37 +++++- .../test/RadioGroup.test.js | 1 + packages/react-aria/src/dialog/useDialog.ts | 14 +- .../react-aria/test/dialog/useDialog.test.js | 59 ++++++++- 14 files changed, 394 insertions(+), 25 deletions(-) create mode 100644 packages/@react-spectrum/s2/test/AlertDialog.test.tsx create mode 100644 packages/@react-spectrum/s2/test/StandardDialog.test.tsx diff --git a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx index 38428c884e7..8be387d4a8a 100644 --- a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx +++ b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx @@ -11,6 +11,7 @@ */ import AlertMedium from '@spectrum-icons/ui/AlertMedium'; +import {AriaLabelingProps, DOMProps, DOMRef, StyleProps} from '@react-types/shared'; import {Button, SpectrumButtonProps} from '../button/Button'; import {ButtonGroup} from '../buttongroup/ButtonGroup'; import {chain} from 'react-aria/chain'; @@ -19,7 +20,6 @@ import {Content} from '../view/Content'; import {Dialog} from './Dialog'; import {DialogContext, DialogContextValue} from './context'; import {Divider} from '../divider/Divider'; -import {DOMProps, DOMRef, StyleProps} from '@react-types/shared'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {Heading} from '../text/Heading'; import intlMessages from '../../intl/dialog/*.json'; @@ -28,7 +28,7 @@ import styles from '@adobe/spectrum-css-temp/components/dialog/vars.css'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useStyleProps} from '../utils/styleProps'; -export interface SpectrumAlertDialogProps extends DOMProps, StyleProps { +export interface SpectrumAlertDialogProps extends AriaLabelingProps, DOMProps, StyleProps { /** The [visual style](https://spectrum.adobe.com/page/alert-dialog/#Options) of the AlertDialog. */ variant?: 'confirmation' | 'information' | 'destructive' | 'error' | 'warning'; /** The title of the AlertDialog. */ @@ -105,7 +105,7 @@ export const AlertDialog = forwardRef(function AlertDialog( size="M" role="alertdialog" ref={ref} - {...filterDOMProps(props)}> + {...filterDOMProps(props, {labelable: true})}> {title} {(variant === 'error' || variant === 'warning') && ( diff --git a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx index 5bbf5c72edc..a0b86e78a88 100644 --- a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx +++ b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx @@ -69,7 +69,7 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp let domRef = useDOMRef(ref); let gridRef = useRef(null); let sizeVariant = sizeMap[type] || sizeMap[size]; - let {dialogProps, titleProps} = useDialog(mergeProps(contextProps, props), domRef); + let {dialogProps, titleProps, contentProps} = useDialog(mergeProps(contextProps, props), domRef); // oxlint-disable-next-line react/react-compiler let hasHeader = useHasChild(`.${styles['spectrum-Dialog-header']}`, unwrapDOMRef(gridRef)); @@ -100,7 +100,7 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp }, typeIcon: {UNSAFE_className: styles['spectrum-Dialog-typeIcon']}, divider: {UNSAFE_className: styles['spectrum-Dialog-divider'], size: 'M'}, - content: {UNSAFE_className: styles['spectrum-Dialog-content']}, + content: {UNSAFE_className: styles['spectrum-Dialog-content'], ...contentProps}, footer: {UNSAFE_className: styles['spectrum-Dialog-footer']}, buttonGroup: { UNSAFE_className: classNames(styles, 'spectrum-Dialog-buttonGroup', { @@ -110,7 +110,7 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp } }), // eslint-disable-next-line react-hooks/exhaustive-deps - [hasFooter, hasHeader, titleProps] + [hasFooter, hasHeader, titleProps, contentProps] ); return ( diff --git a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js index 9a85f56405a..0c840198996 100644 --- a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js +++ b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js @@ -246,4 +246,37 @@ describe('AlertDialog', function () { let primaryBtn = getByTestId('rsp-AlertDialog-confirmButton'); expect(primaryBtn).toBeDefined(); }); + + it('should have aria-describedby pointing to the content', function () { + let {getByRole} = render( + + + Content body + + + ); + + let dialog = getByRole('alertdialog'); + expect(dialog).toHaveAttribute('aria-describedby'); + let contentId = dialog.getAttribute('aria-describedby'); + let content = document.getElementById(contentId); + expect(content).not.toBeNull(); + expect(content.textContent).toBe('Content body'); + }); + + it('accepts custom aria-describedby', function () { + let {getByRole} = render( + + + Content body + + + ); + + expect(getByRole('alertdialog')).toHaveAttribute('aria-describedby', 'content-id'); + }); }); diff --git a/packages/@react-spectrum/s2/src/AlertDialog.tsx b/packages/@react-spectrum/s2/src/AlertDialog.tsx index a9cdffbb1a9..38d8ee5a0aa 100644 --- a/packages/@react-spectrum/s2/src/AlertDialog.tsx +++ b/packages/@react-spectrum/s2/src/AlertDialog.tsx @@ -11,13 +11,14 @@ */ import AlertTriangle from '../s2wf-icons/S2_Icon_AlertTriangle_20_N.svg'; +import {AriaLabelingProps, DOMProps, DOMRef} from '@react-types/shared'; import {Button} from './Button'; import {ButtonGroup} from './ButtonGroup'; import {CenterBaseline} from './CenterBaseline'; import {chain} from 'react-aria/chain'; import {Content, Heading} from './Content'; import {Dialog} from './Dialog'; -import {DOMProps, DOMRef} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; import {forwardRef, ReactNode} from 'react'; import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; @@ -27,7 +28,7 @@ import {style} from '../style' with {type: 'macro'}; import {UnsafeStyles} from './style-utils' with {type: 'macro'}; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; -export interface AlertDialogProps extends DOMProps, UnsafeStyles { +export interface AlertDialogProps extends AriaLabelingProps, DOMProps, UnsafeStyles { /** * The [visual style](https://spectrum.adobe.com/page/alert-dialog/#Options) of the AlertDialog. * @@ -105,8 +106,11 @@ export const AlertDialog = forwardRef(function AlertDialog(props: AlertDialogPro buttonVariant = 'negative'; } + let domProps = filterDOMProps(props, {labelable: true}); + return ( } {/* Main content */} - - {children} - + + {value => { + let contentValue = {}; + if (value && 'slots' in value && value.slots?.description) { + contentValue = value.slots.description; + } + return ( + + {children} + + ); + }} + {/* Footer and button group */}
{ + let user; + beforeAll(() => { + jest.useFakeTimers(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it('automatically links to the content with aria-describedby', async () => { + let {getByRole} = render( + + Open dialog + + Test content + + + ); + + let trigger = getByRole('button'); + await user.click(trigger); + act(() => { + jest.runAllTimers(); + }); + let dialog = getByRole('alertdialog'); + expect(dialog).toBeVisible(); + let description = dialog.getAttribute('aria-describedby'); + expect(description).toBeDefined(); + let content = document.getElementById(description!); + expect(content).toHaveTextContent('Test content'); + }); + + it('accepts custom aria-describedby', async () => { + let {getByRole} = render( + + Open dialog + + +

Test content

+

Extra content

+
+
+
+ ); + + let trigger = getByRole('button'); + await user.click(trigger); + act(() => { + jest.runAllTimers(); + }); + let dialog = getByRole('alertdialog'); + expect(dialog).toBeVisible(); + let description = dialog.getAttribute('aria-describedby'); + expect(description).toBeDefined(); + let content = document.getElementById(description!); + expect(content).toHaveTextContent('Test content'); + }); +}); diff --git a/packages/@react-spectrum/s2/test/StandardDialog.test.tsx b/packages/@react-spectrum/s2/test/StandardDialog.test.tsx new file mode 100644 index 00000000000..a35318fd556 --- /dev/null +++ b/packages/@react-spectrum/s2/test/StandardDialog.test.tsx @@ -0,0 +1,120 @@ +/* + * 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 {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {ActionButton} from '../src/ActionButton'; +import {Button} from '../src/Button'; +import {ButtonGroup} from '../src/ButtonGroup'; +import {Checkbox} from '../src/Checkbox'; +import {Content, Footer, Header, Heading} from '../src/Content'; +import {Dialog} from '../src/Dialog'; +import {DialogTrigger} from '../src/DialogTrigger'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +describe('StandardDialog', () => { + let user; + beforeAll(() => { + jest.useFakeTimers(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + afterEach(() => { + jest.clearAllMocks(); + act(() => jest.runAllTimers()); + }); + + afterAll(function () { + jest.restoreAllMocks(); + }); + + it('does not automatically add aria-describedby', async () => { + let {getByRole} = render( + + Open dialog + + {({close}) => ( + <> + Dialog title +
Header
+ This is the content of the dialog. +
+ Don't show this again +
+ + + + + + )} +
+
+ ); + + let trigger = getByRole('button'); + await user.click(trigger); + act(() => { + jest.runAllTimers(); + }); + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + let description = dialog.getAttribute('aria-describedby'); + expect(description).toBeNull(); + }); + + it('accepts custom aria-describedby', async () => { + let {getByRole} = render( + + Open dialog + + {({close}) => ( + <> + Dialog title +
Header
+ +

This is the content of the dialog.

+

Extra content

+
+
+ Don't show this again +
+ + + + + + )} +
+
+ ); + + let trigger = getByRole('button'); + await user.click(trigger); + act(() => { + jest.runAllTimers(); + }); + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + let description = dialog.getAttribute('aria-describedby'); + expect(description).toBeDefined(); + let content = document.getElementById(description!); + expect(content).toHaveTextContent('This is the content of the dialog.'); + }); +}); diff --git a/packages/dev/s2-docs/pages/react-aria/Modal.mdx b/packages/dev/s2-docs/pages/react-aria/Modal.mdx index 4e905dad708..3c613b338b6 100644 --- a/packages/dev/s2-docs/pages/react-aria/Modal.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Modal.mdx @@ -188,6 +188,7 @@ const CustomTrigger = React.forwardRef((props, ref) => ( + diff --git a/packages/dev/s2-docs/pages/s2/Dialog.mdx b/packages/dev/s2-docs/pages/s2/Dialog.mdx index ba071d22176..94dc70e7dfb 100644 --- a/packages/dev/s2-docs/pages/s2/Dialog.mdx +++ b/packages/dev/s2-docs/pages/s2/Dialog.mdx @@ -29,7 +29,7 @@ function Example(props) { {({close}) => ( <> - + Subscribe to our newsletter

Enter your information to subscribe to our newsletter and receive updates about new features and announcements.

@@ -76,7 +76,7 @@ function Example(props) { {({close}) => ( <> - + Dialog Title
Header
diff --git a/packages/react-aria-components/src/Dialog.tsx b/packages/react-aria-components/src/Dialog.tsx index ef1a9d80c04..cd92cf30f06 100644 --- a/packages/react-aria-components/src/Dialog.tsx +++ b/packages/react-aria-components/src/Dialog.tsx @@ -40,6 +40,7 @@ import React, { useRef } from 'react'; import {RootMenuTriggerStateContext} from './Menu'; +import {TextContext} from './Text'; import {useId} from 'react-aria/useId'; import {useMenuTriggerState} from 'react-stately/useMenuTriggerState'; import {useOverlayTrigger} from 'react-aria/useOverlayTrigger'; @@ -124,7 +125,7 @@ export const Dialog = /*#__PURE__*/ (forwardRef as forwardRefType)(function Dial ) { let originalAriaLabelledby = props['aria-labelledby']; [props, ref] = useContextProps(props, ref, DialogContext); - let {dialogProps, titleProps} = useDialog( + let {dialogProps, titleProps, contentProps} = useDialog( { ...props, // Only pass aria-labelledby from props, not context. @@ -176,6 +177,15 @@ export const Dialog = /*#__PURE__*/ (forwardRef as forwardRefType)(function Dial } } ], + [ + TextContext, + { + slots: { + [DEFAULT_SLOT]: {}, + description: contentProps + } + } + ], [ ButtonContext, { diff --git a/packages/react-aria-components/test/Dialog.test.js b/packages/react-aria-components/test/Dialog.test.js index 9621e7c90d8..1ea070282cf 100644 --- a/packages/react-aria-components/test/Dialog.test.js +++ b/packages/react-aria-components/test/Dialog.test.js @@ -23,6 +23,7 @@ import {OverlayArrow} from '../src/OverlayArrow'; import {Popover} from '../src/Popover'; import React, {useRef} from 'react'; import * as stories from '../stories/Modal.stories'; +import {Text} from '../src/Text'; import {TextField} from '../src/TextField'; import {UNSAFE_PortalProvider} from 'react-aria/PortalProvider'; import {User} from '@react-aria/test-utils'; @@ -59,6 +60,7 @@ describe('Dialog', () => { {({close}) => ( <> Alert + This is the alert message. )} @@ -75,7 +77,6 @@ describe('Dialog', () => { let heading = getByRole('heading'); expect(dialog).toHaveAttribute('aria-labelledby', heading.id); expect(dialog).toHaveAttribute('data-test', 'dialog'); - expect(dialog.closest('.react-aria-Modal')).toHaveAttribute('data-test', 'modal'); expect(dialog.closest('.react-aria-ModalOverlay')).toBeInTheDocument(); @@ -85,6 +86,36 @@ describe('Dialog', () => { expect(dialog).not.toBeInTheDocument(); }); + it('should set aria-describedby when Text slot="description" is used in alertdialog', async () => { + let {getByRole} = render( + + + + + {({close}) => ( + <> + Alert Title + This is the alert message. + + + )} + + + + ); + + let button = getByRole('button'); + let dialogTester = testUtilUser.createTester('Dialog', {root: button, overlayType: 'modal'}); + await dialogTester.open(); + let dialog = dialogTester.getDialog(); + expect(dialog).toHaveAttribute('role', 'alertdialog'); + expect(dialog).toHaveAttribute('aria-describedby'); + let descId = dialog.getAttribute('aria-describedby'); + let descEl = document.getElementById(descId); + expect(descEl).not.toBeNull(); + expect(descEl.textContent).toBe('This is the alert message.'); + }); + it('works with modal and custom underlay', async () => { let {getByRole} = render( @@ -95,6 +126,7 @@ describe('Dialog', () => { {({close}) => ( <> Alert + This is the alert message. )} @@ -130,6 +162,7 @@ describe('Dialog', () => { {({close}) => ( <> Alert + This is the alert message. )} @@ -325,6 +358,7 @@ describe('Dialog', () => { {({close}) => ( <> Alert + This is the alert message. )} @@ -367,6 +401,7 @@ describe('Dialog', () => { {({close}) => ( <> Alert + This is the alert message. )} diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 357564454ca..01b314f58e6 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -524,6 +524,7 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { buttonClassName: ({isFocusVisible}) => (isFocusVisible ? 'focus' : '') }} /> + Alert description )} diff --git a/packages/react-aria/src/dialog/useDialog.ts b/packages/react-aria/src/dialog/useDialog.ts index f27917a23d2..1a7a021a9a5 100644 --- a/packages/react-aria/src/dialog/useDialog.ts +++ b/packages/react-aria/src/dialog/useDialog.ts @@ -39,6 +39,9 @@ export interface DialogAria { /** Props for the dialog title element. */ titleProps: DOMAttributes; + + /** Props for the dialog content/description element. Used for aria-describedby on alertdialogs. */ + contentProps: DOMAttributes; } /** @@ -53,6 +56,9 @@ export function useDialog( let titleId: string | undefined = useSlotId(); titleId = props['aria-label'] ? undefined : titleId; + let contentId: string | undefined = useSlotId(); + contentId = role === 'alertdialog' && !props['aria-describedby'] ? contentId : undefined; + let isRefocusing = useRef(false); // Focus the dialog itself on mount, unless a child element is already focused. @@ -105,6 +111,8 @@ export function useDialog( } }); + let ariaDescribedby = props['aria-describedby'] ?? contentId; + // We do not use aria-modal due to a Safari bug which forces the first focusable element to be focused // on mount when inside an iframe, no matter which element we programmatically focus. // See https://bugs.webkit.org/show_bug.cgi?id=211934. @@ -115,7 +123,8 @@ export function useDialog( ...filterDOMProps(props, {labelable: true}), role, tabIndex: -1, - 'aria-labelledby': props['aria-labelledby'] || titleId, + 'aria-labelledby': props['aria-labelledby'] ?? titleId, + 'aria-describedby': ariaDescribedby, // Prevent blur events from reaching useOverlay, which may cause // popovers to close. Since focus is contained within the dialog, // we don't want this to occur due to the above useEffect. @@ -127,6 +136,9 @@ export function useDialog( }, titleProps: { id: titleId + }, + contentProps: { + id: contentId } }; } diff --git a/packages/react-aria/test/dialog/useDialog.test.js b/packages/react-aria/test/dialog/useDialog.test.js index 13f469e5771..83c0b09c91a 100644 --- a/packages/react-aria/test/dialog/useDialog.test.js +++ b/packages/react-aria/test/dialog/useDialog.test.js @@ -33,7 +33,9 @@ describe('useDialog', function () { }); it('should accept role="alertdialog"', function () { - let res = render(); + let res = render( + + ); let el = res.getByTestId('test'); expect(el).toHaveAttribute('role', 'alertdialog'); }); @@ -55,6 +57,61 @@ describe('useDialog', function () { expect(document.activeElement).toBe(input); }); + describe('aria-describedby for alertdialog', function () { + function AlertDialogExample(props) { + let ref = useRef(); + let {dialogProps, titleProps, contentProps} = useDialog({role: 'alertdialog', ...props}, ref); + return ( +
+

Alert Title

+ {props.showContent &&

Alert message content

} + {props.children} +
+ ); + } + + it('should set aria-describedby on alertdialog when content is rendered', function () { + let res = render(); + let el = res.getByTestId('test'); + let contentEl = el.querySelector('p'); + expect(el).toHaveAttribute('aria-describedby', contentEl.id); + }); + + it('should not auto-wire aria-describedby on regular dialog, but contentProps.id is still provided', function () { + function RegularDialogExample(props) { + let ref = useRef(); + let {dialogProps, titleProps, contentProps} = useDialog(props, ref); + return ( +
+

Title

+

Content

+
+ ); + } + + let res = render(); + let el = res.getByTestId('test'); + expect(el).not.toHaveAttribute('aria-describedby'); + }); + + it('should allow aria-describedby override on alertdialog', function () { + let res = render( + + ); + let el = res.getByTestId('test'); + expect(el).toHaveAttribute('aria-describedby', 'custom-id'); + }); + + it('should not generate contentProps.id when aria-describedby is provided', function () { + let res = render( + + ); + let el = res.getByTestId('test'); + let contentEl = el.querySelector('p'); + expect(contentEl).not.toHaveAttribute('id'); + }); + }); + describe('dev warnings', function () { let originalWarn; From a02c505bd13ea69b794fa79e10212a3d8b82d5d2 Mon Sep 17 00:00:00 2001 From: XiaoYan Li Date: Fri, 26 Jun 2026 07:24:41 +0800 Subject: [PATCH 5/8] fix: re-export RenderProps-related types from react-aria-components to (#9955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix downstream TS2742 The v1.17.0 types refactor split the bundled types.d.ts into per-source files. RenderProps extends StyleRenderProps extends DOMRenderProps, and StyleRenderProps['style'] uses StyleOrFunction — neither was publicly nameable. Downstream packages built with composite/declaration emit then fail with TS2742 on any inferred type whose expansion crosses these names. Adds `export` to StyleOrFunction and re-exports DOMRenderProps, PossibleLinkDOMRenderProps, DOMRenderFunction, ClassNameOrFunction, StyleOrFunction, and ChildrenOrFunction from the package entry. Type-only, additive — no runtime impact. Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Robert Snow --- packages/react-aria-components/exports/index.ts | 13 ++++++++++++- packages/react-aria-components/src/utils.tsx | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index 3b0a0720dc9..08d210af006 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -522,7 +522,18 @@ export type { DropIndicatorProps, DropIndicatorRenderProps } from '../src/useDragAndDrop'; -export type {ContextValue, RenderProps, SlotProps, StyleRenderProps} from '../src/utils'; +export type { + ContextValue, + RenderProps, + SlotProps, + StyleRenderProps, + DOMRenderProps, + PossibleLinkDOMRenderProps, + DOMRenderFunction, + ClassNameOrFunction, + StyleOrFunction, + ChildrenOrFunction +} from '../src/utils'; export type {VirtualizerProps} from '../src/Virtualizer'; export type {DateValue} from 'react-stately/useDateFieldState'; diff --git a/packages/react-aria-components/src/utils.tsx b/packages/react-aria-components/src/utils.tsx index 2fbc6316962..9fd8c58ec27 100644 --- a/packages/react-aria-components/src/utils.tsx +++ b/packages/react-aria-components/src/utils.tsx @@ -166,7 +166,7 @@ export interface DOMProps extends StyleProps, SharedDOMProps { export type ClassNameOrFunction = | string | ((values: T & {defaultClassName: string | undefined}) => string); -type StyleOrFunction = +export type StyleOrFunction = | CSSProperties | ((values: T & {defaultStyle: CSSProperties}) => CSSProperties | undefined); From ada64615aed239bfdaf48742fb3789ddb71307c5 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 25 Jun 2026 18:33:45 -0500 Subject: [PATCH 6/8] fix(useId): prevent FinalizationRegistry entry leak in useId (#9853) * fix(useId): avoid FinalizationRegistry entry leak in useId * fix test in React 16/17 * add comments to test * additional test: changing the id should unregister the old token and register the new one --------- Co-authored-by: Robert Snow --- packages/react-aria/src/utils/useId.ts | 16 +- packages/react-aria/test/utils/useId.test.jsx | 158 ++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/react-aria/test/utils/useId.test.jsx diff --git a/packages/react-aria/src/utils/useId.ts b/packages/react-aria/src/utils/useId.ts index b7804b137ba..cf7fefc1066 100644 --- a/packages/react-aria/src/utils/useId.ts +++ b/packages/react-aria/src/utils/useId.ts @@ -30,6 +30,7 @@ if (typeof FinalizationRegistry !== 'undefined') { idsUpdaterMap.delete(heldValue); }); } +let registeredIds = new WeakMap(); /** * If a default is not provided, generate an id. @@ -43,9 +44,19 @@ export function useId(defaultId?: string): string { let res = useSSRSafeId(value); let cleanupRef = useRef(null); - if (registry) { + // These are intentionally disabled the compiler, these functions just read the identity + // of the ref, not the value inside current. + // oxlint-disable-next-line react/react-compiler + let registeredId = registeredIds.get(cleanupRef); + if (registry && registeredId !== res) { + if (registeredId != null) { + // oxlint-disable-next-line react/react-compiler + registry.unregister(cleanupRef); + } + // oxlint-disable-next-line react/react-compiler + registry.register(cleanupRef, res, cleanupRef); // oxlint-disable-next-line react/react-compiler - registry.register(cleanupRef, res); + registeredIds.set(cleanupRef, res); } if (canUseDOM) { @@ -67,6 +78,7 @@ export function useId(defaultId?: string): string { // when it is though, also remove it from the finalization registry. if (registry) { registry.unregister(cleanupRef); + registeredIds.delete(cleanupRef); } idsUpdaterMap.delete(r); }; diff --git a/packages/react-aria/test/utils/useId.test.jsx b/packages/react-aria/test/utils/useId.test.jsx new file mode 100644 index 00000000000..d36bddf8608 --- /dev/null +++ b/packages/react-aria/test/utils/useId.test.jsx @@ -0,0 +1,158 @@ +/* + * Copyright 2026 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. + */ + +describe('useId', function () { + let OriginalFinalizationRegistry = global.FinalizationRegistry; + + afterEach(() => { + global.FinalizationRegistry = OriginalFinalizationRegistry; + jest.resetModules(); + }); + + it('registers once per mounted id and uses an unregister token', function () { + let register = jest.fn(); + let unregister = jest.fn(); + + global.FinalizationRegistry = jest.fn(function () { + this.register = register; + this.unregister = unregister; + }); + + jest.isolateModules(() => { + let React = require('react'); + let ReactDOM = require('react-dom'); + let {act} = require('react-dom/test-utils'); + let {useId} = require('../../src/utils/useId'); + let isReact18OrHigher = parseInt(React.version, 10) >= 18; + + function Test({tick}) { + let id = useId(); + return React.createElement('div', {'data-id': id}, tick); + } + + let container = document.createElement('div'); + document.body.appendChild(container); + + // Use createRoot for React 18+ and ReactDOM.render for older + let renderElement; + let unmount; + if (isReact18OrHigher) { + let {createRoot} = require('react-dom/client'); + global.IS_REACT_ACT_ENVIRONMENT = true; + let root = createRoot(container); + renderElement = el => root.render(el); + unmount = () => root.unmount(); + } else { + renderElement = el => ReactDOM.render(el, container); + unmount = () => ReactDOM.unmountComponentAtNode(container); + } + + act(() => { + renderElement(React.createElement(Test, {tick: 0})); + }); + + act(() => { + renderElement(React.createElement(Test, {tick: 1})); + }); + + act(() => { + renderElement(React.createElement(Test, {tick: 2})); + }); + + // Re-rendering the same mounted hook should not add more registry entries. + expect(register).toHaveBeenCalledTimes(1); + // The held value should be the generated id string, and the unregister token should match + // the target object so useId can remove the same registration during cleanup. + expect(register.mock.calls[0][1]).toEqual(expect.any(String)); + expect(register.mock.calls[0][2]).toBe(register.mock.calls[0][0]); + + act(() => { + unmount(); + }); + + document.body.removeChild(container); + + // Unmount should remove the specific registration created for this hook instance. + expect(unregister).toHaveBeenCalledTimes(1); + expect(unregister).toHaveBeenCalledWith(register.mock.calls[0][2]); + }); + }); + + it('unregisters the previous id and re-registers when the id changes', function () { + let register = jest.fn(); + let unregister = jest.fn(); + + global.FinalizationRegistry = jest.fn(function () { + this.register = register; + this.unregister = unregister; + }); + + jest.isolateModules(() => { + let React = require('react'); + let ReactDOM = require('react-dom'); + let {act} = require('react-dom/test-utils'); + let {useId, mergeIds} = require('../../src/utils/useId'); + let isReact18OrHigher = parseInt(React.version, 10) >= 18; + + function Test({tick}) { + let id = useId(); + return React.createElement('div', {'data-id': id}, tick); + } + + let container = document.createElement('div'); + document.body.appendChild(container); + + let renderElement; + let unmount; + if (isReact18OrHigher) { + let {createRoot} = require('react-dom/client'); + global.IS_REACT_ACT_ENVIRONMENT = true; + let root = createRoot(container); + renderElement = el => root.render(el); + unmount = () => root.unmount(); + } else { + renderElement = el => ReactDOM.render(el, container); + unmount = () => ReactDOM.unmountComponentAtNode(container); + } + + act(() => { + renderElement(React.createElement(Test, {tick: 0})); + }); + + expect(register).toHaveBeenCalledTimes(1); + let firstToken = register.mock.calls[0][2]; + // The held value passed to FinalizationRegistry.register is the id string. + let firstId = register.mock.calls[0][1]; + + // mergeIds sets the internal nextId ref and the next render's useEffect flushes that + // through setValue, producing a new res and exercising the id-change branch. + act(() => { + mergeIds(firstId, 'changed-id'); + renderElement(React.createElement(Test, {tick: 1})); + }); + + // The previous registration should be unregistered with the original token, and the new id + // should be registered once. + expect(unregister).toHaveBeenCalledWith(firstToken); + expect(register).toHaveBeenCalledTimes(2); + expect(register.mock.calls[1][1]).toBe('changed-id'); + // Re-using the same target object means the WeakMap key is stable across re-registrations. + expect(register.mock.calls[1][0]).toBe(register.mock.calls[0][0]); + + act(() => { + unmount(); + }); + + document.body.removeChild(container); + }); + }); +}); From d52472c1a97eb4e1c2df22a1fa428d1d7c9fda34 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 25 Jun 2026 17:20:55 -0700 Subject: [PATCH 7/8] fix: prefer ancestor drop target over nearest-by-distance in DragManager (#10170) Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Robert Snow --- packages/react-aria/src/dnd/DragManager.ts | 7 +++- packages/react-aria/test/dnd/dnd.test.js | 49 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/react-aria/src/dnd/DragManager.ts b/packages/react-aria/src/dnd/DragManager.ts index 637cdf0daf6..8d915d67286 100644 --- a/packages/react-aria/src/dnd/DragManager.ts +++ b/packages/react-aria/src/dnd/DragManager.ts @@ -529,8 +529,13 @@ class DragSession { let minDistance = Infinity; let nearest = -1; + let ancestor = -1; for (let i = 0; i < this.validDropTargets.length; i++) { let dropTarget = this.validDropTargets[i]; + if (ancestor < 0 && nodeContains(dropTarget.element, this.dragTarget.element)) { + ancestor = i; + } + let rect = dropTarget.element.getBoundingClientRect(); let dx = rect.left - dragTargetRect.left; let dy = rect.top - dragTargetRect.top; @@ -541,7 +546,7 @@ class DragSession { } } - return nearest; + return ancestor >= 0 ? ancestor : nearest; } setCurrentDropTarget(dropTarget: DropTarget | null, item?: DroppableItem): void { diff --git a/packages/react-aria/test/dnd/dnd.test.js b/packages/react-aria/test/dnd/dnd.test.js index 20295155174..bdd361eb5fa 100644 --- a/packages/react-aria/test/dnd/dnd.test.js +++ b/packages/react-aria/test/dnd/dnd.test.js @@ -1951,6 +1951,55 @@ describe('useDrag and useDrop', function () { expect(document.activeElement).toBe(droppable); }); + it('should prefer an ancestor drop target over the nearest drop target', async () => { + let onDropEnter = jest.fn(); + let onDropEnter2 = jest.fn(); + let tree = render( + <> + + + + Drop here 2 + + ); + + let draggable = tree.getByText('Drag me'); + let droppable = draggable.parentElement; + let droppable2 = tree.getByText('Drop here 2'); + let rect = (left, top) => ({ + left, + top, + x: left, + y: top, + width: 100, + height: 50 + }); + + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this === droppable) { + return rect(1000, 0); + } + + if (this === droppable2) { + return rect(10, 0); + } + + return rect(0, 0); + }); + + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(draggable); + + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + expect(document.activeElement).toBe(droppable); + expect(droppable).toHaveAttribute('data-droptarget', 'true'); + expect(droppable2).toHaveAttribute('data-droptarget', 'false'); + expect(onDropEnter).toHaveBeenCalledTimes(1); + expect(onDropEnter2).not.toHaveBeenCalled(); + }); + it('should cancel the drag when pressing the escape key', async () => { let tree = render( <> From e6c47b546e5586662dff7f2780ba08b96a988543 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 25 Jun 2026 17:29:54 -0700 Subject: [PATCH 8/8] fix: guard ProgressBar/Meter percentage against NaN when min equals max (#10169) * fix: guard ProgressBar/Meter percentage against NaN when min equals max * simplify, fix hooks, and fix v3 * fix lint --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Robert Snow --- .../src/progress/ProgressBarBase.tsx | 3 +- .../src/progress/ProgressCircle.tsx | 3 +- packages/react-aria-components/src/Meter.tsx | 4 +- .../react-aria-components/src/ProgressBar.tsx | 11 ++- .../react-aria-components/test/Meter.test.js | 40 ++++++++++ .../test/ProgressBar.test.js | 73 +++++++++++++++++-- .../react-aria/src/progress/useProgressBar.ts | 3 +- 7 files changed, 124 insertions(+), 13 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx b/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx index 6a5bd6f8a69..1059ac8a5fc 100644 --- a/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx +++ b/packages/@adobe/react-spectrum/src/progress/ProgressBarBase.tsx @@ -86,7 +86,8 @@ export const ProgressBarBase = React.forwardRef(function ProgressBarBase( let barStyle: CSSProperties = {}; if (!isIndeterminate) { - let percentage = (value - minValue) / (maxValue - minValue); + let range = maxValue - minValue; + let percentage = range === 0 ? 0 : (value - minValue) / range; barStyle.width = `${Math.round(percentage * 100)}%`; } diff --git a/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx b/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx index 9b0c82c1146..a78692cfcb1 100644 --- a/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx +++ b/packages/@adobe/react-spectrum/src/progress/ProgressCircle.tsx @@ -92,7 +92,8 @@ export const ProgressCircle = React.forwardRef(function ProgressCircle( let subMask1Style: CSSProperties = {}; let subMask2Style: CSSProperties = {}; if (!isIndeterminate) { - let percentage = ((value - minValue) / (maxValue - minValue)) * 100; + let range = maxValue - minValue; + let percentage = range === 0 ? 0 : ((value - minValue) / range) * 100; let angle; if (percentage > 0 && percentage <= 50) { angle = -180 + (percentage / 50) * 180; diff --git a/packages/react-aria-components/src/Meter.tsx b/packages/react-aria-components/src/Meter.tsx index e3f27032dec..d2060e40f95 100644 --- a/packages/react-aria-components/src/Meter.tsx +++ b/packages/react-aria-components/src/Meter.tsx @@ -11,7 +11,6 @@ */ import {AriaMeterProps, useMeter} from 'react-aria/useMeter'; - import {clamp} from 'react-stately/private/utils/number'; import { ClassNameOrFunction, @@ -69,12 +68,13 @@ export const Meter = /*#__PURE__*/ (forwardRef as forwardRefType)(function Meter [props, ref] = useContextProps(props, ref, MeterContext); let {value = 0, minValue = 0, maxValue = 100} = props; value = clamp(value, minValue, maxValue); + let range = maxValue - minValue; let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); let {meterProps, labelProps} = useMeter({...props, label}); // Calculate the width of the progress bar as a percentage - let percentage = ((value - minValue) / (maxValue - minValue)) * 100; + let percentage = range === 0 ? 0 : ((value - minValue) / range) * 100; let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria-components/src/ProgressBar.tsx b/packages/react-aria-components/src/ProgressBar.tsx index 3d546f92174..c57ee4fbbc1 100644 --- a/packages/react-aria-components/src/ProgressBar.tsx +++ b/packages/react-aria-components/src/ProgressBar.tsx @@ -11,7 +11,6 @@ */ import {AriaProgressBarProps, useProgressBar} from 'react-aria/useProgressBar'; - import {clamp} from 'react-stately/private/utils/number'; import { ClassNameOrFunction, @@ -81,8 +80,16 @@ export const ProgressBar = forwardRef(function ProgressBar( let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); let {progressBarProps, labelProps} = useProgressBar({...props, label}); + let range = maxValue - minValue; // Calculate the width of the progress bar as a percentage - let percentage = isIndeterminate ? undefined : ((value - minValue) / (maxValue - minValue)) * 100; + let percentage: number | undefined = undefined; + if (!isIndeterminate) { + if (range === 0) { + percentage = 0; + } else { + percentage = ((value - minValue) / range) * 100; + } + } let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria-components/test/Meter.test.js b/packages/react-aria-components/test/Meter.test.js index aa11b3f80d0..1053af69ae8 100644 --- a/packages/react-aria-components/test/Meter.test.js +++ b/packages/react-aria-components/test/Meter.test.js @@ -22,6 +22,7 @@ let TestMeter = props => ( <> {valueText} + {percentage}
)} @@ -48,6 +49,45 @@ describe('Meter', () => { expect(bar).toHaveStyle('width: 25%'); }); + it('supports a custom range', () => { + let {getByRole} = render(); + + let meter = getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuenow', '3'); + expect(meter).toHaveAttribute('aria-valuemin', '0'); + expect(meter).toHaveAttribute('aria-valuemax', '6'); + expect(meter).toHaveAttribute('aria-valuetext', '50%'); + + let value = meter.querySelector('.value'); + expect(value).toHaveTextContent('50%'); + + let percentage = meter.querySelector('.percentage'); + expect(percentage).toHaveTextContent('50'); + + let bar = meter.querySelector('.bar'); + expect(bar).toHaveStyle('width: 50%'); + }); + + it('renders 0 percent for an empty range', () => { + let {getByRole} = render(); + + let meter = getByRole('meter'); + expect(meter).toHaveAttribute('aria-valuenow', '0'); + expect(meter).toHaveAttribute('aria-valuemin', '0'); + expect(meter).toHaveAttribute('aria-valuemax', '0'); + expect(meter).toHaveAttribute('aria-valuetext', '0%'); + expect(meter).not.toHaveAttribute('aria-valuetext', 'NaN%'); + + let value = meter.querySelector('.value'); + expect(value).toHaveTextContent('0%'); + + let percentage = meter.querySelector('.percentage'); + expect(percentage).toHaveTextContent('0'); + + let bar = meter.querySelector('.bar'); + expect(bar).toHaveStyle('width: 0%'); + }); + it('should support slot', () => { let {getByRole} = render( diff --git a/packages/react-aria-components/test/ProgressBar.test.js b/packages/react-aria-components/test/ProgressBar.test.js index bbee1b2f8ab..15924de3553 100644 --- a/packages/react-aria-components/test/ProgressBar.test.js +++ b/packages/react-aria-components/test/ProgressBar.test.js @@ -22,6 +22,7 @@ let TestProgressBar = props => ( <> {valueText} + {percentage}
)} @@ -48,23 +49,83 @@ describe('ProgressBar', () => { expect(bar).toHaveStyle('width: 25%'); }); + it('supports a custom range', () => { + let {getByRole} = render(); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toHaveAttribute('aria-valuenow', '3'); + expect(progressbar).toHaveAttribute('aria-valuemin', '0'); + expect(progressbar).toHaveAttribute('aria-valuemax', '6'); + expect(progressbar).toHaveAttribute('aria-valuetext', '50%'); + + let value = progressbar.querySelector('.value'); + expect(value).toHaveTextContent('50%'); + + let percentage = progressbar.querySelector('.percentage'); + expect(percentage).toHaveTextContent('50'); + + let bar = progressbar.querySelector('.bar'); + expect(bar).toHaveStyle('width: 50%'); + }); + + it('renders 0 percent for an empty range', () => { + let {getByRole} = render(); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toHaveAttribute('aria-valuenow', '0'); + expect(progressbar).toHaveAttribute('aria-valuemin', '0'); + expect(progressbar).toHaveAttribute('aria-valuemax', '0'); + expect(progressbar).toHaveAttribute('aria-valuetext', '0%'); + expect(progressbar).not.toHaveAttribute('aria-valuetext', 'NaN%'); + + let value = progressbar.querySelector('.value'); + expect(value).toHaveTextContent('0%'); + + let percentage = progressbar.querySelector('.percentage'); + expect(percentage).toHaveTextContent('0'); + + let bar = progressbar.querySelector('.bar'); + expect(bar).toHaveStyle('width: 0%'); + }); + + it('renders 0 percent for an empty range with a non-zero bound', () => { + let {getByRole} = render(); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toHaveAttribute('aria-valuenow', '5'); + expect(progressbar).toHaveAttribute('aria-valuemin', '5'); + expect(progressbar).toHaveAttribute('aria-valuemax', '5'); + expect(progressbar).toHaveAttribute('aria-valuetext', '0%'); + + let percentage = progressbar.querySelector('.percentage'); + expect(percentage).toHaveTextContent('0'); + + let bar = progressbar.querySelector('.bar'); + expect(bar).toHaveStyle('width: 0%'); + }); + it('supports indeterminate state', () => { + let renderedPercentage; let {getByRole} = render( `progressbar ${isIndeterminate ? 'indeterminate' : ''}`}> - {({percentage, valueText}) => ( - <> - -
- - )} + {({percentage}) => { + renderedPercentage = percentage; + return ( + <> + +
+ + ); + }} ); let progressbar = getByRole('progressbar'); expect(progressbar).toHaveAttribute('class', 'progressbar indeterminate'); expect(progressbar).not.toHaveAttribute('aria-valuenow'); + expect(renderedPercentage).toBeUndefined(); let bar = progressbar.querySelector('.bar'); expect(bar.style.width).toBe(''); diff --git a/packages/react-aria/src/progress/useProgressBar.ts b/packages/react-aria/src/progress/useProgressBar.ts index 5b27625af9e..2ad3899b0f4 100644 --- a/packages/react-aria/src/progress/useProgressBar.ts +++ b/packages/react-aria/src/progress/useProgressBar.ts @@ -94,7 +94,8 @@ export function useProgressBar(props: AriaProgressBarProps): ProgressBarAria { }); value = clamp(value, minValue, maxValue); - let percentage = (value - minValue) / (maxValue - minValue); + let range = maxValue - minValue; + let percentage = range === 0 ? 0 : (value - minValue) / range; let formatter = useNumberFormatter(formatOptions); if (!isIndeterminate && !valueLabel) {