diff --git a/packages/@react-aria/tag/src/useTagGroup.ts b/packages/@react-aria/tag/src/useTagGroup.ts index 11b32c9508a..d8578594b7e 100644 --- a/packages/@react-aria/tag/src/useTagGroup.ts +++ b/packages/@react-aria/tag/src/useTagGroup.ts @@ -31,8 +31,11 @@ export interface TagGroupAria { errorMessageProps: DOMAttributes } -export interface AriaTagGroupProps extends CollectionBase, MultipleSelection, Pick, 'escapeKeyBehavior'>, DOMProps, LabelableProps, AriaLabelingProps, Omit { - /** How multiple selection should behave in the collection. */ +export interface AriaTagGroupProps extends CollectionBase, MultipleSelection, Pick, 'escapeKeyBehavior' | 'onAction'>, DOMProps, LabelableProps, AriaLabelingProps, Omit { + /** + * How multiple selection should behave in the collection. + * @default 'toggle' + */ selectionBehavior?: SelectionBehavior, /** Whether selection should occur on press up instead of press down. */ shouldSelectOnPressUp?: boolean, diff --git a/packages/@react-stately/numberfield/src/useNumberFieldState.ts b/packages/@react-stately/numberfield/src/useNumberFieldState.ts index 5bc374a4313..ad71558b3c9 100644 --- a/packages/@react-stately/numberfield/src/useNumberFieldState.ts +++ b/packages/@react-stately/numberfield/src/useNumberFieldState.ts @@ -175,11 +175,14 @@ export function useNumberFieldState( } clampedValue = numberParser.parse(format(clampedValue)); + let shouldValidate = clampedValue !== numberValue; setNumberValue(clampedValue); // in a controlled state, the numberValue won't change, so we won't go back to our old input without help setInputValue(format(value === undefined ? clampedValue : numberValue)); - validation.commitValidation(); + if (shouldValidate) { + validation.commitValidation(); + } }; let safeNextStep = (operation: '+' | '-', minMax: number = 0) => { diff --git a/packages/@react-stately/selection/src/useMultipleSelectionState.ts b/packages/@react-stately/selection/src/useMultipleSelectionState.ts index e58d0823850..f7893f47764 100644 --- a/packages/@react-stately/selection/src/useMultipleSelectionState.ts +++ b/packages/@react-stately/selection/src/useMultipleSelectionState.ts @@ -31,11 +31,17 @@ function equalSets(setA, setB) { } export interface MultipleSelectionStateProps extends MultipleSelection { - /** How multiple selection should behave in the collection. */ + /** + * How multiple selection should behave in the collection. + * @default 'toggle' + */ selectionBehavior?: SelectionBehavior, /** Whether onSelectionChange should fire even if the new set of keys is the same as the last. */ allowDuplicateSelectionEvents?: boolean, - /** Whether `disabledKeys` applies to all interactions, or only selection. */ + /** + * Whether `disabledKeys` applies to all interactions, or only selection. + * @default 'all' + */ disabledBehavior?: DisabledBehavior } diff --git a/packages/dev/s2-docs/src/Nav.tsx b/packages/dev/s2-docs/src/Nav.tsx index eaa6002bbaf..59318a15c4d 100644 --- a/packages/dev/s2-docs/src/Nav.tsx +++ b/packages/dev/s2-docs/src/Nav.tsx @@ -188,7 +188,7 @@ export function Nav() { ); } return ( - + {name}
{nav}
@@ -255,10 +255,19 @@ export function SideNavItem(props) { } export function SideNavLink(props) { - let linkRef = useRef(null); + let linkRef = useRef(null); let selected = useContext(SideNavContext); let {isExternal, ...linkProps} = props; - + + useEffect(() => { + let link = linkRef.current; + if (!link || !props.isSelected) { + return; + } + + link.scrollIntoView({block: 'start', behavior: 'smooth'}); + }, [props.isSelected]); + return ( {(renderProps) => (<> { it('should support dropping into an empty ListBox with a ListBoxLoadMoreItem', () => { let onRootDrop = jest.fn(); let onLoadMore = jest.fn(); - + let EmptyListBoxWithLoader = (props) => { let {dragAndDropHooks} = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), @@ -1210,7 +1210,7 @@ describe('ListBox', () => { {(item) => {item.name}} - + ); @@ -1235,7 +1235,7 @@ describe('ListBox', () => { let listboxes = getAllByRole('listbox'); let options = getAllByRole('option'); - + // Start dragging from first listbox let dataTransfer = new DataTransfer(); fireEvent(options[0], new DragEvent('dragstart', {dataTransfer, clientX: 5, clientY: 5})); @@ -1244,7 +1244,7 @@ describe('ListBox', () => { // Drag over the empty listbox (which only has a loader) fireEvent(listboxes[1], new DragEvent('dragenter', {dataTransfer, clientX: 50, clientY: 50})); fireEvent(listboxes[1], new DragEvent('dragover', {dataTransfer, clientX: 50, clientY: 50})); - + expect(listboxes[1]).toHaveAttribute('data-drop-target', 'true'); // Drop on the empty listbox diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index ae7fb7c4130..cd3516072e1 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -13,7 +13,7 @@ jest.mock('@react-aria/live-announcer'); import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {announce} from '@react-aria/live-announcer'; -import {Button, FieldError, Group, Input, Label, NumberField, NumberFieldContext, Text} from '../'; +import {Button, FieldError, Form, Group, Input, Label, NumberField, NumberFieldContext, Text} from '../'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -250,4 +250,40 @@ describe('NumberField', () => { await user.keyboard('{Enter}'); expect(input).toHaveValue('200'); }); + + it('should not reset validation errors on blur when value has not changed', async () => { + let {getByRole} = render( +
+ + + + + + + + + +
+ ); + + let input = getByRole('textbox'); + let numberfield = input.closest('.react-aria-NumberField'); + + // Validation error should be displayed + expect(numberfield).toHaveAttribute('data-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(document.getElementById(input.getAttribute('aria-describedby').split(' ')[0])).toHaveTextContent('This field has an error.'); + + // Focus the field without changing the value + act(() => { input.focus(); }); + expect(numberfield).toHaveAttribute('data-invalid'); + + // Blur the field without changing the value + act(() => { input.blur(); }); + + // Validation error should still be displayed because the value didn't change + expect(numberfield).toHaveAttribute('data-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(document.getElementById(input.getAttribute('aria-describedby').split(' ')[0])).toHaveTextContent('This field has an error.'); + }); }); diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index 37201eb6bf2..1966cbd342f 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -572,6 +572,70 @@ describe('TagGroup', () => { expect(onRemove).toHaveBeenLastCalledWith(new Set(['dog'])); }); + it('should support onAction', async () => { + let onAction = jest.fn(); + let {getAllByRole} = renderTagGroup({onAction, selectionMode: 'none'}); + let items = getAllByRole('row'); + + await user.click(items[0]); + expect(onAction).toHaveBeenCalledTimes(1); + onAction.mockReset(); + + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('should support onAction with selectionMode = single, behaviour = replace', async () => { + let onAction = jest.fn(); + let {getAllByRole} = renderTagGroup({onAction, selectionMode: 'single', selectionBehavior: 'replace'}); + let items = getAllByRole('row'); + + await user.dblClick(items[0]); + expect(onAction).toHaveBeenCalledTimes(1); + onAction.mockReset(); + + await user.click(items[1]); + expect(onAction).not.toHaveBeenCalled(); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + + await user.dblClick(items[0]); + expect(onAction).toHaveBeenCalledTimes(1); + expect(items[0]).toHaveAttribute('aria-selected', 'false'); + expect(items[1]).toHaveAttribute('aria-selected', 'false'); + onAction.mockReset(); + + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(items[0]).toHaveAttribute('aria-selected', 'false'); + expect(items[1]).toHaveAttribute('aria-selected', 'false'); + }); + + it('should support onAction with selectionMode = multiple, behaviour = replace', async () => { + let onAction = jest.fn(); + let {getAllByRole} = renderTagGroup({onAction, selectionMode: 'multiple', selectionBehavior: 'replace'}); + let items = getAllByRole('row'); + + await user.dblClick(items[0]); + expect(onAction).toHaveBeenCalledTimes(1); + onAction.mockReset(); + + await user.click(items[1]); + expect(onAction).not.toHaveBeenCalled(); + onAction.mockReset(); + expect(items[1]).toHaveAttribute('aria-selected', 'true'); + + await user.dblClick(items[0]); + expect(onAction).toHaveBeenCalledTimes(1); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items[1]).toHaveAttribute('aria-selected', 'false'); + onAction.mockReset(); + + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(items[0]).toHaveAttribute('aria-selected', 'true'); + expect(items[1]).toHaveAttribute('aria-selected', 'false'); + }); + describe('shouldSelectOnPressUp', () => { it('should select an item on pressing down when shouldSelectOnPressUp is not provided', async () => { let onSelectionChange = jest.fn();