diff --git a/packages/@react-aria/list/README.md b/packages/@react-aria/list/README.md new file mode 100644 index 00000000000..8c3905bdc98 --- /dev/null +++ b/packages/@react-aria/list/README.md @@ -0,0 +1,3 @@ +# @react-aria/list + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-aria/list/docs/useList.placeholder.mdx b/packages/@react-aria/list/docs/useList.placeholder.mdx new file mode 100644 index 00000000000..03780773cf0 --- /dev/null +++ b/packages/@react-aria/list/docs/useList.placeholder.mdx @@ -0,0 +1,69 @@ +{/* Copyright 2022 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 {Layout} from '@react-spectrum/docs'; +export default Layout; + +import docs from 'docs:@react-aria/list'; +import {HeaderInfo, FunctionAPI, TypeContext, InterfaceType, TypeLink, PageDescription} from '@react-spectrum/docs'; +import packageData from '@react-aria/list/package.json'; + +*Include after_version if the docs shouldn't be published to the website until reaching a specific package version.* +--- +category: Category Name +keywords: [] +after_version: 3.0.0-alpha.0 +--- + +# useList + +{docs.exports.useList.description} + +*Be sure to update the W3C url below if applicable to your hook, otherwise omit the sourceData prop.* + + +## API + +*Include an additional FunctionAPI if multiple hooks are being documented in a single file. See useTabList.mdx for an example.* +## Features + +*Describe what the aria hook helps with/provides.* + +## Anatomy/Usage + +*For hooks that are meant to be used with specific elements/components, include an Anatomy section detailing the props the hook returns. See useColorField.mdx for an example.* +*If applicable, the anatomy diagram should be added as a local svg file, sourced from the Spectrum XD file (ask Devon for the file if you are unsure). Follow these steps after you obtain the XD file:* +*1. Open the XD file and find the anatomy diagram. Select it by double clicking its artboard.* +*2. Export it as an SVG via File -> Export -> Selected...* +*3a. Manually remove any extraneous Spectrum-only elements and labels from the SVG.* +*3b. Replace the colors in the SVG with their spectrum color variable equivalents. See docs.css .provider for a mapping of these colors.* +*3c. Add a `title` and `desc` to the SVG summarizing the contents of the diagram. See any of existing anatomy.svg for an example. * + +*For hooks that are meant for more general use, include a Usage section instead detailing the props/params the hook accepts and returns. See useKeyboard.mdx for an example.* + + +## Example + +*Add an example of the hook (being used with native elements, etc)* +*If you create an example component that will be reused else where in this doc, include export=true so that you can directly reuse the component and avoid copy pasting the same code.* +*See useComboBox.mdx for an example.* + +## Usage + +*For hooks that are meant to be used with specific elements/components, include this usage section detailing examples of how to use the hook. * +*This should roughly mirror the examples that the corresponding React Spectrum component docs have (e.g. Controlled/Uncontrolled, Disabled, change handlers, etc). * + +## Internationalization + +*Mention if RTL * diff --git a/packages/@react-aria/list/index.ts b/packages/@react-aria/list/index.ts new file mode 100644 index 00000000000..4e9931530d8 --- /dev/null +++ b/packages/@react-aria/list/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2022 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. + */ + +export * from './src'; diff --git a/packages/@react-aria/list/intl/ar-AE.json b/packages/@react-aria/list/intl/ar-AE.json new file mode 100644 index 00000000000..eb78c078009 --- /dev/null +++ b/packages/@react-aria/list/intl/ar-AE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} غير المحدد", + "longPressToSelect": "اضغط مطولًا للدخول إلى وضع التحديد.", + "select": "تحديد", + "selectedAll": "جميع العناصر المحددة.", + "selectedCount": "{count, plural, =0 {لم يتم تحديد عناصر} one {# عنصر محدد} other {# عنصر محدد}}.", + "selectedItem": "{item} المحدد" +} diff --git a/packages/@react-aria/list/intl/bg-BG.json b/packages/@react-aria/list/intl/bg-BG.json new file mode 100644 index 00000000000..1af7894ca44 --- /dev/null +++ b/packages/@react-aria/list/intl/bg-BG.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} не е избран.", + "longPressToSelect": "Натиснете и задръжте за да влезете в избирателен режим.", + "select": "Изберете", + "selectedAll": "Всички елементи са избрани.", + "selectedCount": "{count, plural, =0 {Няма избрани елементи} one {# избран елемент} other {# избрани елементи}}.", + "selectedItem": "{item} избран." +} diff --git a/packages/@react-aria/list/intl/cs-CZ.json b/packages/@react-aria/list/intl/cs-CZ.json new file mode 100644 index 00000000000..4e6d2cf58c2 --- /dev/null +++ b/packages/@react-aria/list/intl/cs-CZ.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Položka {item} není vybrána.", + "longPressToSelect": "Dlouhým stisknutím přejdete do režimu výběru.", + "select": "Vybrat", + "selectedAll": "Vybrány všechny položky.", + "selectedCount": "{count, plural, =0 {Nevybrány žádné položky} one {Vybrána # položka} other {Vybráno # položek}}.", + "selectedItem": "Vybrána položka {item}." +} diff --git a/packages/@react-aria/list/intl/da-DK.json b/packages/@react-aria/list/intl/da-DK.json new file mode 100644 index 00000000000..6f9c9aaaf86 --- /dev/null +++ b/packages/@react-aria/list/intl/da-DK.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} ikke valgt.", + "longPressToSelect": "Lav et langt tryk for at aktivere valgtilstand.", + "select": "Vælg", + "selectedAll": "Alle elementer valgt.", + "selectedCount": "{count, plural, =0 {Ingen elementer valgt} one {# element valgt} other {# elementer valgt}}.", + "selectedItem": "{item} valgt." +} diff --git a/packages/@react-aria/list/intl/de-DE.json b/packages/@react-aria/list/intl/de-DE.json new file mode 100644 index 00000000000..dc735f4e174 --- /dev/null +++ b/packages/@react-aria/list/intl/de-DE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nicht ausgewählt.", + "longPressToSelect": "Gedrückt halten, um Auswahlmodus zu öffnen.", + "select": "Auswählen", + "selectedAll": "Alle Elemente ausgewählt.", + "selectedCount": "{count, plural, =0 {Keine Elemente ausgewählt} one {# Element ausgewählt} other {# Elemente ausgewählt}}.", + "selectedItem": "{item} ausgewählt." +} diff --git a/packages/@react-aria/list/intl/el-GR.json b/packages/@react-aria/list/intl/el-GR.json new file mode 100644 index 00000000000..74f9211c5a6 --- /dev/null +++ b/packages/@react-aria/list/intl/el-GR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Δεν επιλέχθηκε το στοιχείο {item}.", + "longPressToSelect": "Πατήστε παρατεταμένα για να μπείτε σε λειτουργία επιλογής.", + "select": "Επιλογή", + "selectedAll": "Επιλέχθηκαν όλα τα στοιχεία.", + "selectedCount": "{count, plural, =0 {Δεν επιλέχθηκαν στοιχεία} one {Επιλέχθηκε # στοιχείο} other {Επιλέχθηκαν # στοιχεία}}.", + "selectedItem": "Επιλέχθηκε το στοιχείο {item}." +} diff --git a/packages/@react-aria/list/intl/en-US.json b/packages/@react-aria/list/intl/en-US.json new file mode 100644 index 00000000000..b4fcc207f28 --- /dev/null +++ b/packages/@react-aria/list/intl/en-US.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} not selected.", + "select": "Select", + "selectedCount": "{count, plural, =0 {No items selected} one {# item selected} other {# items selected}}.", + "selectedAll": "All items selected.", + "selectedItem": "{item} selected.", + "longPressToSelect": "Long press to enter selection mode." +} diff --git a/packages/@react-aria/list/intl/es-ES.json b/packages/@react-aria/list/intl/es-ES.json new file mode 100644 index 00000000000..9ac6b8a9730 --- /dev/null +++ b/packages/@react-aria/list/intl/es-ES.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} no seleccionado.", + "longPressToSelect": "Mantenga pulsado para abrir el modo de selección.", + "select": "Seleccionar", + "selectedAll": "Todos los elementos seleccionados.", + "selectedCount": "{count, plural, =0 {Ningún elemento seleccionado} one {# elemento seleccionado} other {# elementos seleccionados}}.", + "selectedItem": "{item} seleccionado." +} diff --git a/packages/@react-aria/list/intl/et-EE.json b/packages/@react-aria/list/intl/et-EE.json new file mode 100644 index 00000000000..57a2141b983 --- /dev/null +++ b/packages/@react-aria/list/intl/et-EE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} pole valitud.", + "longPressToSelect": "Valikurežiimi sisenemiseks vajutage pikalt.", + "select": "Vali", + "selectedAll": "Kõik üksused valitud.", + "selectedCount": "{count, plural, =0 {Üksusi pole valitud} one {# üksus valitud} other {# üksust valitud}}.", + "selectedItem": "{item} valitud." +} diff --git a/packages/@react-aria/list/intl/fi-FI.json b/packages/@react-aria/list/intl/fi-FI.json new file mode 100644 index 00000000000..1b7375371ed --- /dev/null +++ b/packages/@react-aria/list/intl/fi-FI.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Kohdetta {item} ei valittu.", + "longPressToSelect": "Siirry valintatilaan painamalla pitkään.", + "select": "Valitse", + "selectedAll": "Kaikki kohteet valittu.", + "selectedCount": "{count, plural, =0 {Ei yhtään kohdetta valittu} one {# kohde valittu} other {# kohdetta valittu}}.", + "selectedItem": "{item} valittu." +} diff --git a/packages/@react-aria/list/intl/fr-FR.json b/packages/@react-aria/list/intl/fr-FR.json new file mode 100644 index 00000000000..c5db90e5f4c --- /dev/null +++ b/packages/@react-aria/list/intl/fr-FR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} non sélectionné.", + "longPressToSelect": "Appuyez de manière prolongée pour passer en mode de sélection.", + "select": "Sélectionner", + "selectedAll": "Tous les éléments sélectionnés.", + "selectedCount": "{count, plural, =0 {Aucun élément sélectionné} one {# élément sélectionné} other {# éléments sélectionnés}}.", + "selectedItem": "{item} sélectionné." +} diff --git a/packages/@react-aria/list/intl/he-IL.json b/packages/@react-aria/list/intl/he-IL.json new file mode 100644 index 00000000000..7bee1b9eed9 --- /dev/null +++ b/packages/@react-aria/list/intl/he-IL.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} לא נבחר.", + "longPressToSelect": "הקשה ארוכה לכניסה למצב בחירה.", + "select": "בחר", + "selectedAll": "כל הפריטים נבחרו.", + "selectedCount": "{count, plural, =0 {לא נבחרו פריטים} one {פריט # נבחר} other {# פריטים נבחרו}}.", + "selectedItem": "{item} נבחר." +} diff --git a/packages/@react-aria/list/intl/hr-HR.json b/packages/@react-aria/list/intl/hr-HR.json new file mode 100644 index 00000000000..b8f21698f6b --- /dev/null +++ b/packages/@react-aria/list/intl/hr-HR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Stavka {item} nije odabrana.", + "longPressToSelect": "Dugo pritisnite za ulazak u način odabira.", + "select": "Odaberite", + "selectedAll": "Odabrane su sve stavke.", + "selectedCount": "{count, plural, =0 {Nije odabrana nijedna stavka} one {Odabrana je # stavka} other {Odabrano je # stavki}}.", + "selectedItem": "Stavka {item} je odabrana." +} diff --git a/packages/@react-aria/list/intl/hu-HU.json b/packages/@react-aria/list/intl/hu-HU.json new file mode 100644 index 00000000000..2b51e1b6da8 --- /dev/null +++ b/packages/@react-aria/list/intl/hu-HU.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nincs kijelölve.", + "longPressToSelect": "Nyomja hosszan a kijelöléshez.", + "select": "Kijelölés", + "selectedAll": "Az összes elem kijelölve.", + "selectedCount": "{count, plural, =0 {Egy elem sincs kijelölve} one {# elem kijelölve} other {# elem kijelölve}}.", + "selectedItem": "{item} kijelölve." +} diff --git a/packages/@react-aria/list/intl/it-IT.json b/packages/@react-aria/list/intl/it-IT.json new file mode 100644 index 00000000000..1d402d0c675 --- /dev/null +++ b/packages/@react-aria/list/intl/it-IT.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} non selezionato.", + "longPressToSelect": "Premi a lungo per passare alla modalità di selezione.", + "select": "Seleziona", + "selectedAll": "Tutti gli elementi selezionati.", + "selectedCount": "{count, plural, =0 {Nessun elemento selezionato} one {# elemento selezionato} other {# elementi selezionati}}.", + "selectedItem": "{item} selezionato." +} diff --git a/packages/@react-aria/list/intl/ja-JP.json b/packages/@react-aria/list/intl/ja-JP.json new file mode 100644 index 00000000000..1e5f8653c32 --- /dev/null +++ b/packages/@react-aria/list/intl/ja-JP.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} が選択されていません。", + "longPressToSelect": "長押しして選択モードを開きます。", + "select": "選択", + "selectedAll": "すべての項目を選択しました。", + "selectedCount": "{count, plural, =0 {項目が選択されていません} one {# 項目を選択しました} other {# 項目を選択しました}}。", + "selectedItem": "{item} を選択しました。" +} diff --git a/packages/@react-aria/list/intl/ko-KR.json b/packages/@react-aria/list/intl/ko-KR.json new file mode 100644 index 00000000000..96143803671 --- /dev/null +++ b/packages/@react-aria/list/intl/ko-KR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item}이(가) 선택되지 않았습니다.", + "longPressToSelect": "선택 모드로 들어가려면 길게 누르십시오.", + "select": "선택", + "selectedAll": "모든 항목이 선택되었습니다.", + "selectedCount": "{count, plural, =0 {선택된 항목이 없습니다} one {#개 항목이 선택되었습니다} other {#개 항목이 선택되었습니다}}.", + "selectedItem": "{item}이(가) 선택되었습니다." +} diff --git a/packages/@react-aria/list/intl/lt-LT.json b/packages/@react-aria/list/intl/lt-LT.json new file mode 100644 index 00000000000..59bde223e72 --- /dev/null +++ b/packages/@react-aria/list/intl/lt-LT.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nepasirinkta.", + "longPressToSelect": "Norėdami įjungti pasirinkimo režimą, paspauskite ir palaikykite.", + "select": "Pasirinkti", + "selectedAll": "Pasirinkti visi elementai.", + "selectedCount": "{count, plural, =0 {Nepasirinktas nė vienas elementas} one {Pasirinktas # elementas} other {Pasirinkta elementų: #}}.", + "selectedItem": "Pasirinkta: {item}." +} diff --git a/packages/@react-aria/list/intl/lv-LV.json b/packages/@react-aria/list/intl/lv-LV.json new file mode 100644 index 00000000000..f1282ade995 --- /dev/null +++ b/packages/@react-aria/list/intl/lv-LV.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Vienums {item} nav atlasīts.", + "longPressToSelect": "Ilgi turiet nospiestu. lai ieslēgtu atlases režīmu.", + "select": "Atlasīt", + "selectedAll": "Atlasīti visi vienumi.", + "selectedCount": "{count, plural, =0 {Nav atlasīts neviens vienums} one {Atlasīto vienumu skaits: #} other {Atlasīto vienumu skaits: #}}.", + "selectedItem": "Atlasīts vienums {item}." +} diff --git a/packages/@react-aria/list/intl/nb-NO.json b/packages/@react-aria/list/intl/nb-NO.json new file mode 100644 index 00000000000..52d2c4fe603 --- /dev/null +++ b/packages/@react-aria/list/intl/nb-NO.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} er ikke valgt.", + "longPressToSelect": "Bruk et langt trykk for å gå inn i valgmodus.", + "select": "Velg", + "selectedAll": "Alle elementer er valgt.", + "selectedCount": "{count, plural, =0 {Ingen elementer er valgt} one {# element er valgt} other {# elementer er valgt}}.", + "selectedItem": "{item} er valgt." +} diff --git a/packages/@react-aria/list/intl/nl-NL.json b/packages/@react-aria/list/intl/nl-NL.json new file mode 100644 index 00000000000..61d80eb0725 --- /dev/null +++ b/packages/@react-aria/list/intl/nl-NL.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} niet geselecteerd.", + "longPressToSelect": "Druk lang om de selectiemodus te openen.", + "select": "Selecteren", + "selectedAll": "Alle items geselecteerd.", + "selectedCount": "{count, plural, =0 {Geen items geselecteerd} one {# item geselecteerd} other {# items geselecteerd}}.", + "selectedItem": "{item} geselecteerd." +} diff --git a/packages/@react-aria/list/intl/pl-PL.json b/packages/@react-aria/list/intl/pl-PL.json new file mode 100644 index 00000000000..cef60105f09 --- /dev/null +++ b/packages/@react-aria/list/intl/pl-PL.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Nie zaznaczono {item}.", + "longPressToSelect": "Naciśnij i przytrzymaj, aby wejść do trybu wyboru.", + "select": "Zaznacz", + "selectedAll": "Wszystkie zaznaczone elementy.", + "selectedCount": "{count, plural, =0 {Nie zaznaczono żadnych elementów} one {# zaznaczony element} other {# zaznaczonych elementów}}.", + "selectedItem": "Zaznaczono {item}." +} diff --git a/packages/@react-aria/list/intl/pt-BR.json b/packages/@react-aria/list/intl/pt-BR.json new file mode 100644 index 00000000000..87a2a14508a --- /dev/null +++ b/packages/@react-aria/list/intl/pt-BR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} não selecionado.", + "longPressToSelect": "Mantenha pressionado para entrar no modo de seleção.", + "select": "Selecionar", + "selectedAll": "Todos os itens selecionados.", + "selectedCount": "{count, plural, =0 {Nenhum item selecionado} one {# item selecionado} other {# itens selecionados}}.", + "selectedItem": "{item} selecionado." +} diff --git a/packages/@react-aria/list/intl/pt-PT.json b/packages/@react-aria/list/intl/pt-PT.json new file mode 100644 index 00000000000..e0af18f69f5 --- /dev/null +++ b/packages/@react-aria/list/intl/pt-PT.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} não selecionado.", + "longPressToSelect": "Prima continuamente para entrar no modo de seleção.", + "select": "Selecionar", + "selectedAll": "Todos os itens selecionados.", + "selectedCount": "{count, plural, =0 {Nenhum item selecionado} one {# item selecionado} other {# itens selecionados}}.", + "selectedItem": "{item} selecionado." +} diff --git a/packages/@react-aria/list/intl/ro-RO.json b/packages/@react-aria/list/intl/ro-RO.json new file mode 100644 index 00000000000..c2c749e47f1 --- /dev/null +++ b/packages/@react-aria/list/intl/ro-RO.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} neselectat.", + "longPressToSelect": "Apăsați lung pentru a intra în modul de selectare.", + "select": "Selectare", + "selectedAll": "Toate elementele selectate.", + "selectedCount": "{count, plural, =0 {Niciun element selectat} one {# element selectat} other {# elemente selectate}}.", + "selectedItem": "{item} selectat." +} diff --git a/packages/@react-aria/list/intl/ru-RU.json b/packages/@react-aria/list/intl/ru-RU.json new file mode 100644 index 00000000000..9e805fe6346 --- /dev/null +++ b/packages/@react-aria/list/intl/ru-RU.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} не выбрано.", + "longPressToSelect": "Нажмите и удерживайте для входа в режим выбора.", + "select": "Выбрать", + "selectedAll": "Выбраны все элементы.", + "selectedCount": "{count, plural, =0 {Нет выбранных элементов} one {# элемент выбран} other {# элементов выбрано}}.", + "selectedItem": "{item} выбрано." +} diff --git a/packages/@react-aria/list/intl/sk-SK.json b/packages/@react-aria/list/intl/sk-SK.json new file mode 100644 index 00000000000..91e6c7b6fd6 --- /dev/null +++ b/packages/@react-aria/list/intl/sk-SK.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Nevybraté položky: {item}.", + "longPressToSelect": "Dlhším stlačením prejdite do režimu výberu.", + "select": "Vybrať", + "selectedAll": "Všetky vybraté položky.", + "selectedCount": "{count, plural, =0 {Žiadne vybraté položky} one {# vybratá položka} other {Počet vybratých položiek:#}}.", + "selectedItem": "Vybraté položky: {item}." +} diff --git a/packages/@react-aria/list/intl/sl-SI.json b/packages/@react-aria/list/intl/sl-SI.json new file mode 100644 index 00000000000..dde6830c824 --- /dev/null +++ b/packages/@react-aria/list/intl/sl-SI.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "Element {item} ni izbran.", + "longPressToSelect": "Za izbirni način pritisnite in dlje časa držite.", + "select": "Izberite", + "selectedAll": "Vsi elementi so izbrani.", + "selectedCount": "{count, plural, =0 {Noben element ni izbran} one {# element je izbran} other {# elementov je izbranih}}.", + "selectedItem": "Element {item} je izbran." +} diff --git a/packages/@react-aria/list/intl/sr-SP.json b/packages/@react-aria/list/intl/sr-SP.json new file mode 100644 index 00000000000..b2300ab5fb6 --- /dev/null +++ b/packages/@react-aria/list/intl/sr-SP.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} nije izabrano.", + "longPressToSelect": "Dugo pritisnite za ulazak u režim biranja.", + "select": "Izaberite", + "selectedAll": "Izabrane su sve stavke.", + "selectedCount": "{count, plural, =0 {Nije izabrana nijedna stavka} one {Izabrana je # stavka} other {Izabrano je # stavki}}.", + "selectedItem": "{item} je izabrano." +} diff --git a/packages/@react-aria/list/intl/sv-SE.json b/packages/@react-aria/list/intl/sv-SE.json new file mode 100644 index 00000000000..b4b58379beb --- /dev/null +++ b/packages/@react-aria/list/intl/sv-SE.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} ej markerat.", + "longPressToSelect": "Tryck länge när du vill öppna väljarläge.", + "select": "Markera", + "selectedAll": "Alla markerade objekt.", + "selectedCount": "{count, plural, =0 {Inga markerade objekt} one {# markerat objekt} other {# markerade objekt}}.", + "selectedItem": "{item} markerat." +} diff --git a/packages/@react-aria/list/intl/tr-TR.json b/packages/@react-aria/list/intl/tr-TR.json new file mode 100644 index 00000000000..0c441b800c8 --- /dev/null +++ b/packages/@react-aria/list/intl/tr-TR.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} seçilmedi.", + "longPressToSelect": "Seçim moduna girmek için uzun basın.", + "select": "Seç", + "selectedAll": "Tüm ögeler seçildi.", + "selectedCount": "{count, plural, =0 {Hiçbir öge seçilmedi} one {# öge seçildi} other {# öge seçildi}}.", + "selectedItem": "{item} seçildi." +} diff --git a/packages/@react-aria/list/intl/uk-UA.json b/packages/@react-aria/list/intl/uk-UA.json new file mode 100644 index 00000000000..f113d5407bf --- /dev/null +++ b/packages/@react-aria/list/intl/uk-UA.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "{item} не вибрано.", + "longPressToSelect": "Виконайте довге натиснення, щоб перейти в режим вибору.", + "select": "Вибрати", + "selectedAll": "Усі елементи вибрано.", + "selectedCount": "{count, plural, =0 {Жодних елементів не вибрано} one {# елемент вибрано} other {Вибрано елементів: #}}.", + "selectedItem": "{item} вибрано." +} diff --git a/packages/@react-aria/list/intl/zh-CN.json b/packages/@react-aria/list/intl/zh-CN.json new file mode 100644 index 00000000000..1b6644bd680 --- /dev/null +++ b/packages/@react-aria/list/intl/zh-CN.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "未选择 {item}。", + "longPressToSelect": "长按以进入选择模式。", + "select": "选择", + "selectedAll": "已选择所有项目。", + "selectedCount": "{count, plural, =0 {未选择项目} one {已选择 # 个项目} other {已选择 # 个项目}}。", + "selectedItem": "已选择 {item}。" +} diff --git a/packages/@react-aria/list/intl/zh-TW.json b/packages/@react-aria/list/intl/zh-TW.json new file mode 100644 index 00000000000..bfb55511765 --- /dev/null +++ b/packages/@react-aria/list/intl/zh-TW.json @@ -0,0 +1,8 @@ +{ + "deselectedItem": "未選取「{item}」。", + "longPressToSelect": "長按以進入選擇模式。", + "select": "選取", + "selectedAll": "已選取所有項目。", + "selectedCount": "{count, plural, =0 {未選取任何項目} one {已選取 # 個項目} other {已選取 # 個項目}}。", + "selectedItem": "已選取「{item}」。" +} diff --git a/packages/@react-aria/list/package.json b/packages/@react-aria/list/package.json new file mode 100644 index 00000000000..c64deab732a --- /dev/null +++ b/packages/@react-aria/list/package.json @@ -0,0 +1,39 @@ +{ + "name": "@react-aria/list", + "version": "3.0.0-alpha.1", + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-aria/focus": "^3.5.4", + "@react-aria/grid": "^3.2.5", + "@react-aria/i18n": "^3.3.8", + "@react-aria/interactions": "^3.8.3", + "@react-aria/live-announcer": "^3.0.5", + "@react-aria/selection": "^3.8.1", + "@react-aria/utils": "^3.11.3", + "@react-stately/list": "^3.4.4", + "@react-types/checkbox": "^3.2.6", + "@react-types/list": "3.0.0-alpha.1", + "@react-types/shared": "^3.11.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-aria/list/src/index.ts b/packages/@react-aria/list/src/index.ts new file mode 100644 index 00000000000..27c64e7195a --- /dev/null +++ b/packages/@react-aria/list/src/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2022 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. + */ + +export {useList} from './useList'; +export {useListItem} from './useListItem'; +export {useListSelectionCheckbox} from './useListSelectionCheckbox'; + +export type {AriaListOptions, ListViewAria} from './useList'; +export type {AriaListItemOptions, ListItemAria} from './useListItem'; +export type {SelectionCheckboxProps, SelectionCheckboxAria} from './useListSelectionCheckbox'; diff --git a/packages/@react-aria/list/src/useList.ts b/packages/@react-aria/list/src/useList.ts new file mode 100644 index 00000000000..41ad41aa3f9 --- /dev/null +++ b/packages/@react-aria/list/src/useList.ts @@ -0,0 +1,192 @@ +/* + * Copyright 2022 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 {announce} from '@react-aria/live-announcer'; +import {AriaListProps} from '@react-types/list'; +import {filterDOMProps, mergeProps, useId, useUpdateEffect} from '@react-aria/utils'; +import {HTMLAttributes, Key, RefObject, useMemo, useRef} from 'react'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {KeyboardDelegate, Selection} from '@react-types/shared'; +import {listMap} from './utils'; +import {ListState} from '@react-stately/list'; +import {useDescription} from '@react-aria/utils'; +import {useInteractionModality} from '@react-aria/interactions'; +import {useMessageFormatter} from '@react-aria/i18n'; +import {useSelectableList} from '@react-aria/selection'; + +export interface AriaListOptions extends Omit, 'children'> { + /** Whether the list uses virtual scrolling. */ + isVirtualized?: boolean, + /** + * An optional keyboard delegate implementation for type to select, + * to override the default. + */ + keyboardDelegate?: KeyboardDelegate, + /** + * A function that returns the text that should be announced by assistive technology when a row is added or removed from selection. + * @default (key) => state.collection.getItem(key)?.textValue + */ + getRowText?: (key: Key) => string +} + +export interface ListViewAria { + /** Props for the grid element. */ + gridProps: HTMLAttributes +} + +/** + * Provides the behavior and accessibility implementation for a list component. + * A list displays data in a single columns and enables a user to navigate its contents via directional navigation keys. + * @param props - Props for the list. + * @param state - State for the list, as returned by `useListState`. + * @param ref - The ref attached to the list element. + */ +export function useList(props: AriaListOptions, state: ListState, ref: RefObject): ListViewAria { + // Rough copy of useGrid, but modifications + things removed for ListView specific case + let { + isVirtualized, + keyboardDelegate, + getRowText = (key) => state.collection.getItem(key)?.textValue, + onAction + } = props; + let formatMessage = useMessageFormatter(intlMessages); + + if (!props['aria-label'] && !props['aria-labelledby']) { + console.warn('An aria-label or aria-labelledby prop is required for accessibility.'); + } + + let {listProps} = useSelectableList({ + selectionManager: state.selectionManager, + collection: state.collection, + disabledKeys: state.disabledKeys, + ref, + keyboardDelegate: keyboardDelegate, + isVirtualized, + selectOnFocus: state.selectionManager.selectionBehavior === 'replace' + }); + + let id = useId(); + listMap.set(state, {id, onAction}); + + // This is useHighlightSelectionDescription copy pasted, it isn't exposed by react-aria/grid. + let modality = useInteractionModality(); + // null is the default if the user hasn't interacted with the list at all yet or the rest of the page + let shouldLongPress = (modality === 'pointer' || modality === 'virtual' || modality == null) + && typeof window !== 'undefined' && 'ontouchstart' in window; + + let interactionDescription = useMemo(() => { + let selectionMode = state.selectionManager.selectionMode; + let selectionBehavior = state.selectionManager.selectionBehavior; + + let message = undefined; + if (shouldLongPress) { + message = formatMessage('longPressToSelect'); + } + + return selectionBehavior === 'replace' && selectionMode !== 'none' && onAction ? message : undefined; + }, [state.selectionManager.selectionMode, state.selectionManager.selectionBehavior, onAction, formatMessage, shouldLongPress]); + + let descriptionProps = useDescription(interactionDescription); + + let domProps = filterDOMProps(props, {labelable: true}); + let gridProps: HTMLAttributes = mergeProps( + domProps, + { + role: 'grid', + id, + 'aria-multiselectable': state.selectionManager.selectionMode === 'multiple' ? 'true' : undefined + }, + listProps, + descriptionProps + ); + + if (isVirtualized) { + gridProps['aria-rowcount'] = state.collection.size; + gridProps['aria-colcount'] = 1; + } + + // Many screen readers do not announce when items in a grid are selected/deselected. + // We do this using an ARIA live region. + let selection = state.selectionManager.rawSelection; + let lastSelection = useRef(selection); + useUpdateEffect(() => { + if (!state.selectionManager.isFocused) { + lastSelection.current = selection; + + return; + } + + let addedKeys = diffSelection(selection, lastSelection.current); + let removedKeys = diffSelection(lastSelection.current, selection); + + // If adding or removing a single row from the selection, announce the name of that item. + let isReplace = state.selectionManager.selectionBehavior === 'replace'; + let messages = []; + + if ((state.selectionManager.selectedKeys.size === 1 && isReplace)) { + if (state.collection.getItem(state.selectionManager.selectedKeys.keys().next().value)) { + let currentSelectionText = getRowText(state.selectionManager.selectedKeys.keys().next().value); + if (currentSelectionText) { + messages.push(formatMessage('selectedItem', {item: currentSelectionText})); + } + } + } else if (addedKeys.size === 1 && removedKeys.size === 0) { + let addedText = getRowText(addedKeys.keys().next().value); + if (addedText) { + messages.push(formatMessage('selectedItem', {item: addedText})); + } + } else if (removedKeys.size === 1 && addedKeys.size === 0) { + if (state.collection.getItem(removedKeys.keys().next().value)) { + let removedText = getRowText(removedKeys.keys().next().value); + if (removedText) { + messages.push(formatMessage('deselectedItem', {item: removedText})); + } + } + } + + // Announce how many items are selected, except when selecting the first item. + if (state.selectionManager.selectionMode === 'multiple') { + if (messages.length === 0 || selection === 'all' || selection.size > 1 || lastSelection.current === 'all' || lastSelection.current?.size > 1) { + messages.push(selection === 'all' + ? formatMessage('selectedAll') + : formatMessage('selectedCount', {count: selection.size}) + ); + } + } + + if (messages.length > 0) { + announce(messages.join(' ')); + } + + lastSelection.current = selection; + }, [selection]); + + return { + gridProps + }; +} + +function diffSelection(a: Selection, b: Selection): Set { + let res = new Set(); + if (a === 'all' || b === 'all') { + return res; + } + + for (let key of a.keys()) { + if (!b.has(key)) { + res.add(key); + } + } + + return res; +} diff --git a/packages/@react-aria/list/src/useListItem.ts b/packages/@react-aria/list/src/useListItem.ts new file mode 100644 index 00000000000..3230b8f70f1 --- /dev/null +++ b/packages/@react-aria/list/src/useListItem.ts @@ -0,0 +1,204 @@ +/* + * Copyright 2022 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 {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; +import {getRowId, listMap} from './utils'; +import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, RefObject} from 'react'; +import {isFocusVisible} from '@react-aria/interactions'; +import type {ListState} from '@react-stately/list'; +import {mergeProps} from '@react-aria/utils'; +import {Node as RSNode} from '@react-types/shared'; +import {useLocale} from '@react-aria/i18n'; +import {useSelectableItem} from '@react-aria/selection'; + +export interface AriaListItemOptions { + /** An object representing the list item. Contains all the relevant information that makes up the list row. */ + node: RSNode, + /** Whether the list row is contained in a virtual scroller. */ + isVirtualized?: boolean, + /** Whether selection should occur on press up instead of press down. */ + shouldSelectOnPressUp?: boolean, + /** Whether the list item is disabled. */ + isDisabled?: boolean +} + +export interface ListItemAria { + /** Props for the list row element. */ + rowProps: HTMLAttributes, + /** Props for the grid cell element within the list row. */ + gridCellProps: HTMLAttributes, + /** Whether the row is currently pressed. */ + isPressed: boolean +} + +/** + * Provides the behavior and accessibility implementation for a row in a list. + * @param props - Props for the row. + * @param state - State of the parent list, as returned by `useListState`. + * @param ref - The ref attached to the row element. + */ +export function useListItem(props: AriaListItemOptions, state: ListState, ref: RefObject): ListItemAria { + // Copied from useGridCell + some modifications to make it not so grid specific + let { + node, + isVirtualized, + shouldSelectOnPressUp, + isDisabled + } = props; + + let {direction} = useLocale(); + let {onAction} = listMap.get(state); + let focus = () => { + // Don't shift focus to the row if the active element is a element within the row already + // (e.g. clicking on a row button) + if (!ref.current.contains(document.activeElement)) { + focusSafely(ref.current); + } + }; + + let {itemProps, isPressed} = useSelectableItem({ + selectionManager: state.selectionManager, + key: node.key, + ref, + isVirtualized, + shouldSelectOnPressUp, + onAction: onAction ? () => onAction(node.key) : undefined, + isDisabled, + focus + }); + + let onKeyDown = (e: ReactKeyboardEvent) => { + if (!e.currentTarget.contains(e.target as HTMLElement)) { + return; + } + + let walker = getFocusableTreeWalker(ref.current); + walker.currentNode = document.activeElement; + + switch (e.key) { + case 'ArrowLeft': { + // Find the next focusable element within the row. + let focusable = direction === 'rtl' + ? walker.nextNode() as HTMLElement + : walker.previousNode() as HTMLElement; + + if (focusable) { + e.preventDefault(); + e.stopPropagation(); + focusSafely(focusable); + } else { + // If there is no next focusable child, then return focus back to the row + e.preventDefault(); + e.stopPropagation(); + if (direction === 'rtl') { + focusSafely(ref.current); + } else { + walker.currentNode = ref.current; + let lastElement = last(walker); + if (lastElement) { + focusSafely(lastElement); + } + } + } + break; + } + case 'ArrowRight': { + let focusable = direction === 'rtl' + ? walker.previousNode() as HTMLElement + : walker.nextNode() as HTMLElement; + + if (focusable) { + e.preventDefault(); + e.stopPropagation(); + focusSafely(focusable); + } else { + e.preventDefault(); + e.stopPropagation(); + if (direction === 'ltr') { + focusSafely(ref.current); + } else { + walker.currentNode = ref.current; + let lastElement = last(walker); + if (lastElement) { + focusSafely(lastElement); + } + } + } + break; + } + case 'ArrowUp': + case 'ArrowDown': + // Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate + // to the row above/below instead. We need to re-dispatch the event from a higher parent so it still + // bubbles and gets handled by useSelectableCollection. + if (!e.altKey && ref.current.contains(e.target as HTMLElement)) { + e.stopPropagation(); + e.preventDefault(); + ref.current.parentElement.dispatchEvent( + new KeyboardEvent(e.nativeEvent.type, e.nativeEvent) + ); + } + break; + } + }; + + let onFocus = (e) => { + if (e.target !== ref.current) { + // useSelectableItem only handles setting the focused key when + // the focused element is the row itself. We also want to + // set the focused key when a child element receives focus. + // If focus is currently visible (e.g. the user is navigating with the keyboard), + // then skip this. We want to restore focus to the previously focused row + // in that case since the list should act like a single tab stop. + if (!isFocusVisible()) { + state.selectionManager.setFocusedKey(node.key); + } + return; + } + }; + + let rowProps: HTMLAttributes = mergeProps(itemProps, { + role: 'row', + onKeyDownCapture: onKeyDown, + onFocus, + 'aria-label': node.textValue, + 'aria-selected': state.selectionManager.selectionMode !== 'none' ? state.selectionManager.isSelected(node.key) : undefined, + id: getRowId(state, node.key) + }); + + if (isVirtualized) { + rowProps['aria-rowindex'] = node.index + 1; + } + + let gridCellProps = { + role: 'gridcell', + 'aria-colindex': 1 + }; + + return { + rowProps, + gridCellProps, + isPressed + }; +} + +function last(walker: TreeWalker) { + let next: HTMLElement; + let last: HTMLElement; + do { + last = walker.lastChild() as HTMLElement; + if (last) { + next = last; + } + } while (last); + return next; +} diff --git a/packages/@react-aria/list/src/useListSelectionCheckbox.ts b/packages/@react-aria/list/src/useListSelectionCheckbox.ts new file mode 100644 index 00000000000..08f91e05b8b --- /dev/null +++ b/packages/@react-aria/list/src/useListSelectionCheckbox.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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 {AriaCheckboxProps} from '@react-types/checkbox'; +import {getRowId} from './utils'; +import {Key} from 'react'; +import type {ListState} from '@react-stately/list'; +import {useGridSelectionCheckbox} from '@react-aria/grid'; + +export interface SelectionCheckboxProps { + /** A unique key for the checkbox. */ + key: Key +} + +export interface SelectionCheckboxAria { + /** Props for the row selection checkbox element. */ + checkboxProps: AriaCheckboxProps +} + +/** + * Provides the behavior and accessibility implementation for a selection checkbox in a list. + * @param props - Props for the selection checkbox. + * @param state - State of the list, as returned by `useListState`. + */ +export function useListSelectionCheckbox(props: SelectionCheckboxProps, state: ListState): SelectionCheckboxAria { + let {key} = props; + const {checkboxProps} = useGridSelectionCheckbox(props, state as any); + + return { + checkboxProps: { + ...checkboxProps, + 'aria-labelledby': `${checkboxProps.id} ${getRowId(state, key)}` + } + }; +} diff --git a/packages/@react-aria/list/src/utils.ts b/packages/@react-aria/list/src/utils.ts new file mode 100644 index 00000000000..6204911f800 --- /dev/null +++ b/packages/@react-aria/list/src/utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2020 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 {Key} from 'react'; +import type {ListState} from '@react-stately/list'; + +interface ListMapShared { + id: string, + onAction: (key: Key) => void +} + +// Used to share: +// id of the list and onAction between useList, useListItem, and useListSelectionCheckbox +export const listMap = new WeakMap, ListMapShared>(); + +export function getRowId(state: ListState, key: Key) { + let {id} = listMap.get(state); + if (!id) { + throw new Error('Unknown list'); + } + + return `${id}-${normalizeKey(key)}`; +} + +export function normalizeKey(key: Key): string { + if (typeof key === 'string') { + return key.replace(/\s*/g, ''); + } + + return '' + key; +} diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index f0137b498a6..c88836c19db 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -37,6 +37,7 @@ "@react-aria/grid": "^3.2.6", "@react-aria/i18n": "^3.3.9", "@react-aria/interactions": "^3.8.4", + "@react-aria/list": "3.0.0-alpha.1", "@react-aria/listbox": "^3.4.5", "@react-aria/separator": "^3.1.7", "@react-aria/utils": "^3.12.0", @@ -59,6 +60,7 @@ "@react-stately/virtualizer": "^3.1.9", "@react-types/button": "^3.4.5", "@react-types/grid": "^3.0.4", + "@react-types/list": "3.0.0-alpha.1", "@react-types/listbox": "^3.2.5", "@react-types/provider": "^3.4.3", "@react-types/shared": "^3.12.0", diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index add1d8b4eef..bd5b5e178dd 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -9,24 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { - AriaLabelingProps, - AsyncLoadable, - CollectionBase, - DOMProps, - DOMRef, - LoadingState, - MultipleSelection, - SpectrumSelectionProps, - StyleProps -} from '@react-types/shared'; + import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; +import {DOMRef} from '@react-types/shared'; import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd'; import {DragHooks, DropHooks} from '@react-spectrum/dnd'; import {DragPreview} from './DragPreview'; import type {DroppableCollectionResult} from '@react-aria/dnd'; import {filterDOMProps, useLayoutEffect} from '@react-aria/utils'; -import {GridCollection, GridState, useGridState} from '@react-stately/grid'; import InsertionIndicator from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -39,13 +29,14 @@ import {ProgressCircle} from '@react-spectrum/progress'; import React, {Key, ReactElement, useContext, useMemo, useRef, useState} from 'react'; import {Rect} from '@react-stately/virtualizer'; import RootDropIndicator from './RootDropIndicator'; +import {SpectrumListProps} from '@react-types/list'; import {useCollator, useLocale, useMessageFormatter} from '@react-aria/i18n'; -import {useGrid} from '@react-aria/grid'; +import {useList} from '@react-aria/list'; import {useProvider} from '@react-spectrum/provider'; import {Virtualizer} from '@react-aria/virtualizer'; interface ListViewContextValue { - state: GridState>, + state: ListState, dragState: DraggableCollectionState, dropState: DroppableCollectionState, dragHooks: DragHooks, @@ -73,7 +64,7 @@ const ROW_HEIGHTS = { } }; -function useListLayout(state: ListState, density: ListViewProps['density']) { +function useListLayout(state: ListState, density: SpectrumListProps['density']) { let {scale} = useProvider(); let collator = useCollator({usage: 'search', sensitivity: 'base'}); let isEmpty = state.collection.size === 0; @@ -92,37 +83,7 @@ function useListLayout(state: ListState, density: ListViewProps['densit return layout; } -interface ListViewProps extends CollectionBase, DOMProps, AriaLabelingProps, StyleProps, MultipleSelection, SpectrumSelectionProps, Omit { - /** - * Sets the amount of vertical padding within each cell. - * @default 'regular' - */ - density?: 'compact' | 'regular' | 'spacious', - /** Whether the ListView should be displayed with a quiet style. */ - isQuiet?: boolean, - /** The current loading state of the ListView. Determines whether or not the progress circle should be shown. */ - loadingState?: LoadingState, - /** - * Sets the text behavior for the row contents. - * @default 'truncate' - */ - overflowMode?: 'truncate' | 'wrap', - /** Sets what the ListView should render when there is no content to display. */ - renderEmptyState?: () => JSX.Element, - /** - * Handler that is called when a user performs an action on an item. The exact user event depends on - * the collection's `selectionBehavior` prop and the interaction modality. - */ - onAction?: (key: string) => void, - /** - * The drag hooks returned by `useDragHooks` used to enable drag and drop behavior for the ListView. See the - * [docs](https://react-spectrum.adobe.com/react-spectrum/useDragHooks.html) for more info. - */ - dragHooks?: DragHooks, - dropHooks?: DropHooks -} - -function ListView(props: ListViewProps, ref: DOMRef) { +function ListView(props: SpectrumListProps, ref: DOMRef) { let { density = 'regular', onLoadMore, @@ -145,45 +106,25 @@ function ListView(props: ListViewProps, ref: DOMRef new GridCollection({ - columnCount: 1, - items: [...collection].map(item => ({ - ...item, - hasChildNodes: true, - childNodes: [{ - key: `cell-${item.key}`, - type: 'cell', - index: 0, - value: null, - level: 0, - rendered: null, - textValue: item.textValue, - hasChildNodes: false, - childNodes: [] - }] - })) - }), [collection]); - let state = useGridState({ - ...props, - collection: gridCollection, - focusMode: 'row', - selectionBehavior: props.selectionStyle === 'highlight' ? 'replace' : 'toggle' - }); let layout = useListLayout(state, props.density || 'regular'); let provider = useProvider(); let dragState: DraggableCollectionState; if (isListDraggable) { dragState = dragHooks.useDraggableCollectionState({ - collection: state.collection, - selectionManager: state.selectionManager, + collection, + selectionManager, renderPreview(draggingKeys, draggedKey) { - let item = state.collection.getItem(draggedKey); + let item = collection.getItem(draggedKey); let itemCount = draggingKeys.size; let itemHeight = layout.getLayoutInfo(draggedKey).rect.height; return ; @@ -196,8 +137,8 @@ function ListView(props: ListViewProps, ref: DOMRef(props: ListViewProps, ref: DOMRef= r.y + 10 && y <= r.maxY - 10 && state.collection.getItem(closest.key).value.type === 'folder') { + // TODO: Figure out the typescript for this + // @ts-ignore + if (y >= r.y + 10 && y <= r.maxY - 10 && collection.getItem(closest.key).value.type === 'folder') { closestDir = 'on'; } } @@ -251,17 +194,17 @@ function ListView(props: ListViewProps, ref: DOMRef(props: ListViewProps, ref: DOMRef {(type, item) => { if (type === 'item') { return ( <> - {isListDroppable && state.collection.getKeyBefore(item.key) == null && + {isListDroppable && collection.getKeyBefore(item.key) == null && } {isListDroppable && @@ -330,7 +273,7 @@ function ListView(props: ListViewProps, ref: DOMRef + isPresentationOnly={collection.getKeyAfter(item.key) !== null} /> } ); @@ -339,7 +282,7 @@ function ListView(props: ListViewProps, ref: DOMRef 0 ? formatMessage('loadingMore') : formatMessage('loading')} /> + aria-label={collection.size > 0 ? formatMessage('loadingMore') : formatMessage('loading')} /> ); } else if (type === 'placeholder') { @@ -385,5 +328,5 @@ function CenteredWrapper({children}) { /** * Lists display a linear collection of data. They allow users to quickly scan, sort, compare, and take action on large amounts of data. */ -const _ListView = React.forwardRef(ListView) as (props: ListViewProps & {ref?: DOMRef}) => ReactElement; +const _ListView = React.forwardRef(ListView) as (props: SpectrumListProps & {ref?: DOMRef}) => ReactElement; export {_ListView as ListView}; diff --git a/packages/@react-spectrum/list/src/ListViewItem.tsx b/packages/@react-spectrum/list/src/ListViewItem.tsx index e7b2e31fbcb..5e567a5d590 100644 --- a/packages/@react-spectrum/list/src/ListViewItem.tsx +++ b/packages/@react-spectrum/list/src/ListViewItem.tsx @@ -19,24 +19,24 @@ import type {DraggableItemResult, DroppableItemResult} from '@react-aria/dnd'; import {DropTarget, Node} from '@react-types/shared'; import {FocusRing, useFocusRing} from '@react-aria/focus'; import {Grid} from '@react-spectrum/layout'; -import {isFocusVisible as isGlobalFocusVisible, useHover, usePress} from '@react-aria/interactions'; +import {isFocusVisible as isGlobalFocusVisible, useHover} from '@react-aria/interactions'; import ListGripper from '@spectrum-icons/ui/ListGripper'; import listStyles from './styles.css'; import {ListViewContext} from './ListView'; import {mergeProps} from '@react-aria/utils'; import React, {useContext, useRef} from 'react'; import {useButton} from '@react-aria/button'; -import {useGridCell, useGridSelectionCheckbox} from '@react-aria/grid'; +import {useListItem, useListSelectionCheckbox} from '@react-aria/list'; import {useLocale} from '@react-aria/i18n'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; -interface ListViewItemProps { - item: Node, +interface ListViewItemProps { + item: Node, isEmphasized: boolean, hasActions: boolean } -export function ListViewItem(props: ListViewItemProps) { +export function ListViewItem(props: ListViewItemProps) { let { item, isEmphasized, @@ -52,21 +52,16 @@ export function ListViewItem(props: ListViewItemProps) { let {isFocusVisible, focusProps} = useFocusRing(); let allowsInteraction = state.selectionManager.selectionMode !== 'none' || hasActions; let isDisabled = !allowsInteraction || state.disabledKeys.has(item.key); + let isSelected = state.selectionManager.isSelected(item.key); let isDroppable = isListDroppable && !isDisabled; let {hoverProps, isHovered} = useHover({isDisabled}); - let {pressProps, isPressed} = usePress({isDisabled}); - - // We only make use of useGridCell here to allow for keyboard navigation to the focusable children of the row. - // The actual grid cell of the ListView is inert since we don't want to ever focus it to decrease screenreader - // verbosity, so we pretend the row node is the cell for interaction purposes. useGridRow is never used since - // it would conflict with useGridCell if applied to the same node. - let {gridCellProps} = useGridCell({ + let {rowProps, gridCellProps, isPressed} = useListItem({ node: item, - focusMode: 'cell', isVirtualized: true, - shouldSelectOnPressUp: isListDraggable + shouldSelectOnPressUp: isListDraggable, + isDisabled }, state, rowRef); - delete gridCellProps['aria-colindex']; + let {checkboxProps} = useListSelectionCheckbox({key: item.key}, state); let draggableItem: DraggableItemResult; if (isListDraggable) { @@ -82,7 +77,6 @@ export function ListViewItem(props: ListViewItemProps) { droppableItem = dropHooks.useDroppableItem({target}, dropState, rowRef); } - let {checkboxProps} = useGridSelectionCheckbox({...props, key: item.key}, state); let dragButtonRef = React.useRef(); let {buttonProps} = useButton({ ...draggableItem?.dragButtonProps, @@ -114,19 +108,10 @@ export function ListViewItem(props: ListViewItemProps) { ); let showCheckbox = state.selectionManager.selectionMode !== 'none' && state.selectionManager.selectionBehavior === 'toggle'; - let isSelected = state.selectionManager.isSelected(item.key); let {visuallyHiddenProps} = useVisuallyHidden(); - let rowProps = { - role: 'row', - 'aria-label': item.textValue, - 'aria-selected': state.selectionManager.selectionMode !== 'none' ? isSelected : undefined, - 'aria-rowindex': item.index + 1 - }; const mergedProps = mergeProps( - gridCellProps, rowProps, - pressProps, draggableItem?.dragProps, isDroppable && droppableItem?.dropProps, hoverProps, @@ -191,8 +176,7 @@ export function ListViewItem(props: ListViewItemProps) { } ) } - role="gridcell" - aria-colindex={1}> + {...gridCellProps}> {isListDraggable &&
diff --git a/packages/@react-spectrum/list/stories/ListView.stories.tsx b/packages/@react-spectrum/list/stories/ListView.stories.tsx index c72def863aa..86ba5ce27ef 100644 --- a/packages/@react-spectrum/list/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/list/stories/ListView.stories.tsx @@ -419,7 +419,7 @@ storiesOf('ListView/Drag and Drop', module) function Example(props?) { return ( - + Utilities Adobe Photoshop @@ -630,7 +630,7 @@ export function ReorderExample() { } onDropAction(e); onMove(keys, e.target); - } + } }, getDropOperation(target) { if (target.type === 'root' || target.dropPosition === 'on') { @@ -640,7 +640,7 @@ export function ReorderExample() { return 'move'; } }); - + return ( ( {item.type === 'folder' ? 'Drop items here' : `Item ${item.textValue}`} - {item.type === 'folder' && + {item.type === 'folder' && <> contains {item.childNodes.length} dropped item(s) @@ -768,7 +768,7 @@ export function DragBetweenListsExample() { {id: '12', type: 'item', textValue: 'Twelve'} ] }); - + let onMove = (keys: React.Key[], target: ItemDropTarget) => { let sourceList = list1.getItem(keys[0]) ? list1 : list2; let destinationList = list1.getItem(target.key) ? list1 : list2; @@ -816,7 +816,7 @@ export function DragBetweenListsExample() { } onDropAction(e); onMove(keys, e.target); - } + } }, getDropOperation(target) { if (target.type === 'root' || target.dropPosition === 'on') { @@ -891,7 +891,7 @@ export function DragBetweenListsRootOnlyExample() { {id: '12', type: 'item', textValue: 'Twelve'} ] }); - + let onMove = (keys: React.Key[]) => { let sourceList = list1.getItem(keys[0]) ? list1 : list2; let destinationList = sourceList === list1 ? list2 : list1; @@ -932,7 +932,7 @@ export function DragBetweenListsRootOnlyExample() { } onDropAction(e); onMove(keys); - } + } }, getDropOperation(target, types) { if (target.type === 'root' && types.has('list2')) { @@ -956,7 +956,7 @@ export function DragBetweenListsRootOnlyExample() { } onDropAction(e); onMove(keys); - } + } }, getDropOperation(target, types) { if (target.type === 'root' && types.has('list1')) { diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 1ea024b5034..588ecae066e 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -9,8 +9,11 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ + +jest.mock('@react-aria/live-announcer'); import {act, fireEvent, render as renderComponent, within} from '@testing-library/react'; import {ActionButton} from '@react-spectrum/button'; +import {announce} from '@react-aria/live-announcer'; import {CUSTOM_DRAG_TYPE} from '@react-aria/dnd/src/constants'; import {DataTransfer, DataTransferItem, DragEvent} from '@react-aria/dnd/test/mocks'; import {DragExample} from '../stories/ListView.stories'; @@ -160,10 +163,14 @@ describe('ListView', function () { let rows = getAllByRole('row'); expect(rows).toHaveLength(3); + expect(rows[0]).toHaveAttribute('aria-rowindex', '1'); + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); let gridCells = within(rows[0]).getAllByRole('gridcell'); expect(gridCells).toHaveLength(1); expect(gridCells[0]).toHaveTextContent('Foo'); + expect(gridCells[0]).toHaveAttribute('aria-colindex', '1'); }); it('renders a dynamic listview', function () { @@ -188,10 +195,14 @@ describe('ListView', function () { let rows = getAllByRole('row'); expect(rows).toHaveLength(3); + expect(rows[0]).toHaveAttribute('aria-rowindex', '1'); + expect(rows[1]).toHaveAttribute('aria-rowindex', '2'); + expect(rows[2]).toHaveAttribute('aria-rowindex', '3'); let gridCells = within(rows[0]).getAllByRole('gridcell'); expect(gridCells).toHaveLength(1); expect(gridCells[0]).toHaveTextContent('Foo'); + expect(gridCells[0]).toHaveAttribute('aria-colindex', '1'); }); it('renders a falsy ids', function () { @@ -242,6 +253,15 @@ describe('ListView', function () { expect(getRow(tree, 'Baz')).toHaveAttribute('aria-label', 'Baz'); }); + it('should label the checkboxes with the row label', function () { + let tree = renderList({selectionMode: 'single'}); + let rows = tree.getAllByRole('row'); + for (let row of rows) { + let checkbox = within(row).getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-labelledby', `${checkbox.id} ${row.id}`); + } + }); + describe('keyboard focus', function () { describe('Type to select', function () { it('focuses the correct cell when typing', function () { @@ -522,7 +542,6 @@ describe('ListView', function () { }); describe('selection', function () { - installPointerEvent(); let items = [ {key: 'foo', label: 'Foo'}, {key: 'bar', label: 'Bar'}, @@ -531,13 +550,27 @@ describe('ListView', function () { let renderSelectionList = (props) => render( {item => ( - + {item.label} )} ); + it('should announce the selected or deselected row', function () { + let onSelectionChange = jest.fn(); + let tree = renderSelectionList({onSelectionChange, selectionMode: 'single'}); + + let row = tree.getAllByRole('row')[1]; + triggerPress(row); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); + + triggerPress(row); + expect(announce).toHaveBeenLastCalledWith('Bar not selected.'); + expect(announce).toHaveBeenCalledTimes(2); + }); + it('should select an item from checkbox', function () { let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); @@ -547,30 +580,38 @@ describe('ListView', function () { checkSelection(onSelectionChange, ['bar']); expect(row).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); }); it('should select a row by pressing the Space key on a row', function () { let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); - let row = tree.getAllByRole('row')[1]; + let row = tree.getAllByRole('row')[0]; + userEvent.tab(); expect(row).toHaveAttribute('aria-selected', 'false'); fireEvent.keyDown(row, {key: ' '}); fireEvent.keyUp(row, {key: ' '}); - checkSelection(onSelectionChange, ['bar']); + checkSelection(onSelectionChange, ['foo']); expect(row).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); }); it('should select a row by pressing the Enter key on a row', function () { let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); - let row = tree.getAllByRole('row')[1]; + let row = tree.getAllByRole('row')[0]; + userEvent.tab(); expect(row).toHaveAttribute('aria-selected', 'false'); fireEvent.keyDown(row, {key: 'Enter'}); fireEvent.keyUp(row, {key: 'Enter'}); - checkSelection(onSelectionChange, ['bar']); + checkSelection(onSelectionChange, ['foo']); expect(row).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); }); it('should only allow one item to be selected in single selection', function () { @@ -582,6 +623,8 @@ describe('ListView', function () { checkSelection(onSelectionChange, ['bar']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); onSelectionChange.mockClear(); act(() => userEvent.click(within(rows[2]).getByRole('checkbox'))); @@ -599,15 +642,68 @@ describe('ListView', function () { checkSelection(onSelectionChange, ['bar']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); onSelectionChange.mockClear(); act(() => userEvent.click(within(rows[2]).getByRole('checkbox'))); checkSelection(onSelectionChange, ['bar', 'baz']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Baz selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + act(() => userEvent.click(within(rows[2]).getByRole('checkbox'))); + expect(announce).toHaveBeenLastCalledWith('Baz not selected. 1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); + }); + + it('should support range selection', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); + + let rows = tree.getAllByRole('row'); + triggerPress(rows[0]); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + triggerPress(rows[2], {shiftKey: true}); + checkSelection(onSelectionChange, ['foo', 'bar', 'baz']); + onSelectionChange.mockClear(); + expect(announce).toHaveBeenLastCalledWith('3 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + triggerPress(rows[0], {shiftKey: true}); + checkSelection(onSelectionChange, ['foo']); + expect(announce).toHaveBeenLastCalledWith('1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); + }); + + it('should support select all and clear all via keyboard', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple'}); + + let rows = tree.getAllByRole('row'); + triggerPress(rows[0]); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(rows[0], {key: 'a', ctrlKey: true}); + fireEvent.keyUp(rows[0], {key: 'a', ctrlKey: true}); + checkSelection(onSelectionChange, 'all'); + onSelectionChange.mockClear(); + expect(announce).toHaveBeenLastCalledWith('All items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + fireEvent.keyDown(rows[0], {key: 'Escape'}); + fireEvent.keyUp(rows[0], {key: 'Escape'}); + checkSelection(onSelectionChange, []); + onSelectionChange.mockClear(); + expect(announce).toHaveBeenLastCalledWith('No items selected.'); + expect(announce).toHaveBeenCalledTimes(3); }); describe('onAction', function () { + installPointerEvent(); it('should trigger onAction when clicking items with the mouse', function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); @@ -741,143 +837,348 @@ describe('ListView', function () { }); }); - it('should toggle items in selection highlight with ctrl-click on Mac', function () { - let uaMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac'); - let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); + describe('selectionStyle highlight', function () { + installPointerEvent(); + it('should toggle items in selection highlight with ctrl-click on Mac', function () { + let uaMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac'); + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); - let rows = tree.getAllByRole('row'); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true})); + let rows = tree.getAllByRole('row'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true})); - checkSelection(onSelectionChange, ['bar']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + checkSelection(onSelectionChange, ['bar']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); - onSelectionChange.mockClear(); - act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true})); - checkSelection(onSelectionChange, ['baz']); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + onSelectionChange.mockClear(); + act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true})); + checkSelection(onSelectionChange, ['baz']); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Baz selected.'); + expect(announce).toHaveBeenCalledTimes(2); - uaMock.mockRestore(); - }); + uaMock.mockRestore(); + }); - it('should allow multiple items to be selected in selection highlight with ctrl-click on Windows', function () { - let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows'); - let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); + it('should allow multiple items to be selected in selection highlight with ctrl-click on Windows', function () { + let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows'); + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); - let rows = tree.getAllByRole('row'); - expect(rows[0]).toHaveAttribute('aria-selected', 'false'); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getRow(tree, 'Foo'), {pointerType: 'mouse', ctrlKey: true})); + let rows = tree.getAllByRole('row'); + expect(rows[0]).toHaveAttribute('aria-selected', 'false'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + act(() => userEvent.click(getRow(tree, 'Foo'), {pointerType: 'mouse', ctrlKey: true})); + + checkSelection(onSelectionChange, ['foo']); + expect(rows[0]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + + onSelectionChange.mockClear(); + act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true})); + checkSelection(onSelectionChange, ['foo', 'baz']); + expect(rows[0]).toHaveAttribute('aria-selected', 'true'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Baz selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + uaMock.mockRestore(); + }); - checkSelection(onSelectionChange, ['foo']); - expect(rows[0]).toHaveAttribute('aria-selected', 'true'); + it('should toggle items in selection highlight with meta-click on Windows', function () { + let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows'); + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); - onSelectionChange.mockClear(); - act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', ctrlKey: true})); - checkSelection(onSelectionChange, ['foo', 'baz']); - expect(rows[0]).toHaveAttribute('aria-selected', 'true'); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + let rows = tree.getAllByRole('row'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', metaKey: true})); - uaMock.mockRestore(); - }); + checkSelection(onSelectionChange, ['bar']); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); - it('should toggle items in selection highlight with meta-click on Windows', function () { - let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Windows'); - let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); + onSelectionChange.mockClear(); + act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', metaKey: true})); + checkSelection(onSelectionChange, ['baz']); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Baz selected.'); + expect(announce).toHaveBeenCalledTimes(2); - let rows = tree.getAllByRole('row'); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', metaKey: true})); + uaMock.mockRestore(); + }); - checkSelection(onSelectionChange, ['bar']); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + it('should support single tap to perform row selection with screen reader if onAction isn\'t provided', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); - onSelectionChange.mockClear(); - act(() => userEvent.click(getRow(tree, 'Baz'), {pointerType: 'mouse', metaKey: true})); - checkSelection(onSelectionChange, ['baz']); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + let rows = tree.getAllByRole('row'); + expect(rows[1]).toHaveAttribute('aria-selected', 'false'); - uaMock.mockRestore(); - }); + act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0})); + checkSelection(onSelectionChange, [ + 'bar' + ]); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); + onSelectionChange.mockClear(); + + // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest + expect(rows[2]).toHaveAttribute('aria-selected', 'false'); + act(() => { + let el = within(rows[2]).getByText('Baz'); + fireEvent(el, pointerEvent('pointerdown', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); + fireEvent(el, pointerEvent('pointerup', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); + fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + }); + checkSelection(onSelectionChange, [ + 'bar', 'baz' + ]); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + expect(rows[2]).toHaveAttribute('aria-selected', 'true'); + expect(announce).toHaveBeenLastCalledWith('Baz selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + }); - it('should support single tap to perform row selection with screen reader if onAction isn\'t provided', function () { - let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight'}); + it('should support single tap to perform onAction with screen reader', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction}); - let rows = tree.getAllByRole('row'); - expect(rows[1]).toHaveAttribute('aria-selected', 'false'); + let rows = tree.getAllByRole('row'); + act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0})); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('bar'); + + // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest + act(() => { + let el = within(rows[2]).getByText('Baz'); + fireEvent(el, pointerEvent('pointerdown', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); + fireEvent(el, pointerEvent('pointerup', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); + fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + }); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(2); + expect(onAction).toHaveBeenCalledWith('baz'); + expect(announce).not.toHaveBeenCalled(); + }); - act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0})); - checkSelection(onSelectionChange, [ - 'bar' - ]); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - onSelectionChange.mockReset(); + it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction}); - // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest - expect(rows[2]).toHaveAttribute('aria-selected', 'false'); - act(() => { - let el = within(rows[2]).getByText('Baz'); - fireEvent(el, pointerEvent('pointerdown', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent(el, pointerEvent('pointerup', {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + let row = tree.getAllByRole('row')[1]; + expect(row).toHaveAttribute('aria-selected', 'false'); + act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true})); + + checkSelection(onSelectionChange, ['bar']); + expect(row).toHaveAttribute('aria-selected', 'true'); + expect(onAction).toHaveBeenCalledTimes(0); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(row, {key: 'Space'}); + fireEvent.keyUp(row, {key: 'Space'}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledTimes(0); + expect(announce).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(row, {key: 'Enter'}); + fireEvent.keyUp(row, {key: 'Enter'}); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('bar'); + expect(announce).toHaveBeenCalledTimes(1); }); - checkSelection(onSelectionChange, [ - 'bar', 'baz' - ]); - expect(rows[1]).toHaveAttribute('aria-selected', 'true'); - expect(rows[2]).toHaveAttribute('aria-selected', 'true'); - }); - it('should support single tap to perform onAction with screen reader', function () { - let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction}); + it('should perform onAction on single click with selectionMode: none', function () { + let tree = renderSelectionList({onSelectionChange, selectionMode: 'none', selectionStyle: 'highlight', onAction}); - let rows = tree.getAllByRole('row'); - act(() => userEvent.click(within(rows[1]).getByText('Bar'), {pointerType: 'touch', width: 0, height: 0})); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('bar'); + let rows = tree.getAllByRole('row'); + userEvent.click(rows[0], {pointerType: 'mouse'}); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onAction).toHaveBeenCalledWith('foo'); + }); - // Android TalkBack double tap test, pointer event sets pointerType and onClick handles the rest - act(() => { - let el = within(rows[2]).getByText('Baz'); - fireEvent(el, pointerEvent('pointerdown', {pointerType: 'touch', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent(el, pointerEvent('pointerup', {pointerType: 'touch', pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); - fireEvent.click(el, {pointerType: 'touch', width: 1, height: 1, detail: 1}); + it('should move selection when using the arrow keys', function () { + let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); + + let rows = tree.getAllByRole('row'); + userEvent.click(rows[0], {pointerType: 'mouse'}); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + expect(announce).toHaveBeenLastCalledWith('Bar selected.'); + expect(announce).toHaveBeenCalledTimes(2); + checkSelection(onSelectionChange, ['bar']); + onSelectionChange.mockClear(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(3); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(4); + checkSelection(onSelectionChange, ['foo', 'bar']); }); - expect(onSelectionChange).not.toHaveBeenCalled(); - expect(onAction).toHaveBeenCalledTimes(2); - expect(onAction).toHaveBeenCalledWith('baz'); - }); - it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', function () { - let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', selectionStyle: 'highlight', onAction}); + it('should announce the new row when moving with the keyboard after multi select', function () { + let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); - let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => userEvent.click(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true})); + let rows = tree.getAllByRole('row'); + userEvent.click(rows[0], {pointerType: 'mouse'}); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + checkSelection(onSelectionChange, ['foo', 'bar']); + onSelectionChange.mockClear(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + expect(announce).toHaveBeenLastCalledWith('Baz selected. 1 item selected.'); + checkSelection(onSelectionChange, ['baz']); + }); - checkSelection(onSelectionChange, ['bar']); - expect(row).toHaveAttribute('aria-selected', 'true'); - expect(onAction).toHaveBeenCalledTimes(0); + it('should support non-contiguous selection with the keyboard', function () { + let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', selectionMode: 'multiple'}); - fireEvent.keyDown(row, {key: 'Space'}); - fireEvent.keyUp(row, {key: 'Space'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(0); + let rows = tree.getAllByRole('row'); + userEvent.click(rows[0], {pointerType: 'mouse'}); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + expect(announce).toHaveBeenCalledTimes(1); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(getRow(tree, 'Bar')); - fireEvent.keyDown(row, {key: 'Enter'}); - fireEvent.keyUp(row, {key: 'Enter'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledWith('bar'); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + expect(announce).toHaveBeenCalledTimes(1); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(getRow(tree, 'Baz')); + + fireEvent.keyDown(document.activeElement, {key: ' ', ctrlKey: true}); + fireEvent.keyUp(document.activeElement, {key: ' ', ctrlKey: true}); + expect(announce).toHaveBeenCalledWith('Baz selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + checkSelection(onSelectionChange, ['foo', 'baz']); + onSelectionChange.mockClear(); + + fireEvent.keyDown(document.activeElement, {key: ' '}); + fireEvent.keyUp(document.activeElement, {key: ' '}); + expect(announce).toHaveBeenCalledWith('Baz selected. 1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); + checkSelection(onSelectionChange, ['baz']); + }); + + it('should announce the current selection when moving from all to one item', function () { + let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', onAction, selectionMode: 'multiple'}); + + let rows = tree.getAllByRole('row'); + userEvent.click(rows[0], {pointerType: 'mouse'}); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(rows[0], {key: 'a', ctrlKey: true}); + fireEvent.keyUp(rows[0], {key: 'a', ctrlKey: true}); + checkSelection(onSelectionChange, 'all'); + onSelectionChange.mockClear(); + expect(announce).toHaveBeenLastCalledWith('All items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + expect(announce).toHaveBeenLastCalledWith('Bar selected. 1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); + checkSelection(onSelectionChange, ['bar']); + }); }); + describe('long press', () => { + installPointerEvent(); + beforeEach(() => { + window.ontouchstart = jest.fn(); + }); + + afterEach(() => { + delete window.ontouchstart; + }); + + it('should support long press to enter selection mode on touch', function () { + window.ontouchstart = jest.fn(); + let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', onAction, selectionMode: 'multiple'}); + let rows = tree.getAllByRole('row'); + userEvent.click(document.body); + + fireEvent.pointerDown(rows[0], {pointerType: 'touch'}); + let description = tree.getByText('Long press to enter selection mode.'); + expect(tree.getByRole('grid')).toHaveAttribute('aria-describedby', expect.stringContaining(description.id)); + expect(announce).not.toHaveBeenCalled(); + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + + act(() => jest.advanceTimersByTime(800)); + + expect(announce).toHaveBeenLastCalledWith('Foo selected.'); + expect(announce).toHaveBeenCalledTimes(1); + checkSelection(onSelectionChange, ['foo']); + onSelectionChange.mockClear(); + expect(onAction).not.toHaveBeenCalled(); + expect(within(rows[0]).getByRole('checkbox')).toBeTruthy(); + + fireEvent.pointerUp(rows[0], {pointerType: 'touch'}); + + userEvent.click(rows[1], {pointerType: 'touch'}); + expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.'); + expect(announce).toHaveBeenCalledTimes(2); + checkSelection(onSelectionChange, ['foo', 'bar']); + onSelectionChange.mockClear(); + + // Deselect all to exit selection mode + userEvent.click(rows[0], {pointerType: 'touch'}); + expect(announce).toHaveBeenLastCalledWith('Foo not selected. 1 item selected.'); + expect(announce).toHaveBeenCalledTimes(3); + checkSelection(onSelectionChange, ['bar']); + onSelectionChange.mockClear(); + userEvent.click(rows[1], {pointerType: 'touch'}); + expect(announce).toHaveBeenLastCalledWith('Bar not selected.'); + expect(announce).toHaveBeenCalledTimes(4); + + act(() => jest.runAllTimers()); + checkSelection(onSelectionChange, []); + expect(onAction).not.toHaveBeenCalled(); + expect(within(rows[0]).queryByRole('checkbox')).toBeNull(); + }); + }); }); describe('scrolling', function () { @@ -1294,7 +1595,6 @@ describe('ListView', function () { }); it('should make row selection happen on pressUp if list is draggable', function () { - let {getAllByRole} = render( ); diff --git a/packages/@react-types/list/README.md b/packages/@react-types/list/README.md new file mode 100644 index 00000000000..82e22bad671 --- /dev/null +++ b/packages/@react-types/list/README.md @@ -0,0 +1,3 @@ +# @react-types/list + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-types/list/package.json b/packages/@react-types/list/package.json new file mode 100644 index 00000000000..c5c6038a6e5 --- /dev/null +++ b/packages/@react-types/list/package.json @@ -0,0 +1,21 @@ +{ + "name": "@react-types/list", + "version": "3.0.0-alpha.1", + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "types": "src/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@react-spectrum/dnd": "3.0.0-alpha.2", + "@react-types/shared": "^3.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-types/list/src/index.d.ts b/packages/@react-types/list/src/index.d.ts new file mode 100644 index 00000000000..4a7dce96a2b --- /dev/null +++ b/packages/@react-types/list/src/index.d.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2022 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 { + AriaLabelingProps, + AsyncLoadable, + CollectionBase, + DOMProps, + LoadingState, + MultipleSelection, + SpectrumSelectionProps, + StyleProps +} from '@react-types/shared'; +import {DragHooks, DropHooks} from '@react-spectrum/dnd'; + +export interface ListProps extends CollectionBase, MultipleSelection { + /** + * Handler that is called when a user performs an action on an item. The exact user event depends on + * the collection's `selectionBehavior` prop and the interaction modality. + */ + onAction?: (key: string) => void +} + +export interface AriaListProps extends ListProps, DOMProps, AriaLabelingProps {} + +export interface SpectrumListProps extends AriaListProps, StyleProps, SpectrumSelectionProps, Omit { + /** + * Sets the amount of vertical padding within each cell. + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious', + /** Whether the ListView should be displayed with a quiet style. */ + isQuiet?: boolean, + /** The current loading state of the ListView. Determines whether or not the progress circle should be shown. */ + loadingState?: LoadingState, + /** + * Sets the text behavior for the row contents. + * @default 'truncate' + */ + overflowMode?: 'truncate' | 'wrap', + /** Sets what the ListView should render when there is no content to display. */ + renderEmptyState?: () => JSX.Element, + /** + * Handler that is called when a user performs an action on an item. The exact user event depends on + * the collection's `selectionBehavior` prop and the interaction modality. + */ + onAction?: (key: string) => void, + /** + * The drag hooks returned by `useDragHooks` used to enable drag and drop behavior for the ListView. See the + * [docs](https://react-spectrum.adobe.com/react-spectrum/useDragHooks.html) for more info. + */ + dragHooks?: DragHooks, + /** + * The drag hooks returned by `useDragHooks` used to enable drag and drop behavior for the ListView. + */ + dropHooks?: DropHooks +}