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/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/@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/@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-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
+
+
+ );
+
+ 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
+
+
+ );
+
+ 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
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/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/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/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);
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(
+
+
+
+
+
+
+ );
+
+ 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/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-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/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/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) {
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/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