10
10
* governing permissions and limitations under the License.
11
11
*/
12
12
13
- import { act , fireEvent , installPointerEvent , mockClickDefault , pointerMap , render , within } from '@react-spectrum/test-utils-internal' ;
13
+ import { act , fireEvent , installPointerEvent , mockClickDefault , pointerMap , render , setupIntersectionObserverMock , within } from '@react-spectrum/test-utils-internal' ;
14
14
import {
15
- Button , Dialog ,
15
+ Button ,
16
+ Collection ,
17
+ Dialog ,
16
18
DialogTrigger ,
17
19
DropIndicator ,
18
20
Header , Heading ,
@@ -27,6 +29,7 @@ import {
27
29
Virtualizer
28
30
} from '../' ;
29
31
import React , { useState } from 'react' ;
32
+ import { UNSTABLE_ListBoxLoadingSentinel } from '../src/ListBox' ;
30
33
import { User } from '@react-aria/test-utils' ;
31
34
import userEvent from '@testing-library/user-event' ;
32
35
@@ -899,28 +902,28 @@ describe('ListBox', () => {
899
902
fireEvent . keyUp ( option , { key : 'Enter' } ) ;
900
903
act ( ( ) => jest . runAllTimers ( ) ) ;
901
904
902
- let rows = getAllByRole ( 'option' ) ;
903
- expect ( rows ) . toHaveLength ( 4 ) ;
904
- expect ( rows [ 0 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
905
- expect ( rows [ 0 ] ) . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
906
- expect ( rows [ 0 ] ) . toHaveAttribute ( 'aria-label' , 'Insert before Cat' ) ;
907
- expect ( rows [ 0 ] ) . toHaveTextContent ( 'Test' ) ;
908
- expect ( rows [ 1 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
909
- expect ( rows [ 1 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
910
- expect ( rows [ 1 ] ) . toHaveAttribute ( 'aria-label' , 'Insert between Cat and Dog' ) ;
911
- expect ( rows [ 2 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
912
- expect ( rows [ 2 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
913
- expect ( rows [ 2 ] ) . toHaveAttribute ( 'aria-label' , 'Insert between Dog and Kangaroo' ) ;
914
- expect ( rows [ 3 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
915
- expect ( rows [ 3 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
916
- expect ( rows [ 3 ] ) . toHaveAttribute ( 'aria-label' , 'Insert after Kangaroo' ) ;
905
+ let options = getAllByRole ( 'option' ) ;
906
+ expect ( options ) . toHaveLength ( 4 ) ;
907
+ expect ( options [ 0 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
908
+ expect ( options [ 0 ] ) . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
909
+ expect ( options [ 0 ] ) . toHaveAttribute ( 'aria-label' , 'Insert before Cat' ) ;
910
+ expect ( options [ 0 ] ) . toHaveTextContent ( 'Test' ) ;
911
+ expect ( options [ 1 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
912
+ expect ( options [ 1 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
913
+ expect ( options [ 1 ] ) . toHaveAttribute ( 'aria-label' , 'Insert between Cat and Dog' ) ;
914
+ expect ( options [ 2 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
915
+ expect ( options [ 2 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
916
+ expect ( options [ 2 ] ) . toHaveAttribute ( 'aria-label' , 'Insert between Dog and Kangaroo' ) ;
917
+ expect ( options [ 3 ] ) . toHaveAttribute ( 'class' , 'react-aria-DropIndicator' ) ;
918
+ expect ( options [ 3 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
919
+ expect ( options [ 3 ] ) . toHaveAttribute ( 'aria-label' , 'Insert after Kangaroo' ) ;
917
920
918
921
fireEvent . keyDown ( document . activeElement , { key : 'ArrowDown' } ) ;
919
922
fireEvent . keyUp ( document . activeElement , { key : 'ArrowDown' } ) ;
920
923
921
924
expect ( document . activeElement ) . toHaveAttribute ( 'aria-label' , 'Insert between Cat and Dog' ) ;
922
- expect ( rows [ 0 ] ) . not . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
923
- expect ( rows [ 1 ] ) . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
925
+ expect ( options [ 0 ] ) . not . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
926
+ expect ( options [ 1 ] ) . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
924
927
925
928
fireEvent . keyDown ( document . activeElement , { key : 'Enter' } ) ;
926
929
fireEvent . keyUp ( document . activeElement , { key : 'Enter' } ) ;
@@ -929,7 +932,7 @@ describe('ListBox', () => {
929
932
expect ( onReorder ) . toHaveBeenCalledTimes ( 1 ) ;
930
933
} ) ;
931
934
932
- it ( 'should support dropping on rows ' , ( ) => {
935
+ it ( 'should support dropping on options ' , ( ) => {
933
936
let onItemDrop = jest . fn ( ) ;
934
937
let { getAllByRole} = render ( < >
935
938
< DraggableListBox />
@@ -942,13 +945,13 @@ describe('ListBox', () => {
942
945
act ( ( ) => jest . runAllTimers ( ) ) ;
943
946
944
947
let listboxes = getAllByRole ( 'listbox' ) ;
945
- let rows = within ( listboxes [ 1 ] ) . getAllByRole ( 'option' ) ;
946
- expect ( rows ) . toHaveLength ( 3 ) ;
947
- expect ( rows [ 0 ] ) . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
948
- expect ( rows [ 1 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
949
- expect ( rows [ 2 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
948
+ let options = within ( listboxes [ 1 ] ) . getAllByRole ( 'option' ) ;
949
+ expect ( options ) . toHaveLength ( 3 ) ;
950
+ expect ( options [ 0 ] ) . toHaveAttribute ( 'data-drop-target' , 'true' ) ;
951
+ expect ( options [ 1 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
952
+ expect ( options [ 2 ] ) . not . toHaveAttribute ( 'data-drop-target' ) ;
950
953
951
- expect ( document . activeElement ) . toBe ( rows [ 0 ] ) ;
954
+ expect ( document . activeElement ) . toBe ( options [ 0 ] ) ;
952
955
953
956
fireEvent . keyDown ( document . activeElement , { key : 'Enter' } ) ;
954
957
fireEvent . keyUp ( document . activeElement , { key : 'Enter' } ) ;
@@ -1297,9 +1300,9 @@ describe('ListBox', () => {
1297
1300
let { getAllByRole} = renderListbox ( { selectionMode : 'single' , onSelectionChange} ) ;
1298
1301
let items = getAllByRole ( 'option' ) ;
1299
1302
1300
- await user . pointer ( { target : items [ 0 ] , keys : '[MouseLeft>]' } ) ;
1303
+ await user . pointer ( { target : items [ 0 ] , keys : '[MouseLeft>]' } ) ;
1301
1304
expect ( onSelectionChange ) . toBeCalledTimes ( 1 ) ;
1302
-
1305
+
1303
1306
await user . pointer ( { target : items [ 0 ] , keys : '[/MouseLeft]' } ) ;
1304
1307
expect ( onSelectionChange ) . toBeCalledTimes ( 1 ) ;
1305
1308
} ) ;
@@ -1309,9 +1312,9 @@ describe('ListBox', () => {
1309
1312
let { getAllByRole} = renderListbox ( { selectionMode : 'single' , onSelectionChange, shouldSelectOnPressUp : false } ) ;
1310
1313
let items = getAllByRole ( 'option' ) ;
1311
1314
1312
- await user . pointer ( { target : items [ 0 ] , keys : '[MouseLeft>]' } ) ;
1315
+ await user . pointer ( { target : items [ 0 ] , keys : '[MouseLeft>]' } ) ;
1313
1316
expect ( onSelectionChange ) . toBeCalledTimes ( 1 ) ;
1314
-
1317
+
1315
1318
await user . pointer ( { target : items [ 0 ] , keys : '[/MouseLeft]' } ) ;
1316
1319
expect ( onSelectionChange ) . toBeCalledTimes ( 1 ) ;
1317
1320
} ) ;
@@ -1321,11 +1324,223 @@ describe('ListBox', () => {
1321
1324
let { getAllByRole} = renderListbox ( { selectionMode : 'single' , onSelectionChange, shouldSelectOnPressUp : true } ) ;
1322
1325
let items = getAllByRole ( 'option' ) ;
1323
1326
1324
- await user . pointer ( { target : items [ 0 ] , keys : '[MouseLeft>]' } ) ;
1327
+ await user . pointer ( { target : items [ 0 ] , keys : '[MouseLeft>]' } ) ;
1325
1328
expect ( onSelectionChange ) . toBeCalledTimes ( 0 ) ;
1326
-
1329
+
1327
1330
await user . pointer ( { target : items [ 0 ] , keys : '[/MouseLeft]' } ) ;
1328
1331
expect ( onSelectionChange ) . toBeCalledTimes ( 1 ) ;
1329
1332
} ) ;
1330
1333
} ) ;
1334
+
1335
+ describe ( 'async loading' , ( ) => {
1336
+ let items = [
1337
+ { name : 'Foo' } ,
1338
+ { name : 'Bar' } ,
1339
+ { name : 'Baz' }
1340
+ ] ;
1341
+ let renderEmptyState = ( ) => {
1342
+ return (
1343
+ < div > empty state</ div >
1344
+ ) ;
1345
+ } ;
1346
+ let AsyncListbox = ( props ) => {
1347
+ let { items, isLoading, onLoadMore, ...listBoxProps } = props ;
1348
+ return (
1349
+ < ListBox
1350
+ { ...listBoxProps }
1351
+ aria-label = "async listbox"
1352
+ renderEmptyState = { ( ) => renderEmptyState ( ) } >
1353
+ < Collection items = { items } >
1354
+ { ( item ) => (
1355
+ < ListBoxItem id = { item . name } > { item . name } </ ListBoxItem >
1356
+ ) }
1357
+ </ Collection >
1358
+ < UNSTABLE_ListBoxLoadingSentinel isLoading = { isLoading } onLoadMore = { onLoadMore } >
1359
+ Loading...
1360
+ </ UNSTABLE_ListBoxLoadingSentinel >
1361
+ </ ListBox >
1362
+ ) ;
1363
+ } ;
1364
+
1365
+ let onLoadMore = jest . fn ( ) ;
1366
+ let observe = jest . fn ( ) ;
1367
+ afterEach ( ( ) => {
1368
+ jest . clearAllMocks ( ) ;
1369
+ } ) ;
1370
+
1371
+ it ( 'should render the loading element when loading' , async ( ) => {
1372
+ let tree = render ( < AsyncListbox isLoading items = { items } /> ) ;
1373
+
1374
+ let listboxTester = testUtilUser . createTester ( 'ListBox' , { root : tree . getByRole ( 'listbox' ) } ) ;
1375
+ let options = listboxTester . options ( ) ;
1376
+ expect ( options ) . toHaveLength ( 4 ) ;
1377
+ let loaderRow = options [ 3 ] ;
1378
+ expect ( loaderRow ) . toHaveTextContent ( 'Loading...' ) ;
1379
+
1380
+ let sentinel = tree . getByTestId ( 'loadMoreSentinel' ) ;
1381
+ expect ( sentinel . parentElement ) . toHaveAttribute ( 'inert' ) ;
1382
+ } ) ;
1383
+
1384
+ it ( 'should render the sentinel but not the loading indicator when not loading' , async ( ) => {
1385
+ let tree = render ( < AsyncListbox items = { items } /> ) ;
1386
+
1387
+ let listboxTester = testUtilUser . createTester ( 'ListBox' , { root : tree . getByRole ( 'listbox' ) } ) ;
1388
+ let options = listboxTester . options ( ) ;
1389
+ expect ( options ) . toHaveLength ( 3 ) ;
1390
+ expect ( tree . queryByText ( 'Loading...' ) ) . toBeFalsy ( ) ;
1391
+ expect ( tree . getByTestId ( 'loadMoreSentinel' ) ) . toBeInTheDocument ( ) ;
1392
+ } ) ;
1393
+
1394
+ it ( 'should properly render the renderEmptyState if listbox is empty' , async ( ) => {
1395
+ let tree = render ( < AsyncListbox items = { [ ] } /> ) ;
1396
+
1397
+ let listboxTester = testUtilUser . createTester ( 'ListBox' , { root : tree . getByRole ( 'listbox' ) } ) ;
1398
+ let options = listboxTester . options ( ) ;
1399
+ expect ( options ) . toHaveLength ( 1 ) ;
1400
+ expect ( options [ 0 ] ) . toHaveTextContent ( 'empty state' ) ;
1401
+ expect ( tree . queryByText ( 'Loading...' ) ) . toBeFalsy ( ) ;
1402
+ expect ( tree . getByTestId ( 'loadMoreSentinel' ) ) . toBeInTheDocument ( ) ;
1403
+
1404
+ // Even if the listbox is empty, providing isLoading will render the loader
1405
+ tree . rerender ( < AsyncListbox items = { [ ] } isLoading /> ) ;
1406
+ options = listboxTester . options ( ) ;
1407
+ expect ( options ) . toHaveLength ( 2 ) ;
1408
+ expect ( options [ 1 ] ) . toHaveTextContent ( 'empty state' ) ;
1409
+ expect ( tree . queryByText ( 'Loading...' ) ) . toBeTruthy ( ) ;
1410
+ expect ( tree . getByTestId ( 'loadMoreSentinel' ) ) . toBeInTheDocument ( ) ;
1411
+ } ) ;
1412
+
1413
+ it ( 'should only fire loadMore when intersection is detected regardless of loading state' , async ( ) => {
1414
+ let observer = setupIntersectionObserverMock ( {
1415
+ observe
1416
+ } ) ;
1417
+
1418
+ let tree = render ( < AsyncListbox items = { items } onLoadMore = { onLoadMore } isLoading /> ) ;
1419
+ let sentinel = tree . getByTestId ( 'loadMoreSentinel' ) ;
1420
+ expect ( observe ) . toHaveBeenCalledTimes ( 2 ) ;
1421
+ expect ( observe ) . toHaveBeenLastCalledWith ( sentinel ) ;
1422
+ expect ( onLoadMore ) . toHaveBeenCalledTimes ( 0 ) ;
1423
+
1424
+ act ( ( ) => { observer . instance . triggerCallback ( [ { isIntersecting : true } ] ) ; } ) ;
1425
+ expect ( onLoadMore ) . toHaveBeenCalledTimes ( 1 ) ;
1426
+
1427
+ tree . rerender ( < AsyncListbox items = { items } onLoadMore = { onLoadMore } /> ) ;
1428
+ expect ( observe ) . toHaveBeenCalledTimes ( 3 ) ;
1429
+ expect ( observe ) . toHaveBeenLastCalledWith ( sentinel ) ;
1430
+ expect ( onLoadMore ) . toHaveBeenCalledTimes ( 1 ) ;
1431
+
1432
+ act ( ( ) => { observer . instance . triggerCallback ( [ { isIntersecting : true } ] ) ; } ) ;
1433
+ expect ( onLoadMore ) . toHaveBeenCalledTimes ( 2 ) ;
1434
+ } ) ;
1435
+
1436
+ describe ( 'virtualized' , ( ) => {
1437
+ let items = [ ] ;
1438
+ for ( let i = 0 ; i < 50 ; i ++ ) {
1439
+ items . push ( { name : 'Foo' + i } ) ;
1440
+ }
1441
+ let clientWidth , clientHeight ;
1442
+
1443
+ beforeAll ( ( ) => {
1444
+ clientWidth = jest . spyOn ( window . HTMLElement . prototype , 'clientWidth' , 'get' ) . mockImplementation ( ( ) => 100 ) ;
1445
+ clientHeight = jest . spyOn ( window . HTMLElement . prototype , 'clientHeight' , 'get' ) . mockImplementation ( ( ) => 100 ) ;
1446
+ } ) ;
1447
+
1448
+ beforeEach ( ( ) => {
1449
+ act ( ( ) => { jest . runAllTimers ( ) ; } ) ;
1450
+ } ) ;
1451
+
1452
+ afterAll ( function ( ) {
1453
+ clientWidth . mockReset ( ) ;
1454
+ clientHeight . mockReset ( ) ;
1455
+ } ) ;
1456
+
1457
+ let VirtualizedAsyncListbox = ( props ) => {
1458
+ let { items, isLoading, onLoadMore, ...listBoxProps } = props ;
1459
+ return (
1460
+ < Virtualizer
1461
+ layout = { ListLayout }
1462
+ layoutOptions = { {
1463
+ rowHeight : 25 ,
1464
+ loaderHeight : 30
1465
+ } } >
1466
+ < ListBox
1467
+ { ...listBoxProps }
1468
+ aria-label = "async virtualized listbox"
1469
+ renderEmptyState = { ( ) => renderEmptyState ( ) } >
1470
+ < Collection items = { items } >
1471
+ { ( item ) => (
1472
+ < ListBoxItem id = { item . name } > { item . name } </ ListBoxItem >
1473
+ ) }
1474
+ </ Collection >
1475
+ < UNSTABLE_ListBoxLoadingSentinel isLoading = { isLoading } onLoadMore = { onLoadMore } >
1476
+ Loading...
1477
+ </ UNSTABLE_ListBoxLoadingSentinel >
1478
+ </ ListBox >
1479
+ </ Virtualizer >
1480
+ ) ;
1481
+ } ;
1482
+
1483
+ it ( 'should always render the sentinel even when virtualized' , ( ) => {
1484
+ let tree = render ( < VirtualizedAsyncListbox isLoading items = { items } /> ) ;
1485
+ let listboxTester = testUtilUser . createTester ( 'ListBox' , { root : tree . getByRole ( 'listbox' ) } ) ;
1486
+ let options = listboxTester . options ( ) ;
1487
+ expect ( options ) . toHaveLength ( 8 ) ;
1488
+ let loaderRow = options [ 7 ] ;
1489
+ expect ( loaderRow ) . toHaveTextContent ( 'Loading...' ) ;
1490
+ expect ( loaderRow ) . toHaveAttribute ( 'aria-posinset' , '51' ) ;
1491
+ expect ( loaderRow ) . toHaveAttribute ( 'aria-setSize' , '51' ) ;
1492
+ let loaderParentStyles = loaderRow . parentElement . style ;
1493
+
1494
+ // 50 items * 25px = 1250
1495
+ expect ( loaderParentStyles . top ) . toBe ( '1250px' ) ;
1496
+ expect ( loaderParentStyles . height ) . toBe ( '30px' ) ;
1497
+
1498
+ let sentinel = within ( loaderRow . parentElement ) . getByTestId ( 'loadMoreSentinel' ) ;
1499
+ expect ( sentinel . parentElement ) . toHaveAttribute ( 'inert' ) ;
1500
+ } ) ;
1501
+
1502
+ // TODO: for some reason this tree renders empty if ran with the above test...
1503
+ // It thinks that the contextSize is 0 and never updates
1504
+ it . skip ( 'should not reserve room for the loader if isLoading is false' , ( ) => {
1505
+ let tree = render ( < VirtualizedAsyncListbox items = { items } /> ) ;
1506
+ let listboxTester = testUtilUser . createTester ( 'ListBox' , { root : tree . getByRole ( 'listbox' ) } ) ;
1507
+ let options = listboxTester . options ( ) ;
1508
+ expect ( options ) . toHaveLength ( 7 ) ;
1509
+ expect ( within ( listboxTester . listbox ) . queryByText ( 'Loading...' ) ) . toBeFalsy ( ) ;
1510
+
1511
+ let sentinel = within ( listboxTester . listbox ) . getByTestId ( 'loadMoreSentinel' ) ;
1512
+ let sentinelParentStyles = sentinel . parentElement . parentElement . style ;
1513
+ expect ( sentinelParentStyles . top ) . toBe ( '1250px' ) ;
1514
+ expect ( sentinelParentStyles . height ) . toBe ( '0px' ) ;
1515
+ expect ( sentinel . parentElement ) . toHaveAttribute ( 'inert' ) ;
1516
+
1517
+ tree . rerender ( < VirtualizedAsyncListbox items = { [ ] } /> ) ;
1518
+ options = listboxTester . options ( ) ;
1519
+ expect ( options ) . toHaveLength ( 1 ) ;
1520
+ let emptyStateRow = options [ 0 ] ;
1521
+ expect ( emptyStateRow ) . toHaveTextContent ( 'empty state' ) ;
1522
+ expect ( within ( listboxTester . listbox ) . queryByText ( 'Loading...' ) ) . toBeFalsy ( ) ;
1523
+
1524
+ sentinel = within ( listboxTester . listbox ) . getByTestId ( 'loadMoreSentinel' ) ;
1525
+ sentinelParentStyles = sentinel . parentElement . parentElement . style ;
1526
+ expect ( sentinelParentStyles . top ) . toBe ( '0px' ) ;
1527
+ expect ( sentinelParentStyles . height ) . toBe ( '0px' ) ;
1528
+
1529
+ // Setting isLoading will render the loader even if the list is empty.
1530
+ tree . rerender ( < VirtualizedAsyncListbox items = { [ ] } isLoading /> ) ;
1531
+ options = listboxTester . options ( ) ;
1532
+ expect ( options ) . toHaveLength ( 2 ) ;
1533
+ emptyStateRow = options [ 1 ] ;
1534
+ expect ( emptyStateRow ) . toHaveTextContent ( 'empty state' ) ;
1535
+
1536
+ let loadingRow = options [ 0 ] ;
1537
+ expect ( loadingRow ) . toHaveTextContent ( 'Loading...' ) ;
1538
+
1539
+ sentinel = within ( listboxTester . listbox ) . getByTestId ( 'loadMoreSentinel' ) ;
1540
+ sentinelParentStyles = sentinel . parentElement . parentElement . style ;
1541
+ expect ( sentinelParentStyles . top ) . toBe ( '0px' ) ;
1542
+ expect ( sentinelParentStyles . height ) . toBe ( '30px' ) ;
1543
+ } ) ;
1544
+ } ) ;
1545
+ } ) ;
1331
1546
} ) ;
0 commit comments