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 + + {({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/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( + + + + + {({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/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(); /** * 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/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; diff --git a/packages/react-aria/test/dnd/dnd.test.js b/packages/react-aria/test/dnd/dnd.test.js index 848278cc8a3..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( <> @@ -2882,6 +2931,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 +2994,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, 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); + }); + }); +}); 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', () => { 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); } } }