diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index cdd7de1988c..a85fdecb54d 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -505,6 +505,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // Scroll the focused element into view when the focusedKey changes. let lastFocusedKey = useRef(manager.focusedKey); + let raf = useRef(null); useEffect(() => { if (manager.isFocused && manager.focusedKey != null && (manager.focusedKey !== lastFocusedKey.current || didAutoFocusRef.current) && scrollRef.current && ref.current) { let modality = getInteractionModality(); @@ -516,8 +517,16 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } if (modality === 'keyboard' || didAutoFocusRef.current) { - scrollIntoView(scrollRef.current, element); + if (raf.current) { + cancelAnimationFrame(raf.current); + } + + raf.current = requestAnimationFrame(() => { + if (scrollRef.current) { + scrollIntoView(scrollRef.current, element); + } + }); // Avoid scroll in iOS VO, since it may cause overlay to close (i.e. RAC submenu) if (modality !== 'virtual') { scrollIntoViewport(element, {containingElement: ref.current}); @@ -534,6 +543,14 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions didAutoFocusRef.current = false; }); + useEffect(() => { + return () => { + if (raf.current) { + cancelAnimationFrame(raf.current); + } + }; + }, []); + // Intercept FocusScope restoration since virtualized collections can reuse DOM nodes. useEvent(ref, 'react-aria-focus-scope-restore', e => { e.preventDefault(); diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index 553c7c38f6c..4b2b42435f3 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -143,6 +143,7 @@ describe('SearchAutocomplete', function () { user = userEvent.setup({delay: null, pointerMap}); jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); window.HTMLElement.prototype.scrollIntoView = jest.fn(); simulateDesktop(); jest.useFakeTimers(); diff --git a/packages/@react-spectrum/card/test/CardView.test.js b/packages/@react-spectrum/card/test/CardView.test.js index 9ed6a8df3e9..41260e2fa54 100644 --- a/packages/@react-spectrum/card/test/CardView.test.js +++ b/packages/@react-spectrum/card/test/CardView.test.js @@ -144,6 +144,10 @@ describe('CardView', function () { jest.useFakeTimers(); }); + beforeEach(() => { + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); + }); + afterEach(() => { jest.clearAllMocks(); act(() => jest.runAllTimers()); diff --git a/packages/@react-spectrum/color/test/ColorPicker.test.js b/packages/@react-spectrum/color/test/ColorPicker.test.js index a22b63aa124..51a930761f7 100644 --- a/packages/@react-spectrum/color/test/ColorPicker.test.js +++ b/packages/@react-spectrum/color/test/ColorPicker.test.js @@ -22,9 +22,14 @@ describe('ColorPicker', function () { beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); jest.useFakeTimers(); }); + afterAll(function () { + jest.restoreAllMocks(); + }); + afterEach(() => { act(() => {jest.runAllTimers();}); }); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 4b97d4c810d..d4415598382 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -257,6 +257,7 @@ describe('ComboBox', function () { }); beforeEach(() => { + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); load = jest .fn() .mockImplementationOnce(getFilterItems) diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index f2b0e792e1e..27735a9a7b9 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -73,6 +73,7 @@ describe('ListView', function () { afterEach(function () { fireEvent.keyDown(document.activeElement, {key: 'Escape'}); fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + act(() => {jest.runAllTimers();}); jest.clearAllMocks(); }); @@ -1453,27 +1454,33 @@ describe('ListView', function () { // scroll us down far enough that item 0 isn't in the view moveFocus('ArrowDown'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 1')); grid.scrollTop = 40; fireEvent.scroll(grid); moveFocus('ArrowDown'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 2')); grid.scrollTop = 80; fireEvent.scroll(grid); moveFocus('ArrowDown'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 3')); grid.scrollTop = 120; fireEvent.scroll(grid); moveFocus('ArrowUp'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 2')); grid.scrollTop = 80; fireEvent.scroll(grid); moveFocus('ArrowUp'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 1')); grid.scrollTop = 40; fireEvent.scroll(grid); moveFocus('ArrowUp'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(grid, getRow(tree, 'Item 0')); }); @@ -1506,6 +1513,7 @@ describe('ListView', function () { }); focusRow(tree, 'Item 1'); + act(() => jest.runAllTimers()); expect(document.activeElement).toBe(getRow(tree, 'Item 1')); expect(scrollIntoView).toHaveBeenLastCalledWith(grid, document.activeElement); }); @@ -1539,6 +1547,7 @@ describe('ListView', function () { // Moving focus should scroll the new focused item into view moveFocus('ArrowDown'); + act(() => jest.runAllTimers()); expect(document.activeElement).toBe(getRow(tree, 'Item 1')); expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 36421aaf844..da7c4ad6960 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -31,7 +31,7 @@ import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; describe('Picker', function () { - let offsetWidth, offsetHeight; + let offsetWidth, offsetHeight, scrollHeight; let onSelectionChange = jest.fn(); let testUtilUser = new User(); let user; @@ -40,6 +40,7 @@ describe('Picker', function () { user = userEvent.setup({delay: null, pointerMap}); offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); simulateDesktop(); jest.useFakeTimers(); }); @@ -47,6 +48,7 @@ describe('Picker', function () { afterAll(function () { offsetWidth.mockReset(); offsetHeight.mockReset(); + scrollHeight.mockReset(); }); afterEach(() => { diff --git a/packages/@react-spectrum/picker/test/TempUtilTest.test.js b/packages/@react-spectrum/picker/test/TempUtilTest.test.js index a9fbc25eba4..0e135d7f4a5 100644 --- a/packages/@react-spectrum/picker/test/TempUtilTest.test.js +++ b/packages/@react-spectrum/picker/test/TempUtilTest.test.js @@ -27,9 +27,14 @@ describe('Picker/Select ', function () { beforeAll(function () { user = userEvent.setup({delay: null, pointerMap}); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); simulateDesktop(); }); + afterAll(function () { + jest.restoreAllMocks(); + }); + describe('with real timers', function () { beforeAll(function () { jest.useRealTimers(); diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index cdd7f71d35d..7598a0d163b 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -23,35 +23,33 @@ import { ListBoxItem, ListBoxItemProps, ListBoxProps, + ListLayout, Provider, - SectionProps + SectionProps, + Virtualizer } from 'react-aria-components'; -import {baseColor, style} from '../style' with {type: 'macro'}; +import {baseColor, edgeToText, focusRing, space, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; +import {centerPadding, control, controlBorderRadius, controlFont, controlSize, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import { checkmark, description, - Divider, icon, iconCenterWrapper, label, - menuitem, - section, - sectionHeader, sectionHeading } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; -import {controlBorderRadius, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {createContext, CSSProperties, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; +import {createContext, CSSProperties, ForwardedRef, forwardRef, ReactNode, Ref, useCallback, useContext, useImperativeHandle, useRef, useState} from 'react'; import {createFocusableRef} from '@react-spectrum/utils'; +import {createLeafComponent} from '@react-aria/collections'; import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; import {HelpTextProps, SpectrumLabelableProps} from '@react-types/shared'; import {IconContext} from './Icon'; -import {menu} from './Picker'; import {mergeRefs, useResizeObserver} from '@react-aria/utils'; import {Placement} from 'react-aria'; import {PopoverBase} from './Popover'; @@ -59,7 +57,6 @@ import {pressScale} from './pressScale'; import {TextFieldRef} from '@react-types/textfield'; import {useSpectrumContextProps} from './useSpectrumContextProps'; - export interface ComboboxStyleProps { /** * The size of the Combobox. @@ -146,6 +143,111 @@ const iconStyles = style({ } }); +export let listbox = style<{size: 'S' | 'M' | 'L' | 'XL'}>({ + width: 'full', + boxSizing: 'border-box', + maxHeight: '[inherit]', + // TODO: Might help with horizontal scrolling happening on Windows, will need to check somehow. Otherwise, revert back to overflow: auto + overflowY: 'auto', + overflowX: 'hidden', + fontFamily: 'sans', + fontSize: controlFont() +}); + +export let listboxItem = style({ + ...focusRing(), + ...control({shape: 'default', wrap: true, icon: true}), + columnGap: 0, + paddingX: 0, + paddingBottom: '--labelPadding', + backgroundColor: { + default: 'transparent', + isFocused: baseColor('gray-100').isFocusVisible + }, + color: { + default: baseColor('neutral'), + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + position: 'relative', + gridColumnStart: 1, + gridColumnEnd: -1, + display: 'grid', + gridTemplateAreas: [ + '. checkmark icon label .', + '. . . description .' + ], + gridTemplateColumns: { + size: { + S: [edgeToText(24), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(24)], + M: [edgeToText(32), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(32)], + L: [edgeToText(40), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(40)], + XL: [edgeToText(48), 'auto', 'auto', 'minmax(0, 1fr)', edgeToText(48)] + } + }, + gridTemplateRows: { + // min-content prevents second row from 'auto'ing to a size larger then 0 when empty + default: 'auto minmax(0, min-content)', + ':has([slot=description])': 'auto auto' + }, + rowGap: { + ':has([slot=description])': space(1) + }, + alignItems: 'baseline', + minHeight: controlSize(), + height: 'min', + textDecoration: 'none', + cursor: { + default: 'default', + isLink: 'pointer' + }, + transition: 'default' +}, getAllowedOverrides()); + +export let listboxHeader = style<{size?: 'S' | 'M' | 'L' | 'XL'}>({ + color: 'neutral', + boxSizing: 'border-box', + minHeight: controlSize(), + paddingY: centerPadding(), + marginX: { + size: { + S: `[${edgeToText(24)}]`, + M: `[${edgeToText(32)}]`, + L: `[${edgeToText(40)}]`, + XL: `[${edgeToText(48)}]` + } + } +}); + +const separatorWrapper = style({ + display: { + ':is(:last-child > *)': 'none', + default: 'flex' + }, + marginX: { + size: { + S: `[${edgeToText(24)}]`, + M: `[${edgeToText(32)}]`, + L: `[${edgeToText(40)}]`, + XL: `[${edgeToText(48)}]` + } + }, + height: 12, + alignItems: 'center' +}); + +const dividerStyle = style({ + backgroundColor: { + default: 'gray-200', + forcedColors: 'ButtonBorder' + }, + borderRadius: 'full', + height: '[2px]', + width: 'full' +}); + let InternalComboboxContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); /** @@ -303,7 +405,7 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co })}> - - {children} - + + + {children} + + @@ -325,7 +435,6 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co ); }); - export interface ComboBoxItemProps extends Omit, StyleProps { children: ReactNode } @@ -347,7 +456,7 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode { ref={ref} textValue={props.textValue || (typeof props.children === 'string' ? props.children as string : undefined)} style={pressScale(ref, props.UNSAFE_style)} - className={renderProps => (props.UNSAFE_className || '') + menuitem({...renderProps, size, isLink}, props.styles)}> + className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> {(renderProps) => { let {children} = props; return ( @@ -382,11 +491,18 @@ export function ComboBoxSection(props: ComboBoxSectionProps return ( <> + {...props}> {props.children} - + ); } + +export const Divider = /*#__PURE__*/ createLeafComponent('separator', function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef) { + return ( +
+
+
+ ); +}); diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index e3e63d7a9d6..3d3033db253 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -23,33 +23,37 @@ import { ListBoxItem, ListBoxItemProps, ListBoxProps, + ListLayout, Provider, SectionProps, - SelectValue + SelectValue, + Virtualizer } from 'react-aria-components'; import {baseColor, edgeToText, focusRing, style} from '../style' with {type: 'macro'}; import {centerBaseline} from './CenterBaseline'; import { checkmark, description, - Divider, icon, iconCenterWrapper, label, - menuitem, - section, - sectionHeader, sectionHeading } from './Menu'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronIcon from '../ui-icons/Chevron'; import {control, controlBorderRadius, controlFont, field, fieldInput, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import { + Divider, + listbox, + listboxHeader, + listboxItem +} from './ComboBox'; import { FieldErrorIcon, FieldLabel, HelpText } from './Field'; -import {FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, SpectrumLabelableProps} from '@react-types/shared'; +import {FocusableRef, FocusableRefValue, HelpTextProps, PressEvent, RefObject, SpectrumLabelableProps} from '@react-types/shared'; import {FormContext, useFormProps} from './Form'; import {forwardRefType} from './types'; import {HeaderContext, HeadingContext, Text, TextContext} from './Content'; @@ -266,21 +270,6 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick menuOffset = 8; } - // For mouse interactions, pickers open on press start. When the popover underlay appears - // it covers the trigger button, causing onPressEnd to fire immediately and no press scaling - // to occur. We override this by listening for pointerup on the document ourselves. - let [isPressed, setPressed] = useState(false); - let {addGlobalListener} = useGlobalListeners(); - let onPressStart = (e: PressEvent) => { - if (e.pointerType !== 'mouse') { - return; - } - setPressed(true); - addGlobalListener(document, 'pointerup', () => { - setPressed(false); - }, {once: true, capture: true}); - }; - return ( {label} - - - + {errorMessage} - - - - {children} - - - + + + + + {children} + + + + )} @@ -416,6 +361,100 @@ export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pick ); }); +interface PickerButtonInnerProps extends PickerStyleProps, Omit { + buttonRef: RefObject +} + +function PickerButton(props: PickerButtonInnerProps) { + let { + isOpen, + isQuiet, + isFocusVisible, + size, + isInvalid, + isDisabled, + buttonRef + } = props; + + // For mouse interactions, pickers open on press start. When the popover underlay appears + // it covers the trigger button, causing onPressEnd to fire immediately and no press scaling + // to occur. We override this by listening for pointerup on the document ourselves. + let [isPressed, setPressed] = useState(false); + let {addGlobalListener} = useGlobalListeners(); + let onPressStart = (e: PressEvent) => { + if (e.pointerType !== 'mouse') { + return; + } + setPressed(true); + addGlobalListener(document, 'pointerup', () => { + setPressed(false); + }, {once: true, capture: true}); + }; + + return ( + + + + ); +} + export interface PickerItemProps extends Omit, StyleProps { children: ReactNode } @@ -437,7 +476,7 @@ export function PickerItem(props: PickerItemProps): ReactNode { ref={ref} textValue={props.textValue || (typeof props.children === 'string' ? props.children as string : undefined)} style={pressScale(ref, props.UNSAFE_style)} - className={renderProps => (props.UNSAFE_className || '') + menuitem({...renderProps, size, isLink}, props.styles)}> + className={renderProps => (props.UNSAFE_className || '') + listboxItem({...renderProps, size, isLink}, props.styles)}> {(renderProps) => { let {children} = props; return ( @@ -480,11 +519,10 @@ export function PickerSection(props: PickerSectionProps): R return ( <> + {...props}> {props.children} - + ); } diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 16128662bed..eeaf4ef075f 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -194,6 +194,9 @@ export class S2TableLayout extends TableLayout { protected buildCollection(): LayoutNode[] { let [header, body] = super.buildCollection(); + if (!header) { + return []; + } let {children, layoutInfo} = body; // TableLayout's buildCollection always sets the body width to the max width between the header width, but // we want the body to be sticky and only as wide as the table so it is always in view if loading/empty diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 802b7971840..e1f931163fe 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -99,6 +99,28 @@ export const Dynamic: Story = { }; +function VirtualizedCombobox(props) { + let items: IExampleItem[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i.toString(), label: `Item ${i}`}); + } + + return ( + + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; + export const WithIcons: Story = { render: (args) => ( diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index 0520c236b9e..8b514fc9b6e 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -131,6 +131,28 @@ export const WithIcons: Story = { } }; +function VirtualizedPicker(props) { + let items: IExampleItem[] = []; + for (let i = 0; i < 10000; i++) { + items.push({id: i.toString(), label: `Item ${i}`}); + } + + return ( + + {(item) => {(item as IExampleItem).label}} + + ); +} + +export const ManyItems: Story = { + render: (args) => ( + + ), + args: { + label: 'Many items' + } +}; + const ValidationRender = (props) => (
diff --git a/packages/@react-spectrum/table/test/TableTests.js b/packages/@react-spectrum/table/test/TableTests.js index d9f2065132d..c2f21cc6fd5 100644 --- a/packages/@react-spectrum/table/test/TableTests.js +++ b/packages/@react-spectrum/table/test/TableTests.js @@ -1962,6 +1962,7 @@ export let tableTests = () => { let body = tree.getByRole('grid').childNodes[1]; focusCell(tree, 'Baz 25'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); @@ -2009,14 +2010,17 @@ export let tableTests = () => { let cell = getCell(tree, 'Baz 5'); focusCell(tree, 'Baz 5'); + act(() => jest.runAllTimers()); body.scrollTop = 1000; fireEvent.scroll(body); + act(() => jest.runAllTimers()); expect(body.scrollTop).toBe(1000); expect(document.activeElement).toBe(cell); focusCell(tree, 'Bar'); + act(() => jest.runAllTimers()); expect(document.activeElement).toHaveAttribute('role', 'columnheader'); expect(document.activeElement).toHaveTextContent('Bar'); expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); diff --git a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx index acd352fe896..1788b3f1ede 100644 --- a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx +++ b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx @@ -815,6 +815,7 @@ describe('TableView with expandable rows', function () { let body = (treegrid.getByRole('treegrid').childNodes[1] as HTMLElement); focusCell(treegrid, 'Row 4, Lvl 1, Foo'); + act(() => jest.runAllTimers()); expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); @@ -856,6 +857,7 @@ describe('TableView with expandable rows', function () { // Moving focus should scroll the new focused item into view moveFocus('ArrowRight'); + act(() => jest.runAllTimers()); expect(document.activeElement).toBe(getCell(treegrid, 'Row 1, Lvl 3, Bar')); expect(scrollIntoView).toHaveBeenLastCalledWith(body, document.activeElement); }); diff --git a/packages/@react-spectrum/tabs/test/Tabs.test.js b/packages/@react-spectrum/tabs/test/Tabs.test.js index 3178a266774..802c7701e79 100644 --- a/packages/@react-spectrum/tabs/test/Tabs.test.js +++ b/packages/@react-spectrum/tabs/test/Tabs.test.js @@ -60,6 +60,7 @@ describe('Tabs', function () { beforeEach(() => { jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000); jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000); + jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50); window.HTMLElement.prototype.scrollIntoView = jest.fn(); }); diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index 14c8e0865b4..11c9e3d72c8 100644 --- a/packages/@react-stately/layout/src/GridLayout.ts +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -227,7 +227,7 @@ export class GridLayout exte // Find the closest item within on either side of the point using the gap width. let key: Key | null = null; if (this.numColumns === 1) { - let searchRect = new Rect(x, Math.max(0, y - this.gap.height), 1, this.gap.height * 2); + let searchRect = new Rect(x, Math.max(0, y - this.gap.height), 1, Math.max(1, this.gap.height * 2)); let candidates = this.getVisibleLayoutInfos(searchRect); let minDistance = Infinity; for (let candidate of candidates) { diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 999bd12b82e..dbdbe65b112 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -301,6 +301,7 @@ export class ListLayout exte let layoutNode = this.buildNode(node, x, y); layoutNode.layoutInfo.parentKey = parentKey ?? null; + layoutNode.layoutInfo.allowOverflow = true; this.layoutNodes.set(node.key, layoutNode); return layoutNode; } @@ -315,6 +316,8 @@ export class ListLayout exte return this.buildSectionHeader(node, x, y); case 'loader': return this.buildLoader(node, x, y); + case 'separator': + return this.buildItem(node, x, y); default: throw new Error('Unsupported node type: ' + node.type); } @@ -506,7 +509,7 @@ export class ListLayout exte y += this.virtualizer!.visibleRect.y; // Find the closest item within on either side of the point using the gap width. - let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2); + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2)); let candidates = this.getVisibleLayoutInfos(searchRect); let key: Key | null = null; let minDistance = Infinity; diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index 4c820b5b1e9..68a92183a68 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -85,6 +85,10 @@ export class TableLayout exten this.stickyColumnIndices = []; let collection = this.virtualizer!.collection as TableCollection; + if (collection.head?.key === -1) { + return []; + } + for (let column of collection.columns) { // The selection cell and any other sticky columns always need to be visible. // In addition, row headers need to be in the DOM for accessibility labeling. @@ -543,7 +547,7 @@ export class TableLayout exten y += this.virtualizer!.visibleRect.y; // Find the closest item within on either side of the point using the gap width. - let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2); + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, Math.max(1, this.gap * 2)); let candidates = this.getVisibleLayoutInfos(searchRect); let key: Key | null = null; let minDistance = Infinity; diff --git a/packages/@react-stately/virtualizer/src/Rect.ts b/packages/@react-stately/virtualizer/src/Rect.ts index 58a74d0903d..65b55c194fb 100644 --- a/packages/@react-stately/virtualizer/src/Rect.ts +++ b/packages/@react-stately/virtualizer/src/Rect.ts @@ -92,7 +92,9 @@ export class Rect { * @param rect - The rectangle to check. */ intersects(rect: Rect): boolean { - return this.x <= rect.x + rect.width + return this.area > 0 + && rect.area > 0 + && this.x <= rect.x + rect.width && rect.x <= this.x + this.width && this.y <= rect.y + rect.height && rect.y <= this.y + this.height; diff --git a/packages/@react-stately/virtualizer/src/Virtualizer.ts b/packages/@react-stately/virtualizer/src/Virtualizer.ts index c627658a0bd..09e660dd5a7 100644 --- a/packages/@react-stately/virtualizer/src/Virtualizer.ts +++ b/packages/@react-stately/virtualizer/src/Virtualizer.ts @@ -193,8 +193,7 @@ export class Virtualizer { } else { rect = this._overscanManager.getOverscannedRect(); } - - let layoutInfos = rect.area === 0 ? [] : this.layout.getVisibleLayoutInfos(rect); + let layoutInfos = this.layout.getVisibleLayoutInfos(rect); let map = new Map; for (let layoutInfo of layoutInfos) { map.set(layoutInfo.key, layoutInfo); diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index d7073b645a7..e3e71e81082 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -102,10 +102,6 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat onScrollEnd: state.endScrolling }, scrollRef!); - if (state.contentSize.area === 0) { - return null; - } - return (
diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index 69059c594bc..9fb6ca08ce5 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -537,7 +537,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' expect(input).not.toHaveAttribute('aria-activedescendant'); // Only sets aria-activedescendant after the collection updates and the delay passes - act(() => jest.advanceTimersToNextTimer()); + act(() => jest.runAllTimers()); expect(input).toHaveAttribute('aria-activedescendant', options[0].id); }); });