diff --git a/packages/@react-aria/collections/src/useCachedChildren.ts b/packages/@react-aria/collections/src/useCachedChildren.ts index fc0cfbc64d7..a04df47c8ca 100644 --- a/packages/@react-aria/collections/src/useCachedChildren.ts +++ b/packages/@react-aria/collections/src/useCachedChildren.ts @@ -50,7 +50,7 @@ export function useCachedChildren(props: CachedChildrenOptions throw new Error('Could not determine key for item'); } - if (idScope != null) { + if (idScope != null && rendered.props.id == null) { key = idScope + ':' + key; } // Note: only works if wrapped Item passes through id... diff --git a/packages/@react-aria/table/intl/ar-AE.json b/packages/@react-aria/table/intl/ar-AE.json index a38b3bbd601..0c55642bac5 100644 --- a/packages/@react-aria/table/intl/ar-AE.json +++ b/packages/@react-aria/table/intl/ar-AE.json @@ -7,5 +7,7 @@ "resizerDescription": "اضغط على مفتاح Enter لبدء تغيير الحجم", "select": "تحديد", "selectAll": "تحديد الكل", - "sortable": "عمود قابل للترتيب" + "sortable": "عمود قابل للترتيب", + "collapse": "طي", + "expand": "تمديد" } diff --git a/packages/@react-aria/table/intl/bg-BG.json b/packages/@react-aria/table/intl/bg-BG.json index a587dbec6b2..226de351750 100644 --- a/packages/@react-aria/table/intl/bg-BG.json +++ b/packages/@react-aria/table/intl/bg-BG.json @@ -7,5 +7,7 @@ "resizerDescription": "Натиснете „Enter“, за да започнете да преоразмерявате", "select": "Изберете", "selectAll": "Изберете всичко", - "sortable": "сортираща колона" + "sortable": "сортираща колона", + "collapse": "Свиване", + "expand": "Разширяване" } diff --git a/packages/@react-aria/table/intl/cs-CZ.json b/packages/@react-aria/table/intl/cs-CZ.json index e52b3b7262b..9c03fb8b53e 100644 --- a/packages/@react-aria/table/intl/cs-CZ.json +++ b/packages/@react-aria/table/intl/cs-CZ.json @@ -7,5 +7,7 @@ "resizerDescription": "Stisknutím klávesy Enter začnete měnit velikost", "select": "Vybrat", "selectAll": "Vybrat vše", - "sortable": "sloupec s možností řazení" + "sortable": "sloupec s možností řazení", + "collapse": "Sbalit", + "expand": "Roztáhnout" } diff --git a/packages/@react-aria/table/intl/da-DK.json b/packages/@react-aria/table/intl/da-DK.json index 423364edd2f..002f179f54a 100644 --- a/packages/@react-aria/table/intl/da-DK.json +++ b/packages/@react-aria/table/intl/da-DK.json @@ -7,5 +7,7 @@ "resizerDescription": "Tryk på Enter for at ændre størrelse", "select": "Vælg", "selectAll": "Vælg alle", - "sortable": "sorterbar kolonne" + "sortable": "sorterbar kolonne", + "collapse": "Skjul", + "expand": "Udvid" } diff --git a/packages/@react-aria/table/intl/de-DE.json b/packages/@react-aria/table/intl/de-DE.json index 7a43db019c2..b5c717dbdbe 100644 --- a/packages/@react-aria/table/intl/de-DE.json +++ b/packages/@react-aria/table/intl/de-DE.json @@ -7,5 +7,7 @@ "resizerDescription": "Eingabetaste zum Starten der Größenänderung drücken", "select": "Auswählen", "selectAll": "Alles auswählen", - "sortable": "sortierbare Spalte" + "sortable": "sortierbare Spalte", + "collapse": "Reduzieren", + "expand": "Erweitern" } diff --git a/packages/@react-aria/table/intl/el-GR.json b/packages/@react-aria/table/intl/el-GR.json index 40ac6ca0774..ddc0619eaaa 100644 --- a/packages/@react-aria/table/intl/el-GR.json +++ b/packages/@react-aria/table/intl/el-GR.json @@ -7,5 +7,7 @@ "resizerDescription": "Πατήστε Enter για έναρξη της αλλαγής μεγέθους", "select": "Επιλογή", "selectAll": "Επιλογή όλων", - "sortable": "Στήλη διαλογής" + "sortable": "Στήλη διαλογής", + "collapse": "Σύμπτυξη", + "expand": "Ανάπτυξη" } diff --git a/packages/@react-aria/table/intl/en-US.json b/packages/@react-aria/table/intl/en-US.json index 78a7a208161..1b8d1588c75 100644 --- a/packages/@react-aria/table/intl/en-US.json +++ b/packages/@react-aria/table/intl/en-US.json @@ -7,5 +7,7 @@ "ascendingSort": "sorted by column {columnName} in ascending order", "descendingSort": "sorted by column {columnName} in descending order", "columnSize": "{value} pixels", - "resizerDescription": "Press Enter to start resizing" + "resizerDescription": "Press Enter to start resizing", + "expand": "Expand", + "collapse": "Collapse" } diff --git a/packages/@react-aria/table/intl/es-ES.json b/packages/@react-aria/table/intl/es-ES.json index 632b0f551a7..46f97de7077 100644 --- a/packages/@react-aria/table/intl/es-ES.json +++ b/packages/@react-aria/table/intl/es-ES.json @@ -7,5 +7,7 @@ "resizerDescription": "Pulse Intro para empezar a redimensionar", "select": "Seleccionar", "selectAll": "Seleccionar todos", - "sortable": "columna ordenable" + "sortable": "columna ordenable", + "collapse": "Contraer", + "expand": "Ampliar" } diff --git a/packages/@react-aria/table/intl/et-EE.json b/packages/@react-aria/table/intl/et-EE.json index 79663675162..2a25f5d5e6a 100644 --- a/packages/@react-aria/table/intl/et-EE.json +++ b/packages/@react-aria/table/intl/et-EE.json @@ -7,5 +7,7 @@ "resizerDescription": "Suuruse muutmise alustamiseks vajutage klahvi Enter", "select": "Vali", "selectAll": "Vali kõik", - "sortable": "sorditav veerg" + "sortable": "sorditav veerg", + "collapse": "Ahenda", + "expand": "Laienda" } diff --git a/packages/@react-aria/table/intl/fi-FI.json b/packages/@react-aria/table/intl/fi-FI.json index e0e0b3d3983..b71824d5cd2 100644 --- a/packages/@react-aria/table/intl/fi-FI.json +++ b/packages/@react-aria/table/intl/fi-FI.json @@ -7,5 +7,7 @@ "resizerDescription": "Aloita koon muutos painamalla Enter-näppäintä", "select": "Valitse", "selectAll": "Valitse kaikki", - "sortable": "lajiteltava sarake" + "sortable": "lajiteltava sarake", + "collapse": "Pienennä", + "expand": "Laajenna" } diff --git a/packages/@react-aria/table/intl/fr-FR.json b/packages/@react-aria/table/intl/fr-FR.json index aba98517700..39073fb5f28 100644 --- a/packages/@react-aria/table/intl/fr-FR.json +++ b/packages/@react-aria/table/intl/fr-FR.json @@ -7,5 +7,7 @@ "resizerDescription": "Appuyez sur Entrée pour commencer le redimensionnement.", "select": "Sélectionner", "selectAll": "Sélectionner tout", - "sortable": "colonne triable" + "sortable": "colonne triable", + "collapse": "Réduire", + "expand": "Développer" } diff --git a/packages/@react-aria/table/intl/he-IL.json b/packages/@react-aria/table/intl/he-IL.json index b90c1bb7fe3..37b0bf89038 100644 --- a/packages/@react-aria/table/intl/he-IL.json +++ b/packages/@react-aria/table/intl/he-IL.json @@ -7,5 +7,7 @@ "resizerDescription": "הקש Enter כדי לשנות את הגודל", "select": "בחר", "selectAll": "בחר הכול", - "sortable": "עמודה שניתן למיין" + "sortable": "עמודה שניתן למיין", + "collapse": "כווץ", + "expand": "הרחב" } diff --git a/packages/@react-aria/table/intl/hr-HR.json b/packages/@react-aria/table/intl/hr-HR.json index b32e31e153e..14f9fea57e6 100644 --- a/packages/@react-aria/table/intl/hr-HR.json +++ b/packages/@react-aria/table/intl/hr-HR.json @@ -7,5 +7,7 @@ "resizerDescription": "Pritisnite Enter da biste započeli promenu veličine", "select": "Odaberite", "selectAll": "Odaberite sve", - "sortable": "stupac koji se može razvrstati" + "sortable": "stupac koji se može razvrstati", + "collapse": "Sažmi", + "expand": "Proširi" } diff --git a/packages/@react-aria/table/intl/hu-HU.json b/packages/@react-aria/table/intl/hu-HU.json index c378e798c15..264461798d5 100644 --- a/packages/@react-aria/table/intl/hu-HU.json +++ b/packages/@react-aria/table/intl/hu-HU.json @@ -7,5 +7,7 @@ "resizerDescription": "Nyomja le az Enter billentyűt az átméretezés megkezdéséhez", "select": "Kijelölés", "selectAll": "Összes kijelölése", - "sortable": "rendezendő oszlop" + "sortable": "rendezendő oszlop", + "collapse": "Összecsukás", + "expand": "Kibontás" } diff --git a/packages/@react-aria/table/intl/it-IT.json b/packages/@react-aria/table/intl/it-IT.json index 250273ac490..13b6e156e44 100644 --- a/packages/@react-aria/table/intl/it-IT.json +++ b/packages/@react-aria/table/intl/it-IT.json @@ -7,5 +7,7 @@ "resizerDescription": "Premi Invio per iniziare a ridimensionare", "select": "Seleziona", "selectAll": "Seleziona tutto", - "sortable": "colonna ordinabile" + "sortable": "colonna ordinabile", + "collapse": "Comprimi", + "expand": "Espandi" } diff --git a/packages/@react-aria/table/intl/ja-JP.json b/packages/@react-aria/table/intl/ja-JP.json index 58824e126ef..ce926f9dc72 100644 --- a/packages/@react-aria/table/intl/ja-JP.json +++ b/packages/@react-aria/table/intl/ja-JP.json @@ -7,5 +7,7 @@ "resizerDescription": "Enter キーを押してサイズ変更を開始", "select": "選択", "selectAll": "すべて選択", - "sortable": "並べ替え可能な列" + "sortable": "並べ替え可能な列", + "collapse": "折りたたむ", + "expand": "展開" } diff --git a/packages/@react-aria/table/intl/ko-KR.json b/packages/@react-aria/table/intl/ko-KR.json index 6631abd8e80..88244dca6e8 100644 --- a/packages/@react-aria/table/intl/ko-KR.json +++ b/packages/@react-aria/table/intl/ko-KR.json @@ -7,5 +7,7 @@ "resizerDescription": "크기 조정을 시작하려면 Enter를 누르세요.", "select": "선택", "selectAll": "모두 선택", - "sortable": "정렬 가능한 열" + "sortable": "정렬 가능한 열", + "collapse": "접기", + "expand": "펼치기" } diff --git a/packages/@react-aria/table/intl/lt-LT.json b/packages/@react-aria/table/intl/lt-LT.json index e13be0f81c2..e7db5764765 100644 --- a/packages/@react-aria/table/intl/lt-LT.json +++ b/packages/@react-aria/table/intl/lt-LT.json @@ -7,5 +7,7 @@ "resizerDescription": "Paspauskite „Enter“, kad pradėtumėte keisti dydį", "select": "Pasirinkti", "selectAll": "Pasirinkti viską", - "sortable": "rikiuojamas stulpelis" + "sortable": "rikiuojamas stulpelis", + "collapse": "Sutraukti", + "expand": "Išskleisti" } diff --git a/packages/@react-aria/table/intl/lv-LV.json b/packages/@react-aria/table/intl/lv-LV.json index 8ce77f98941..b5530c746c0 100644 --- a/packages/@react-aria/table/intl/lv-LV.json +++ b/packages/@react-aria/table/intl/lv-LV.json @@ -7,5 +7,7 @@ "resizerDescription": "Nospiediet Enter, lai sāktu izmēru mainīšanu", "select": "Atlasīt", "selectAll": "Atlasīt visu", - "sortable": "kārtojamā kolonna" + "sortable": "kārtojamā kolonna", + "collapse": "Sakļaut", + "expand": "Izvērst" } diff --git a/packages/@react-aria/table/intl/nb-NO.json b/packages/@react-aria/table/intl/nb-NO.json index 6585dff50e1..4d99e8320e4 100644 --- a/packages/@react-aria/table/intl/nb-NO.json +++ b/packages/@react-aria/table/intl/nb-NO.json @@ -7,5 +7,7 @@ "resizerDescription": "Trykk på Enter for å starte størrelsesendring", "select": "Velg", "selectAll": "Velg alle", - "sortable": "kolonne som kan sorteres" + "sortable": "kolonne som kan sorteres", + "collapse": "Skjul", + "expand": "Utvid" } diff --git a/packages/@react-aria/table/intl/nl-NL.json b/packages/@react-aria/table/intl/nl-NL.json index b54364b0549..9abbf954578 100644 --- a/packages/@react-aria/table/intl/nl-NL.json +++ b/packages/@react-aria/table/intl/nl-NL.json @@ -7,5 +7,7 @@ "resizerDescription": "Druk op Enter om het formaat te wijzigen", "select": "Selecteren", "selectAll": "Alles selecteren", - "sortable": "sorteerbare kolom" + "sortable": "sorteerbare kolom", + "collapse": "Samenvouwen", + "expand": "Uitvouwen" } diff --git a/packages/@react-aria/table/intl/pl-PL.json b/packages/@react-aria/table/intl/pl-PL.json index 634bdef9eba..8fa1aca043f 100644 --- a/packages/@react-aria/table/intl/pl-PL.json +++ b/packages/@react-aria/table/intl/pl-PL.json @@ -7,5 +7,7 @@ "resizerDescription": "Naciśnij Enter, aby rozpocząć zmienianie rozmiaru", "select": "Zaznacz", "selectAll": "Zaznacz wszystko", - "sortable": "kolumna z możliwością sortowania" + "sortable": "kolumna z możliwością sortowania", + "collapse": "Zwiń", + "expand": "Rozwiń" } diff --git a/packages/@react-aria/table/intl/pt-BR.json b/packages/@react-aria/table/intl/pt-BR.json index deba085c7ce..a47e8df5951 100644 --- a/packages/@react-aria/table/intl/pt-BR.json +++ b/packages/@react-aria/table/intl/pt-BR.json @@ -7,5 +7,7 @@ "resizerDescription": "Pressione Enter para começar a redimensionar", "select": "Selecionar", "selectAll": "Selecionar tudo", - "sortable": "coluna classificável" + "sortable": "coluna classificável", + "collapse": "Recolher", + "expand": "Expandir" } diff --git a/packages/@react-aria/table/intl/pt-PT.json b/packages/@react-aria/table/intl/pt-PT.json index 1092b1aac16..b82e356ac23 100644 --- a/packages/@react-aria/table/intl/pt-PT.json +++ b/packages/@react-aria/table/intl/pt-PT.json @@ -7,5 +7,7 @@ "resizerDescription": "Prima Enter para iniciar o redimensionamento", "select": "Selecionar", "selectAll": "Selecionar tudo", - "sortable": "Coluna ordenável" + "sortable": "Coluna ordenável", + "collapse": "Colapsar", + "expand": "Expandir" } diff --git a/packages/@react-aria/table/intl/ro-RO.json b/packages/@react-aria/table/intl/ro-RO.json index 326df865f0f..8070e6cbd84 100644 --- a/packages/@react-aria/table/intl/ro-RO.json +++ b/packages/@react-aria/table/intl/ro-RO.json @@ -7,5 +7,7 @@ "resizerDescription": "Apăsați pe Enter pentru a începe redimensionarea", "select": "Selectare", "selectAll": "Selectare totală", - "sortable": "coloană sortabilă" + "sortable": "coloană sortabilă", + "collapse": "Restrângeți", + "expand": "Extindeți" } diff --git a/packages/@react-aria/table/intl/ru-RU.json b/packages/@react-aria/table/intl/ru-RU.json index 93c881aa56d..d3fedf3062b 100644 --- a/packages/@react-aria/table/intl/ru-RU.json +++ b/packages/@react-aria/table/intl/ru-RU.json @@ -7,5 +7,7 @@ "resizerDescription": "Нажмите клавишу Enter для начала изменения размеров", "select": "Выбрать", "selectAll": "Выбрать все", - "sortable": "сортируемый столбец" + "sortable": "сортируемый столбец", + "collapse": "Свернуть", + "expand": "Развернуть" } diff --git a/packages/@react-aria/table/intl/sk-SK.json b/packages/@react-aria/table/intl/sk-SK.json index 91fd9ee2a5a..4438b14f8e9 100644 --- a/packages/@react-aria/table/intl/sk-SK.json +++ b/packages/@react-aria/table/intl/sk-SK.json @@ -7,5 +7,7 @@ "resizerDescription": "Stlačením klávesu Enter začnete zmenu veľkosti", "select": "Vybrať", "selectAll": "Vybrať všetko", - "sortable": "zoraditeľný stĺpec" + "sortable": "zoraditeľný stĺpec", + "collapse": "Zbaliť", + "expand": "Rozbaliť" } diff --git a/packages/@react-aria/table/intl/sl-SI.json b/packages/@react-aria/table/intl/sl-SI.json index 8eee7a33988..cc33eeffb9f 100644 --- a/packages/@react-aria/table/intl/sl-SI.json +++ b/packages/@react-aria/table/intl/sl-SI.json @@ -7,5 +7,7 @@ "resizerDescription": "Pritisnite tipko Enter da začnete spreminjati velikost", "select": "Izberite", "selectAll": "Izberite vse", - "sortable": "razvrstljivi stolpec" + "sortable": "razvrstljivi stolpec", + "collapse": "Strni", + "expand": "Razširi" } diff --git a/packages/@react-aria/table/intl/sr-SP.json b/packages/@react-aria/table/intl/sr-SP.json index 36a0eede6aa..6bcafd178b2 100644 --- a/packages/@react-aria/table/intl/sr-SP.json +++ b/packages/@react-aria/table/intl/sr-SP.json @@ -7,5 +7,7 @@ "resizerDescription": "Pritisnite Enter da biste započeli promenu veličine", "select": "Izaberite", "selectAll": "Izaberite sve", - "sortable": "kolona koja se može sortirati" + "sortable": "kolona koja se može sortirati", + "collapse": " Skupi", + "expand": "Proširi" } diff --git a/packages/@react-aria/table/intl/sv-SE.json b/packages/@react-aria/table/intl/sv-SE.json index 058e37b1b47..42934ef7b12 100644 --- a/packages/@react-aria/table/intl/sv-SE.json +++ b/packages/@react-aria/table/intl/sv-SE.json @@ -7,5 +7,7 @@ "resizerDescription": "Tryck på Retur för att börja ändra storlek", "select": "Markera", "selectAll": "Markera allt", - "sortable": "sorterbar kolumn" + "sortable": "sorterbar kolumn", + "collapse": "Dölj", + "expand": "Expandera" } diff --git a/packages/@react-aria/table/intl/tr-TR.json b/packages/@react-aria/table/intl/tr-TR.json index cd5f2ae109e..3eb690336d7 100644 --- a/packages/@react-aria/table/intl/tr-TR.json +++ b/packages/@react-aria/table/intl/tr-TR.json @@ -7,5 +7,7 @@ "resizerDescription": "Yeniden boyutlandırmak için Enter'a basın", "select": "Seç", "selectAll": "Tümünü Seç", - "sortable": "Sıralanabilir sütun" + "sortable": "Sıralanabilir sütun", + "collapse": "Daralt", + "expand": "Genişlet" } diff --git a/packages/@react-aria/table/intl/uk-UA.json b/packages/@react-aria/table/intl/uk-UA.json index 7a2f2e30383..24db6a98d37 100644 --- a/packages/@react-aria/table/intl/uk-UA.json +++ b/packages/@react-aria/table/intl/uk-UA.json @@ -7,5 +7,7 @@ "resizerDescription": "Натисніть Enter, щоб почати зміну розміру", "select": "Вибрати", "selectAll": "Вибрати все", - "sortable": "сортувальний стовпець" + "sortable": "сортувальний стовпець", + "collapse": "Згорнути", + "expand": "Розгорнути" } diff --git a/packages/@react-aria/table/intl/zh-CN.json b/packages/@react-aria/table/intl/zh-CN.json index 7a6db99b1b1..c35e8492517 100644 --- a/packages/@react-aria/table/intl/zh-CN.json +++ b/packages/@react-aria/table/intl/zh-CN.json @@ -7,5 +7,7 @@ "resizerDescription": "按“输入”键开始调整大小。", "select": "选择", "selectAll": "全选", - "sortable": "可排序的列" + "sortable": "可排序的列", + "collapse": "折叠", + "expand": "扩展" } diff --git a/packages/@react-aria/table/intl/zh-TW.json b/packages/@react-aria/table/intl/zh-TW.json index d4931fd36aa..02cf05f7793 100644 --- a/packages/@react-aria/table/intl/zh-TW.json +++ b/packages/@react-aria/table/intl/zh-TW.json @@ -7,5 +7,7 @@ "resizerDescription": "按 Enter 鍵以開始調整大小", "select": "選取", "selectAll": "全選", - "sortable": "可排序的欄" + "sortable": "可排序的欄", + "collapse": "收合", + "expand": "展開" } diff --git a/packages/@react-aria/table/package.json b/packages/@react-aria/table/package.json index 815984c094d..9aa6a6b9377 100644 --- a/packages/@react-aria/table/package.json +++ b/packages/@react-aria/table/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/button": "^3.14.5", "@react-aria/focus": "^3.21.5", "@react-aria/grid": "^3.14.8", "@react-aria/i18n": "^3.12.16", diff --git a/packages/@react-aria/table/src/useTable.ts b/packages/@react-aria/table/src/useTable.ts index 3ed31be82a7..115a532fc99 100644 --- a/packages/@react-aria/table/src/useTable.ts +++ b/packages/@react-aria/table/src/useTable.ts @@ -18,7 +18,6 @@ import intlMessages from '../intl/*.json'; import {Key, LayoutDelegate, Rect, RefObject, Size} from '@react-types/shared'; import {mergeProps, useDescription, useId, useUpdateEffect} from '@react-aria/utils'; import {TableKeyboardDelegate} from './TableKeyboardDelegate'; -import {tableNestedRows} from '@react-stately/flags'; import {TableState, TreeGridState} from '@react-stately/table'; import {useCollator, useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useMemo} from 'react'; @@ -76,7 +75,7 @@ export function useTable(props: AriaTableProps, state: TableState | TreeGr layout }), [keyboardDelegate, state.collection, state.disabledKeys, disabledBehavior, ref, direction, collator, layoutDelegate, layout]); let id = useId(props.id); - gridIds.set(state, id); + gridIds.set(state as TableState, id); let {gridProps} = useGrid({ ...props, @@ -89,7 +88,7 @@ export function useTable(props: AriaTableProps, state: TableState | TreeGr gridProps['aria-rowcount'] = state.collection.size + state.collection.headerRows.length; } - if (tableNestedRows() && 'expandedKeys' in state) { + if (state.treeColumn != null) { gridProps.role = 'treegrid'; } diff --git a/packages/@react-aria/table/src/useTableHeaderRow.ts b/packages/@react-aria/table/src/useTableHeaderRow.ts index bac939ea05f..71b3bc90aef 100644 --- a/packages/@react-aria/table/src/useTableHeaderRow.ts +++ b/packages/@react-aria/table/src/useTableHeaderRow.ts @@ -12,7 +12,6 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {GridRowProps} from '@react-aria/grid'; -import {tableNestedRows} from '@react-stately/flags'; import {TableState} from '@react-stately/table'; export interface TableHeaderRowAria { @@ -32,7 +31,7 @@ export function useTableHeaderRow(props: GridRowProps, state: TableState(props: GridRowProps, state: TableState | TreeGridState, ref: RefObject): GridRowAria { +export function useTableRow(props: GridRowProps, state: TableState | TreeGridState, ref: RefObject): TableRowAria { let {node, isVirtualized} = props; - let {rowProps, ...states} = useGridRow, TableState>(props, state, ref); + let {rowProps, ...states} = useGridRow, TableState>(props, state as TableState, ref); let {direction} = useLocale(); - if (isVirtualized && !(tableNestedRows() && 'expandedKeys' in state)) { + if (isVirtualized && state.treeColumn == null) { rowProps['aria-rowindex'] = node.index + 1 + state.collection.headerRows.length; // aria-rowindex is 1 based } else { delete rowProps['aria-rowindex']; } + let isExpanded = state.treeColumn != null && (state.expandedKeys === 'all' || state.expandedKeys.has(node.key)); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/table'); + let labelProps = useLabels({ + 'aria-label': isExpanded ? stringFormatter.format('collapse') : stringFormatter.format('expand'), + 'aria-labelledby': getRowLabelledBy(state as TableState, node.key) + }); + let treeGridRowProps: HTMLAttributes = {}; - if (tableNestedRows() && 'expandedKeys' in state) { - let treeNode = state.keyMap.get(node.key); + let expandButtonProps: AriaButtonProps = {}; + if (state.treeColumn != null) { + let treeNode = state.collection.getItem(node.key); if (treeNode != null) { - let hasChildRows = treeNode.props?.UNSTABLE_childItems || treeNode.props?.children?.length > state.userColumnCount; + let lastChild = getLastChild(state.collection, node); + let hasChildRows = treeNode.props?.hasChildRows || treeNode.props?.UNSTABLE_childItems || lastChild?.type !== 'cell'; + let parent = state.collection.getItem(node.parentKey!)!; + let isParentBody = parent.type === 'tablebody' || parent.type === 'body'; + let lastSibling = getLastChild(state.collection, parent)!; + while (lastSibling && lastSibling.type !== 'item' && lastSibling.prevKey != null) { + lastSibling = state.collection.getItem(lastSibling.prevKey)!; + } + treeGridRowProps = { onKeyDown: (e) => { if ((e.key === EXPANSION_KEYS['expand'][direction]) && state.selectionManager.focusedKey === treeNode.key && hasChildRows && state.expandedKeys !== 'all' && !state.expandedKeys.has(treeNode.key)) { @@ -65,11 +85,25 @@ export function useTableRow(props: GridRowProps, state: TableState | Tr } }, 'aria-expanded': hasChildRows ? state.expandedKeys === 'all' || state.expandedKeys.has(node.key) : undefined, - 'aria-level': treeNode.level, - 'aria-posinset': (treeNode.indexOfType ?? 0) + 1, - 'aria-setsize': treeNode.level > 1 ? - ((getLastItem(state.keyMap.get(treeNode.parentKey!)?.childNodes ?? []) as GridNode)?.indexOfType ?? 0) + 1 : - ((getLastItem(state.collection.body.childNodes) as GridNode)?.indexOfType ?? 0) + 1 + 'aria-level': treeNode.level + 1, + 'aria-posinset': treeNode.index - (isParentBody ? 0 : state.collection.columnCount) + 1, + 'aria-setsize': lastSibling.index - (isParentBody ? 0 : state.collection.columnCount) + 1 + }; + + expandButtonProps = { + isDisabled: states.isDisabled, + onPress: () => { + if (!states.isDisabled) { + state.toggleKey(node.key); + state.selectionManager.setFocused(true); + state.selectionManager.setFocusedKey(node.key); + } + }, + excludeFromTabOrder: true, + preventFocusOnPress: true, + // @ts-ignore + 'data-react-aria-prevent-focus': true, + ...labelProps }; } } @@ -79,8 +113,17 @@ export function useTableRow(props: GridRowProps, state: TableState | Tr return { rowProps: { ...mergeProps(rowProps, treeGridRowProps, linkProps), - 'aria-labelledby': getRowLabelledBy(state, node.key) + 'aria-labelledby': getRowLabelledBy(state as TableState, node.key) }, + expandButtonProps, ...states }; } + +function getLastChild(collection: Collection>, node: Node) { + if ('lastChildKey' in node) { + return node.lastChildKey != null ? collection.getItem(node.lastChildKey) : null; + } else { + return Array.from(node.childNodes).findLast(item => item.parentKey === node.key); + } +} diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 95d4e6fe184..2401beeed1b 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -11,10 +11,11 @@ */ import {act, waitFor, within} from '@testing-library/react'; +import {BaseGridRowInteractionOpts, GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; -import {GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types'; interface TableToggleRowOpts extends ToggleGridRowOpts {} +interface TableToggleExpansionOpts extends BaseGridRowInteractionOpts {} interface TableToggleSortOpts { /** * The index, text, or node of the column to toggle selection for. @@ -162,6 +163,50 @@ export class TableTester { } }; + /** + * Toggles the expansion for the specified tree row. Defaults to using the interaction type set on the tree tester. + */ + async toggleRowExpansion(opts: TableToggleExpansionOpts): Promise { + let { + row, + interactionType = this._interactionType + } = opts; + if (!this.table.contains(document.activeElement)) { + await act(async () => { + this.table.focus(); + }); + } + + if (typeof row === 'string' || typeof row === 'number') { + row = this.findRow({rowIndexOrText: row}); + } + + if (!row) { + throw new Error('Target row not found in the table.'); + } else if (row.getAttribute('aria-expanded') == null) { + throw new Error('Target row is not expandable.'); + } + + if (interactionType === 'mouse' || interactionType === 'touch') { + let rowExpander = within(row).getAllByRole('button')[0]; // what happens if the button is not first? how can we differentiate? + await pressElement(this.user, rowExpander, interactionType); + } else if (interactionType === 'keyboard') { + if (row?.getAttribute('aria-disabled') === 'true') { + return; + } + + // TODO: We always Use Option/Ctrl when keyboard navigating so selection isn't changed + // in selectionmode="replace"/highlight selection when navigating to the row that the user wants + // to expand. Discuss if this is useful or not + await this.keyboardNavigateToRow({row}); + if (row.getAttribute('aria-expanded') === 'true') { + await this.user.keyboard('[ArrowLeft]'); + } else { + await this.user.keyboard('[ArrowRight]'); + } + } + }; + /** * Toggles the sort order for the specified table column. Defaults to using the interaction type set on the table tester. */ diff --git a/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx b/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx index 539286d34d9..fe35d1dd7de 100644 --- a/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx @@ -430,3 +430,71 @@ export const ResizingTable: StoryObj = { }, name: 'resizing only table' }; + +export const TableWithNestedRows: StoryObj = { + render: (args) => ( + + + Name + Type + Date Modified + + + + Games + Folder + 6/7/2023 + + Mario Kart + Game + 8/27/1992 + + + Tetris + Game + 1/27/1988 + + + Pac-Man + Game + 5/22/1980 + + + + Applications + Folder + 4/7/2025 + + Photoshop + Application + 2/19/1990 + + + Premiere + Application + 9/24/2003 + + + Lightroom + Application + 10/18/2017 + + + + 2024 Financial Report + PDF Document + 12/30/2024 + + + Job Posting + Text Document + 1/18/2025 + + + + ), + args: { + selectionMode: 'multiple', + defaultExpandedKeys: ['apps'] + } +}; diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index fac45b68db6..94939aa6741 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -77,7 +77,7 @@ import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {Button as SpectrumButton} from './Button'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef, useMediaQuery} from '@react-spectrum/utils'; -import {useLocalizedStringFormatter} from '@react-aria/i18n'; +import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; import {VisuallyHidden} from 'react-aria'; @@ -952,9 +952,27 @@ const commonCellStyles = { paddingX: 16 // table-edge-to-content } as const; -const cell = style({ +const treeColumnStyles = { + '--indent': { + type: 'width', + value: 16 + }, + '--treeColumnPadding': { + type: 'width', + value: { + default: 16, + isTreeColumnWithNoChildren: 36 + } + }, + paddingStart: { + default: 16, + isTreeColumn: 'calc(var(--treeColumnPadding) + (var(--table-row-level, 1) - 1) * var(--indent))' + } +} as const; + +const cell = style({ ...commonCellStyles, - paddingY: centerPadding(), + ...treeColumnStyles, minHeight: { default: 40, density: { @@ -1052,12 +1070,16 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef cell({ ...renderProps, ...tableVisualOptions, - isDivider: showDivider + isDivider: showDivider, + isTreeColumnWithNoChildren: renderProps.isTreeColumn && !renderProps.hasChildItems })} textValue={textValue} {...otherProps}> - {({isFocusVisible}) => ( + {({id, isFocusVisible, hasChildItems, isTreeColumn, isExpanded, isDisabled}) => ( <> + {hasChildItems && isTreeColumn && + + } {children} {isFocusVisible && } @@ -1066,9 +1088,78 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef({ +const expandButton = style({ + gridArea: 'expand-button', + color: { + default: 'inherit', + isDisabled: { + default: 'disabled', + forcedColors: 'GrayText' + } + }, + height: 'full', + width: 40, + marginStart: -12, + display: 'flex', + flexWrap: 'wrap', + alignContent: 'center', + justifyContent: 'center', + outlineStyle: 'none', + cursor: 'default', + transform: { + isExpanded: { + default: 'rotate(90deg)', + isRTL: 'rotate(-90deg)' + } + }, + padding: 0, + transition: 'default', + backgroundColor: 'transparent', + borderStyle: 'none', + disableTapHighlight: true, + visibility: { + isHidden: 'hidden' + } +}); + +function ExpandableRowChevron(props: ExpandableRowChevronProps) { + let ref = useRef(null); + let {isExpanded, isHidden} = props; + let {direction} = useLocale(); + + return ( + + ); +} + +const editableCell = style({ ...commonCellStyles, + ...treeColumnStyles, color: { default: baseColor('neutral'), isSaving: baseColor('neutral-subdued') @@ -1152,12 +1243,18 @@ export const EditableCell = forwardRef(function EditableCell(props: EditableCell ...renderProps, ...tableVisualOptions, isDivider: showDivider, - isSaving + isSaving, + isTreeColumnWithNoChildren: renderProps.isTreeColumn && !renderProps.hasChildItems })} textValue={textValue} {...otherProps}> - {({isFocusVisible}) => ( - } /> + {({id, isFocusVisible, hasChildItems, isTreeColumn, isExpanded, isDisabled}) => ( + <> + {hasChildItems && isTreeColumn && + + } + } /> + )} ); @@ -1205,7 +1302,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, let boundingRect = cell?.parentElement?.getBoundingClientRect(); let verticalOffset = (boundingRect?.top ?? 0) - (boundingRect?.bottom ?? 0); - let tableWidth = cellRef.current?.closest('[role="grid"]')?.clientWidth || 0; + let tableWidth = cellRef.current?.closest('[role="grid"],[role="treegrid"]')?.clientWidth || 0; setTriggerWidth(width); setVerticalOffset(verticalOffset); setTableWidth(tableWidth); diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index f0a235e872a..1bee8605955 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -14,6 +14,7 @@ import {action} from 'storybook/actions'; import { ActionButton, Cell, + Collection, Column, ColumnProps, Content, @@ -43,7 +44,7 @@ import {Key} from '@react-types/shared'; import type {Meta, StoryObj} from '@storybook/react'; import React, {ReactElement, useCallback, useEffect, useRef, useState} from 'react'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; -import {useAsyncList, useListData} from '@react-stately/data'; +import {useAsyncList, useListData, useTreeData} from '@react-stately/data'; import {useEffectEvent} from '@react-aria/utils'; import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; @@ -1714,3 +1715,136 @@ export const EditableTableWithAsyncSaving: StoryObj = { ); } }; + +export const TableWithNestedRows: StoryObj = { + render: (args) => ( + + + Name + Type + Date Modified + + + + Games + Folder + 6/7/2023 + + Mario Kart + Game + 8/27/1992 + + + Tetris + Game + 1/27/1988 + + + Pac-Man + Game + 5/22/1980 + + + + Applications + Folder + 4/7/2025 + + Photoshop + Application + 2/19/1990 + + + Premiere + Application + 9/24/2003 + + + Lightroom + Application + 10/18/2017 + + + + 2024 Financial Report + PDF Document + 12/30/2024 + + + Job Posting + Text Document + 1/18/2025 + + + + ) +}; + +function NestedInlineEditExample(args) { + let tree = useTreeData({ + initialItems: [ + {id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [ + {id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [ + {id: '3', title: 'Weekly Report', type: 'File', date: '7/10/2025', children: []}, + {id: '4', title: 'Budget', type: 'File', date: '8/20/2025', children: []} + ]} + ]}, + {id: '5', title: 'Photos', type: 'Directory', date: '2/3/2026', children: [ + {id: '6', title: 'Image 1', type: 'File', date: '1/23/2026', children: []}, + {id: '7', title: 'Image 2', type: 'File', date: '2/3/2026', children: []} + ]} + ] + }); + + return ( + + + Name + Type + Date Modified + + + {function renderItem(item) { + return ( + + { + e.preventDefault(); + let formData = new FormData(e.target as HTMLFormElement); + let title = formData.get('name') as string; + tree.update(item.key, { + ...item.value, + title + }); + }} + renderEditing={() => ( + + )}> +
+ {item.value.title} + +
+
+ {item.value.type} + {item.value.date} + + {renderItem} + +
+ ); + }} +
+
+ ); +} + +export const TableWithNestedRowsAndInlineEditing: StoryObj = { + render: (args) => +}; diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 371e288f4aa..9d3dc5acd3b 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -107,7 +107,7 @@ const LEVEL_OFFSET_WIDTH = { }; export interface TableContextValue { - state: TableState | TreeGridState, + state: TableState, dragState: DraggableCollectionState | null, dropState: DroppableCollectionState | null, dragAndDropHooks?: DragAndDropHooks['dragAndDropHooks'], @@ -170,7 +170,7 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef(props: TableBaseProps, ref: DOMRef, dragState, dropState, dragAndDropHooks, @@ -481,7 +481,7 @@ function TableViewBase(props: TableBaseProps, ref: DOMRef} layout={layout} collection={state.collection} persistedKeys={persistedKeys} @@ -1162,7 +1162,7 @@ function TableRow({item, children, layoutInfo, parent, ...otherProps}: {item: Gr } = useFocusRing({within: true}); let {isFocusVisible, focusProps} = useFocusRing(); let {hoverProps, isHovered} = useHover({isDisabled: !isInteractive}); - let isFirstRow = state.collection.rows.find(row => row.level === 1)?.key === item.key; + let isFirstRow = state.collection.rows.find(row => row.type === 'item' && row.level === 0)?.key === item.key; let isLastRow = item.nextKey == null; // Figure out if the TableView content is equal or greater in height to the container. If so, we'll need to round the bottom // border corners of the last row when selected. @@ -1364,26 +1364,26 @@ function TableCheckboxCell({cell}) { function TableCell({cell}) { let {scale} = useProvider(); - let {state} = useTableContext(); - let isExpandableTable = 'expandedKeys' in state; + let state = useTableContext().state as TableState | TreeGridState; + let isExpandableTable = 'keyMap' in state; let ref = useRef(null); let columnProps = cell.column.props as SpectrumColumnProps; let isDisabled = state.selectionManager.isDisabled(cell.parentKey); let {gridCellProps} = useTableCell({ node: cell, isVirtualized: true - }, state, ref); + }, state as TableState, ref); let {id, ...otherGridCellProps} = gridCellProps; let isFirstRowHeaderCell = state.collection.rowHeaderColumnKeys.keys().next().value === cell.column.key; let isRowExpandable = false; let showExpandCollapseButton = false; let levelOffset = 0; - if ('expandedKeys' in state) { + if ('keyMap' in state) { isRowExpandable = state.keyMap.get(cell.parentKey)?.props.UNSTABLE_childItems?.length > 0 || state.keyMap.get(cell.parentKey)?.props?.children?.length > state.userColumnCount; showExpandCollapseButton = isFirstRowHeaderCell && isRowExpandable; // Offset based on level, and add additional offset if there is no expand/collapse button on a row - levelOffset = (cell.level - 2) * LEVEL_OFFSET_WIDTH[scale] + (!showExpandCollapseButton ? LEVEL_OFFSET_WIDTH[scale] * 2 : 0); + levelOffset = (cell.level - 1) * LEVEL_OFFSET_WIDTH[scale] + (!showExpandCollapseButton ? LEVEL_OFFSET_WIDTH[scale] * 2 : 0); } return ( @@ -1467,12 +1467,12 @@ function TableCellWrapper({layoutInfo, virtualizer, parent, children}: {layoutIn function ExpandableRowChevron({cell}) { // TODO: move some/all of the chevron button setup into a separate hook? let {direction} = useLocale(); - let {state} = useTableContext(); + let state = useTableContext().state as TableState | TreeGridState; let expandButtonRef = useRef(null); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/table'); let isExpanded; - if ('expandedKeys' in state) { + if ('keyMap' in state) { isExpanded = state.expandedKeys === 'all' || state.expandedKeys.has(cell.parentKey); } @@ -1537,10 +1537,10 @@ function EmptyState() { } function CenteredWrapper({children}) { - let {state} = useTableContext(); + let state = useTableContext().state as TableState | TreeGridState; let rowProps; - if ('expandedKeys' in state) { + if ('keyMap' in state) { let topLevelRowCount = [...state.collection.body.childNodes].length; rowProps = { 'aria-level': 1, diff --git a/packages/@react-spectrum/table/src/TableViewWrapper.tsx b/packages/@react-spectrum/table/src/TableViewWrapper.tsx index 2787307ecfe..c229de93286 100644 --- a/packages/@react-spectrum/table/src/TableViewWrapper.tsx +++ b/packages/@react-spectrum/table/src/TableViewWrapper.tsx @@ -18,7 +18,7 @@ import {tableNestedRows} from '@react-stately/flags'; import {TableView} from './TableView'; import {TreeGridTableView} from './TreeGridTableView'; -export interface SpectrumTableProps extends TableProps, SpectrumSelectionProps, DOMProps, AriaLabelingProps, StyleProps { +export interface SpectrumTableProps extends Omit, 'treeColumn' | 'expandedKeys' | 'defaultExpandedKeys' | 'onExpandedChange'>, SpectrumSelectionProps, DOMProps, AriaLabelingProps, StyleProps { /** * Sets the amount of vertical padding within each cell. * @default 'regular' diff --git a/packages/@react-stately/collections/src/CollectionBuilder.ts b/packages/@react-stately/collections/src/CollectionBuilder.ts index c8077ca0a4f..6e7dda20601 100644 --- a/packages/@react-stately/collections/src/CollectionBuilder.ts +++ b/packages/@react-stately/collections/src/CollectionBuilder.ts @@ -204,7 +204,7 @@ export class CollectionBuilder { key: partialNode.key, parentKey: parentNode ? parentNode.key : null, value: partialNode.value ?? null, - level: parentNode ? parentNode.level + 1 : 0, + level: (parentNode?.level ?? 0) + (parentNode?.type === 'item' ? 1 : 0), index: partialNode.index, rendered: partialNode.rendered, textValue: partialNode.textValue ?? '', diff --git a/packages/@react-stately/dnd/src/useDroppableCollectionState.ts b/packages/@react-stately/dnd/src/useDroppableCollectionState.ts index 01036c91688..e3c1c43e838 100644 --- a/packages/@react-stately/dnd/src/useDroppableCollectionState.ts +++ b/packages/@react-stately/dnd/src/useDroppableCollectionState.ts @@ -194,6 +194,25 @@ export function useDroppableCollectionState(props: DroppableCollectionStateOptio return false; }, getDropOperation(e) { + let {target, isInternal, draggingKeys} = e; + + // Prevent dropping items onto themselves or their descendants + if (isInternal && target.type === 'item' && draggingKeys.size > 0) { + if (draggingKeys.has(target.key) && target.dropPosition === 'on') { + return 'cancel'; + } + + let currentKey: Key | null = target.key; + while (currentKey != null) { + let item = collection.getItem(currentKey); + let parentKey = item?.parentKey; + if (parentKey != null && draggingKeys.has(parentKey)) { + return 'cancel'; + } + currentKey = parentKey ?? null; + } + } + return defaultGetDropOperation(e); } }; diff --git a/packages/@react-stately/table/src/TableCollection.ts b/packages/@react-stately/table/src/TableCollection.ts index 346cd516347..2ca36d4f13a 100644 --- a/packages/@react-stately/table/src/TableCollection.ts +++ b/packages/@react-stately/table/src/TableCollection.ts @@ -317,6 +317,9 @@ export class TableCollection extends GridCollection implements ITableColle } getItem(key: Key): GridNode | null { + if (key === this.body.key) { + return this.body; + } return this.keyMap.get(key) ?? null; } @@ -330,6 +333,11 @@ export class TableCollection extends GridCollection implements ITableColle return this.body.childNodes; } + let node = this.getItem(key); + if (node?.type === 'item') { + return [...node.childNodes].filter(n => n.type === 'cell'); + } + return super.getChildren(key); } diff --git a/packages/@react-stately/table/src/useTableState.ts b/packages/@react-stately/table/src/useTableState.ts index d18d173161f..603403e5e74 100644 --- a/packages/@react-stately/table/src/useTableState.ts +++ b/packages/@react-stately/table/src/useTableState.ts @@ -10,13 +10,14 @@ * governing permissions and limitations under the License. */ +import {Expandable, Key, Node, SelectionMode, Sortable, SortDescriptor, SortDirection} from '@react-types/shared'; import {GridState, useGridState} from '@react-stately/grid'; import {TableCollection as ITableCollection, TableBodyProps, TableHeaderProps} from '@react-types/table'; -import {Key, Node, SelectionMode, Sortable, SortDescriptor, SortDirection} from '@react-types/shared'; import {MultipleSelectionState, MultipleSelectionStateProps} from '@react-stately/selection'; import {ReactElement, useCallback, useMemo, useState} from 'react'; import {TableCollection} from './TableCollection'; import {useCollection} from '@react-stately/collections'; +import {useControlledState} from '@react-stately/utils'; export interface TableState extends GridState> { /** A collection of rows and columns in the table. */ @@ -30,7 +31,13 @@ export interface TableState extends GridState> { /** Whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ isKeyboardNavigationDisabled: boolean, /** Set whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ - setKeyboardNavigationDisabled: (val: boolean) => void + setKeyboardNavigationDisabled: (val: boolean) => void, + /** A set of keys for items that are expanded. */ + expandedKeys: Set, + /** Toggles the expanded state for a row by its key. */ + toggleKey(key: Key): void, + /** The id of the column that displays hierarchical data. */ + treeColumn: Key | null } export interface CollectionBuilderContext { @@ -40,7 +47,7 @@ export interface CollectionBuilderContext { columns: Node[] } -export interface TableStateProps extends MultipleSelectionStateProps, Sortable { +export interface TableStateProps extends MultipleSelectionStateProps, Expandable, Sortable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children?: [ReactElement>, ReactElement>], /** A list of row keys to disable. */ @@ -54,7 +61,9 @@ export interface TableStateProps extends MultipleSelectionStateProps, Sortabl */ showDragButtons?: boolean, /** @private - do not use unless you know what you're doing. */ - UNSAFE_selectionState?: MultipleSelectionState + UNSAFE_selectionState?: MultipleSelectionState, + /** The id of the column that displays hierarchical data. */ + treeColumn?: Key } const OPPOSITE_SORT_DIRECTION = { @@ -68,7 +77,7 @@ const OPPOSITE_SORT_DIRECTION = { */ export function useTableState(props: TableStateProps): TableState { let [isKeyboardNavigationDisabled, setKeyboardNavigationDisabled] = useState(false); - let {selectionMode = 'none', showSelectionCheckboxes, showDragButtons} = props; + let {selectionMode = 'none', showSelectionCheckboxes, showDragButtons, treeColumn = null} = props; let context = useMemo(() => ({ showSelectionCheckboxes: showSelectionCheckboxes && selectionMode !== 'none', @@ -89,6 +98,12 @@ export function useTableState(props: TableStateProps): Tabl disabledBehavior: props.disabledBehavior || 'selection' }); + let [expandedKeys, setExpandedKeys] = useControlledState( + props.expandedKeys ? new Set(props.expandedKeys) : undefined, + props.defaultExpandedKeys ? new Set(props.defaultExpandedKeys) : new Set(), + props.onExpandedChange + ); + return { collection, disabledKeys, @@ -104,7 +119,21 @@ export function useTableState(props: TableStateProps): Tabl ? OPPOSITE_SORT_DIRECTION[props.sortDescriptor.direction] : 'ascending') }); - } + }, + expandedKeys, + toggleKey(key) { + setExpandedKeys(keys => { + let newKeys = new Set(keys); + if (newKeys.has(key)) { + newKeys.delete(key); + } else { + newKeys.add(key); + } + + return newKeys; + }); + }, + treeColumn }; } diff --git a/packages/@react-stately/table/src/useTreeGridState.ts b/packages/@react-stately/table/src/useTreeGridState.ts index 3e6e3247927..7858492d37b 100644 --- a/packages/@react-stately/table/src/useTreeGridState.ts +++ b/packages/@react-stately/table/src/useTreeGridState.ts @@ -19,7 +19,7 @@ import {tableNestedRows} from '@react-stately/flags'; import {TableState, TableStateProps, useTableState} from './useTableState'; import {useControlledState} from '@react-stately/utils'; -export interface TreeGridState extends TableState { +export interface TreeGridState extends Omit, 'expandedKeys'> { /** A set of keys for items that are expanded. */ expandedKeys: 'all' | Set, /** Toggles the expanded state for a row by its key. */ @@ -92,7 +92,8 @@ export function UNSTABLE_useTreeGridState(props: TreeGridState keyMap: treeGridCollection.keyMap, userColumnCount: treeGridCollection.userColumnCount, expandedKeys, - toggleKey: onToggle + toggleKey: onToggle, + treeColumn: tableState.treeColumn ?? collection.rowHeaderColumnKeys.keys().next().value ?? null }; } @@ -142,19 +143,10 @@ function generateTreeGridCollection(nodes, opts: TreeGridCollectionOptions): let body: GridNode | null = null; let flattenedRows: GridNode[] = []; - let columnCount = 0; let userColumnCount = 0; let originalColumns: GridNode[] = []; let keyMap = new Map(); - if (opts?.showSelectionCheckboxes) { - columnCount++; - } - - if (opts?.showDragButtons) { - columnCount++; - } - let topLevelRows: GridNode[] = []; let visit = (node: GridNode) => { switch (node.type) { @@ -184,89 +176,28 @@ function generateTreeGridCollection(nodes, opts: TreeGridCollectionOptions): visit(node); } - columnCount += userColumnCount; - // Update each grid node in the treegrid table with values specific to a treegrid structure. Also store a set of flattened row nodes for TableCollection to consume - let globalRowCount = 0; - let visitNode = (node: GridNode, i?: number) => { - // Clone row node and its children so modifications to the node for treegrid specific values aren't applied on the nodes provided - // to TableCollection. Index, level, and parent keys are all changed to reflect a flattened row structure rather than the treegrid structure - // values automatically calculated via CollectionBuilder + let visitNode = (node: GridNode) => { if (node.type === 'item') { - let childNodes: GridNode[] = []; - for (let child of node.childNodes) { - if (child.type === 'cell') { - let cellClone = {...child}; - if (cellClone.index + 1 === columnCount) { - cellClone.nextKey = null; - } - childNodes.push({...cellClone}); - } - } - let clone: GridNode = {...node, childNodes: childNodes, parentKey: body!.key, level: 1, index: globalRowCount++}; - flattenedRows.push(clone); - } - - let newProps = {}; - - // Assign indexOfType to cells and rows for aria-posinset - if (node.type !== 'placeholder' && node.type !== 'column') { - newProps['indexOfType'] = i; + flattenedRows.push(node); } - // Use Object.assign instead of spread to preserve object reference for keyMap. Also ensures retrieving nodes - // via .childNodes returns the same object as the one found via keyMap look up - Object.assign(node, newProps); keyMap.set(node.key, node); - let lastNode: GridNode | null = null; - let rowIndex = 0; for (let child of node.childNodes) { if (!(child.type === 'item' && expandedKeys !== 'all' && !expandedKeys.has(node.key))) { - if (child.parentKey == null) { - // if child is a cell/expanded row/column and the parent key isn't already established by the collection, match child node to parent row - child.parentKey = node.key; - } - - if (lastNode) { - lastNode.nextKey = child.key; - child.prevKey = lastNode.key; - } else { - child.prevKey = null; - } - if (child.type === 'item') { - visitNode(child, rowIndex++); + visitNode(child); } else { // We enforce that the cells come before rows so can just reuse cell index - visitNode(child, child.index); + visitNode(child); } - - lastNode = child; } } - - if (lastNode) { - lastNode.nextKey = null; - } }; - let last: GridNode | null = null; - for (let [i, node] of topLevelRows.entries()) { - visitNode(node as GridNode, i); - - if (last) { - last.nextKey = node.key; - node.prevKey = last.key; - } else { - node.prevKey = null; - } - - last = node; - } - - if (last) { - last.nextKey = null; + for (let node of topLevelRows) { + visitNode(node as GridNode); } return { diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index a653dfc9768..9ca04fdc7d8 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -218,6 +218,10 @@ export interface Node { prevKey?: Key | null, /** The key of the node after this node. */ nextKey?: Key | null, + /** The first child key of this node. */ + firstChildKey?: Key | null, + /** The last child key of this node. */ + lastChildKey?: Key | null, /** Additional properties specific to a particular node type. */ props?: any, /** @private */ diff --git a/packages/@react-types/table/src/index.d.ts b/packages/@react-types/table/src/index.d.ts index 2c898c743f5..40b720e9670 100644 --- a/packages/@react-types/table/src/index.d.ts +++ b/packages/@react-types/table/src/index.d.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, AsyncLoadable, DOMProps, Key, LinkDOMProps, LoadingState, MultipleSelection, Sortable, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; +import {AriaLabelingProps, AsyncLoadable, DOMProps, Expandable, Key, LinkDOMProps, LoadingState, MultipleSelection, Sortable, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; import {GridCollection, GridNode} from '@react-types/grid'; import {JSX, ReactElement, ReactNode} from 'react'; @@ -25,7 +25,7 @@ export type ColumnDynamicSize = `${number}fr`; // match regex: /^(\d+)(?=fr$)/ /** All possible sizes a column can be assigned. */ export type ColumnSize = ColumnStaticSize | ColumnDynamicSize; -export interface TableProps extends MultipleSelection, Sortable { +export interface TableProps extends MultipleSelection, Sortable, Expandable { /** The elements that make up the table. Includes the TableHeader, TableBody, Columns, and Rows. */ children: [ReactElement>, ReactElement>], /** A list of row keys to disable. */ @@ -40,7 +40,9 @@ export interface TableProps extends MultipleSelection, Sortable { */ escapeKeyBehavior?: 'clearSelection' | 'none', /** Whether selection should occur on press up instead of press down. */ - shouldSelectOnPressUp?: boolean + shouldSelectOnPressUp?: boolean, + /** The id of the column that displays hierarchical data. */ + treeColumn?: Key } /** diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index b84ae1716a4..1b47df2d661 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -20,36 +20,66 @@ export const description = 'Displays data in rows and columns and enables a user {docs.exports.Table.description} - ```tsx render docs={docs.exports.Table} links={docs.links} props={['selectionMode']} initialProps={{'aria-label': 'Files', selectionMode: 'multiple'}} type="vanilla" files={["starters/docs/src/Table.tsx", "starters/docs/src/Table.css"]} + ```tsx render docs={docs.exports.Table} links={docs.links} props={['selectionMode']} initialProps={{'aria-label': 'Files', selectionMode: 'multiple', 'treeColumn': 'name'}} type="vanilla" files={["starters/docs/src/Table.tsx", "starters/docs/src/Table.css"]} "use client"; import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/Table'; - Name - Type - Date Modified + Name + Type + Date Modified - + Games - File folder - 6/7/2020 + Folder + 6/7/2023 + + Mario Kart + Game + 8/27/1992 + + + Tetris + Game + 1/27/1988 + + + Pac-Man + Game + 5/22/1980 + - - Program Files - File folder - 4/7/2021 + + Applications + Folder + 4/7/2025 + + Photoshop + Application + 2/19/1990 + + + Premiere + Application + 9/24/2003 + + + Lightroom + Application + 10/18/2017 + - - bootmgr - System file - 11/20/2010 + + 2024 Financial Report + PDF Document + 12/30/2024 - - log.txt + + Job Posting Text Document - 1/18/2016 + 1/18/2025
@@ -61,30 +91,60 @@ export const description = 'Displays data in rows and columns and enables a user - Name - Type - Date Modified + Name + Type + Date Modified - + Games - File folder - 6/7/2020 + Folder + 6/7/2023 + + Mario Kart + Game + 8/27/1992 + + + Tetris + Game + 1/27/1988 + + + Pac-Man + Game + 5/22/1980 + - - Program Files - File folder - 4/7/2021 + + Applications + Folder + 4/7/2025 + + Photoshop + Application + 2/19/1990 + + + Premiere + Application + 9/24/2003 + + + Lightroom + Application + 10/18/2017 + - - bootmgr - System file - 11/20/2010 + + 2024 Financial Report + PDF Document + 12/30/2024 - - log.txt + + Job Posting Text Document - 1/18/2016 + 1/18/2025
@@ -171,6 +231,69 @@ function FileTable() { on external state (e.g. `columns` in this example). +### Expandable rows + +Rows can be nested to display hierarchical data. Use the `treeColumn` prop to designate a column, and render a ` + )} + {props.children} + )} + + ); +} + +export const TableNestedRows: TableStory = (args) => { + return ( + + + Name + Type + Date Modified + + + + Games + File folder + 6/7/2020 + + Pokemon + File + 2/3/2025 + + + + Program Files + File folder + 4/7/2021 + + + bootmgr + System file + 11/20/2010 + + +
+ ); +}; diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.js new file mode 100644 index 00000000000..e84dd0e07af --- /dev/null +++ b/packages/react-aria-components/test/Treeble.test.js @@ -0,0 +1,587 @@ +/* + * 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 {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {Cell as AriaCell, Button, Collection, Column, composeRenderProps, Row, Table, TableBody, TableHeader, useDragAndDrop, useTreeData} from '../src'; +import React from 'react'; +import {User} from '@react-aria/test-utils'; +import userEvent from '@testing-library/user-event'; + +export function Cell(props) { + return ( + + {composeRenderProps(props.children, (children, {hasChildItems, isTreeColumn}) => (<> + {isTreeColumn && hasChildItems && + + } + {children} + ))} + + ); +} + +function Example(props) { + return ( + + + Name + Type + Date Modified + + + + Games + Folder + 6/7/2023 + + Mario Kart + Game + 8/27/1992 + + + Tetris + Game + 1/27/1988 + + + Pac-Man + Game + 5/22/1980 + + + + Applications + Folder + 4/7/2025 + + Photoshop + Application + 2/19/1990 + + + Premiere + Application + 9/24/2003 + + + Lightroom + Application + 10/18/2017 + + + + 2024 Financial Report + PDF Document + 12/30/2024 + + + Job Posting + Text Document + 1/18/2025 + + +
+ ); +} + +describe('Treeble', () => { + let utils = new User(); + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + + it('renders a treegrid', () => { + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + expect(tester.table).toHaveAttribute('role', 'treegrid'); + + expect(tester.rows).toHaveLength(4); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'false'); + expect(tester.rows[0]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[0]).not.toHaveAttribute('data-expanded'); + expect(tester.rows[0]).toHaveAttribute('data-has-child-items', 'true'); + expect(tester.rows[0]).toHaveAttribute('data-level', '1'); + expect(tester.rows[0]).toHaveAttribute('style', '--table-row-level: 1;'); + expect(tester.rowHeaders[0]).toHaveTextContent('Games'); + expect(tester.rowHeaders[0]).toHaveAttribute('data-tree-column'); + for (let cell of tester.cells()) { + expect(cell).not.toHaveAttribute('data-tree-column'); + } + for (let cell of tester.cells({element: tester.rows[0]})) { + expect(cell).not.toHaveAttribute('data-expanded'); + expect(cell).toHaveAttribute('data-has-child-items', 'true'); + expect(cell).toHaveAttribute('data-level', '1'); + } + + let button = within(tester.rowHeaders[0]).getByRole('button'); + expect(button).toHaveAttribute('aria-label', 'Expand'); + expect(button).toHaveAttribute('aria-labelledby', `${button.id} ${tester.rowHeaders[0].id}`); + expect(button).toHaveAttribute('tabindex', '-1'); + + expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'false'); + expect(tester.rows[1]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[1]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[1]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[1]).not.toHaveAttribute('data-expanded'); + expect(tester.rows[1]).toHaveAttribute('data-has-child-items', 'true'); + expect(tester.rows[1]).toHaveAttribute('data-level', '1'); + expect(tester.rows[1]).toHaveAttribute('style', '--table-row-level: 1;'); + expect(tester.rowHeaders[1]).toHaveTextContent('Applications'); + + expect(tester.rows[2]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[2]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[2]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[2]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[2]).not.toHaveAttribute('data-expanded'); + expect(tester.rows[2]).not.toHaveAttribute('data-has-child-items'); + expect(tester.rows[2]).toHaveAttribute('data-level', '1'); + expect(tester.rows[2]).toHaveAttribute('style', '--table-row-level: 1;'); + expect(tester.rowHeaders[2]).toHaveTextContent('2024 Financial Report'); + + expect(tester.rows[3]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[3]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[3]).toHaveAttribute('aria-posinset', '4'); + expect(tester.rows[3]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[3]).not.toHaveAttribute('data-expanded'); + expect(tester.rows[3]).not.toHaveAttribute('data-has-child-items'); + expect(tester.rows[3]).toHaveAttribute('data-level', '1'); + expect(tester.rows[3]).toHaveAttribute('style', '--table-row-level: 1;'); + expect(tester.rowHeaders[3]).toHaveTextContent('Job Posting'); + }); + + it.each(['mouse', 'touch', 'keyboard'])('should expand a row with %s', async (interactionType) => { + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await tester.toggleRowExpansion({row: 0, interactionType}); + + expect(tester.rows).toHaveLength(7); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'true'); + expect(tester.rows[0]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[0]).toHaveAttribute('data-expanded', 'true'); + expect(tester.rows[0]).toHaveAttribute('data-has-child-items', 'true'); + expect(tester.rows[0]).toHaveAttribute('data-level', '1'); + expect(tester.rows[0]).toHaveAttribute('style', '--table-row-level: 1;'); + expect(tester.rowHeaders[0]).toHaveTextContent('Games'); + for (let cell of tester.cells({element: tester.rows[0]})) { + expect(cell).toHaveAttribute('data-expanded'); + expect(cell).toHaveAttribute('data-has-child-items', 'true'); + expect(cell).toHaveAttribute('data-level', '1'); + } + + expect(tester.rows[1]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[1]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[1]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[1]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rows[1]).toHaveAttribute('style', '--table-row-level: 2;'); + expect(tester.rowHeaders[1]).toHaveTextContent('Mario Kart'); + + expect(tester.rows[2]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[2]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rows[2]).toHaveAttribute('style', '--table-row-level: 2;'); + expect(tester.rowHeaders[2]).toHaveTextContent('Tetris'); + + expect(tester.rows[3]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[3]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[3]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[3]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rows[3]).toHaveAttribute('style', '--table-row-level: 2;'); + expect(tester.rowHeaders[3]).toHaveTextContent('Pac-Man'); + + expect(tester.rows[4]).toHaveAttribute('aria-expanded', 'false'); + expect(tester.rows[4]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[4]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[4]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[4]).toHaveAttribute('style', '--table-row-level: 1;'); + expect(tester.rowHeaders[4]).toHaveTextContent('Applications'); + + expect(tester.rows[5]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[5]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[5]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[5]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[5]).toHaveAttribute('style', '--table-row-level: 1;'); + expect(tester.rowHeaders[5]).toHaveTextContent('2024 Financial Report'); + + await tester.toggleRowExpansion({row: 0, interactionType}); + expect(tester.rows).toHaveLength(4); + }); + + it('should support defaultExpandedKeys', async () => { + let onExpandedChange = jest.fn(); + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + expect(tester.rows).toHaveLength(7); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'true'); + expect(tester.rows[0]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-setsize', '4'); + + expect(tester.rowHeaders[0]).toHaveTextContent('Games'); + + expect(tester.rows[1]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[1]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[1]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[1]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[1]).toHaveTextContent('Mario Kart'); + + expect(tester.rows[2]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[2]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[2]).toHaveTextContent('Tetris'); + + expect(tester.rows[3]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[3]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[3]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[3]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[3]).toHaveTextContent('Pac-Man'); + + expect(tester.rows[4]).toHaveAttribute('aria-expanded', 'false'); + expect(tester.rows[4]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[4]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[4]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rowHeaders[4]).toHaveTextContent('Applications'); + + expect(tester.rows[5]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[5]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[5]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[5]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rowHeaders[5]).toHaveTextContent('2024 Financial Report'); + + await tester.toggleRowExpansion({row: 4}); + + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(onExpandedChange).toHaveBeenCalledWith(new Set(['games', 'apps'])); + + expect(tester.rows).toHaveLength(10); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'true'); + expect(tester.rows[0]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rows[0]).toHaveAttribute('data-expanded', 'true'); + expect(tester.rows[0]).toHaveAttribute('data-has-child-items', 'true'); + expect(tester.rows[0]).toHaveAttribute('data-level', '1'); + expect(tester.rowHeaders[0]).toHaveTextContent('Games'); + + expect(tester.rows[1]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[1]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[1]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[1]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[1]).toHaveTextContent('Mario Kart'); + + expect(tester.rows[2]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[2]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[2]).toHaveTextContent('Tetris'); + + expect(tester.rows[3]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[3]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[3]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[3]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[3]).toHaveTextContent('Pac-Man'); + + expect(tester.rows[4]).toHaveAttribute('aria-expanded', 'true'); + expect(tester.rows[4]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[4]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[4]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rowHeaders[4]).toHaveTextContent('Applications'); + + expect(tester.rows[5]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[5]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[5]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[5]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[5]).toHaveTextContent('Photoshop'); + + expect(tester.rows[6]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[6]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[6]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[6]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[6]).toHaveTextContent('Premiere'); + + expect(tester.rows[7]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[7]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[7]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[7]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[7]).toHaveTextContent('Lightroom'); + + expect(tester.rows[8]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[8]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[8]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[8]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rowHeaders[8]).toHaveTextContent('2024 Financial Report'); + + await tester.toggleRowExpansion({row: 4}); + expect(tester.rows).toHaveLength(7); + + expect(onExpandedChange).toHaveBeenCalledTimes(2); + expect(onExpandedChange).toHaveBeenLastCalledWith(new Set(['games'])); + }); + + it('should support expandedKeys', async () => { + let onExpandedChange = jest.fn(); + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + expect(tester.rows).toHaveLength(7); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'true'); + expect(tester.rows[0]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[0]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rowHeaders[0]).toHaveTextContent('Games'); + + expect(tester.rows[1]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[1]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[1]).toHaveAttribute('aria-posinset', '1'); + expect(tester.rows[1]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[1]).toHaveTextContent('Mario Kart'); + + expect(tester.rows[2]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[2]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[2]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[2]).toHaveTextContent('Tetris'); + + expect(tester.rows[3]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[3]).toHaveAttribute('aria-level', '2'); + expect(tester.rows[3]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[3]).toHaveAttribute('aria-setsize', '3'); + expect(tester.rowHeaders[3]).toHaveTextContent('Pac-Man'); + + expect(tester.rows[4]).toHaveAttribute('aria-expanded', 'false'); + expect(tester.rows[4]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[4]).toHaveAttribute('aria-posinset', '2'); + expect(tester.rows[4]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rowHeaders[4]).toHaveTextContent('Applications'); + + expect(tester.rows[5]).not.toHaveAttribute('aria-expanded'); + expect(tester.rows[5]).toHaveAttribute('aria-level', '1'); + expect(tester.rows[5]).toHaveAttribute('aria-posinset', '3'); + expect(tester.rows[5]).toHaveAttribute('aria-setsize', '4'); + expect(tester.rowHeaders[5]).toHaveTextContent('2024 Financial Report'); + + await tester.toggleRowExpansion({row: 4}); + + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(onExpandedChange).toHaveBeenCalledWith(new Set(['games', 'apps'])); + + expect(tester.rows).toHaveLength(7); // controlled + }); + + it('supports keyboard navigation of flattened rows', async () => { + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await user.tab(); + + for (let i = 0; i < tester.rows.length; i++) { + expect(document.activeElement).toBe(tester.rows[i]); + await user.keyboard('{ArrowDown}'); + } + + await user.keyboard('{Home}'); + expect(document.activeElement).toBe(tester.rows[0]); + + await user.keyboard('{End}'); + expect(document.activeElement).toBe(tester.rows[tester.rows.length - 1]); + }); + + it('supports keyboard navigation of cells', async () => { + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await user.tab(); + expect(document.activeElement).toBe(tester.rows[0]); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'false'); + + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(tester.rows[0]); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'true'); + + let cells = [tester.rowHeaders[0], ...tester.cells({element: tester.rows[0]})]; + for (let cell of cells) { + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + } + + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(tester.rows[0]); + + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(tester.rows[0]); + expect(tester.rows[0]).toHaveAttribute('aria-expanded', 'false'); + + for (let cell of cells.reverse()) { + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(cell); + } + + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(tester.rows[0]); + }); + + it('supports selection', async () => { + let onSelectionChange = jest.fn(); + let tree = render( onSelectionChange(new Set(k))} />); + let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); + + await tester.toggleRowSelection({row: 0}); + await user.keyboard('{Shift>}'); + await user.click(tester.rows[2]); + await user.keyboard('{/Shift}'); + + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['games', 'mario', 'tetris'])); + }); + + it('should support drag and drop', async () => { + function ReorderableTreeble() { + let tree = useTreeData({ + initialItems: [ + {id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [ + {id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [ + {id: '3', title: 'Weekly Report', type: 'File', date: '7/10/2025', children: []}, + {id: '4', title: 'Budget', type: 'File', date: '8/20/2025', children: []} + ]} + ]}, + {id: '5', title: 'Photos', type: 'Directory', date: '2/3/2026', children: [ + {id: '6', title: 'Image 1', type: 'File', date: '1/23/2026', children: []}, + {id: '7', title: 'Image 2', type: 'File', date: '2/3/2026', children: []} + ]} + ] + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys, items) => items.map(item => ({'text/plain': item.value.title})), + onMove(e) { + if (e.target.dropPosition === 'before') { + tree.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + tree.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + // Move items to become children of the target + let targetNode = tree.getItem(e.target.key); + if (targetNode) { + let targetIndex = targetNode.children ? targetNode.children.length : 0; + let keyArray = Array.from(e.keys); + for (let i = 0; i < keyArray.length; i++) { + tree.move(keyArray[i], e.target.key, targetIndex + i); + } + } + } + } + }); + + return ( + + + + Name + Type + Date Modified + + + {function renderItem(item) { + return ( + +
+ ); + } + + let tree = render(); + let tester = utils.createTester('Table', {root: tree.getByRole('treegrid')}); + + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + expect(tree.getAllByRole('button').map(r => r.getAttribute('aria-label'))).toEqual([ + 'Insert before Documents', + 'Drop on Documents', + 'Insert between Documents and Photos', + 'Drop on Photos', + 'Insert before Image 1', + 'Drop on Image 1', + 'Insert between Image 1 and Image 2', + 'Drag Image 2', + 'Insert after Image 2', + 'Insert after Photos' + ]); + + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowRight}'); + + expect(tree.getAllByRole('button').map(r => r.getAttribute('aria-label'))).toEqual([ + 'Insert before Documents', + 'Drop on Documents', + 'Insert before Project', + 'Drop on Project', + 'Insert after Project', + 'Insert between Documents and Photos', + 'Drop on Photos', + 'Insert before Image 1', + 'Drop on Image 1', + 'Insert between Image 1 and Image 2', + 'Drag Image 2', + 'Insert after Image 2', + 'Insert after Photos' + ]); + + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + + expect(tester.rowHeaders.map(r => r.textContent)).toEqual([ + '>Documents', + '>Project', + 'Image 2', + '>Photos', + 'Image 1' + ]); + }); +}); diff --git a/starters/docs/src/Table.css b/starters/docs/src/Table.css index 0535caf926a..6e2e4149425 100644 --- a/starters/docs/src/Table.css +++ b/starters/docs/src/Table.css @@ -276,11 +276,41 @@ @media (forced-colors: active) { --border-color: ButtonBorder; } + + &[data-tree-column] { + --chevron-placeholder: var(--spacing-5); + padding-inline-start: calc(var(--spacing-2) + (var(--table-row-level) - 1) * var(--spacing-4) + var(--chevron-placeholder)); + + &[data-has-child-items] { + --chevron-placeholder: 0px; + } + } + + .react-aria-Button[slot=chevron] { + all: unset; + vertical-align: middle; + margin-inline-end: var(--spacing-1); + + svg { + rotate: 0deg; + transition: rotate 200ms; + fill: none; + stroke: currentColor; + stroke-width: 3px; + width: var(--spacing-4); + height: var(--spacing-4); + } + } + + &[data-expanded] .react-aria-Button[slot=chevron] svg { + rotate: 90deg; + } } .react-aria-DropIndicator[data-drop-target] { outline: 1px solid var(--highlight-background); transform: translateZ(0); + translate: calc(68px + (var(--table-row-level) - 1) * var(--spacing-4)) 0; } :where(.react-aria-Row) .react-aria-Checkbox { diff --git a/starters/docs/src/Table.tsx b/starters/docs/src/Table.tsx index bfd8cebadf9..b7cec03e28d 100644 --- a/starters/docs/src/Table.tsx +++ b/starters/docs/src/Table.tsx @@ -18,11 +18,12 @@ import { ColumnResizer, Group, TableLoadMoreItem as AriaTableLoadMoreItem, - TableLoadMoreItemProps + TableLoadMoreItemProps, + composeRenderProps } from 'react-aria-components'; import {Checkbox} from './Checkbox'; import {ProgressCircle} from './ProgressCircle'; -import {ChevronUp, ChevronDown, GripVertical} from 'lucide-react'; +import {ChevronUp, ChevronDown, GripVertical, ChevronRight} from 'lucide-react'; import './Table.css'; export function Table(props: TableProps) { @@ -112,7 +113,18 @@ export function TableBody(props: TableBodyProps) { } export function Cell(props: CellProps) { - return ; + return ( + + {composeRenderProps(props.children, (children, {hasChildItems, isTreeColumn}) => (<> + {isTreeColumn && hasChildItems && + + } + {children} + ))} + + ) } export function TableLoadMoreItem(props: TableLoadMoreItemProps) { diff --git a/starters/tailwind/src/Table.tsx b/starters/tailwind/src/Table.tsx index df713c57bb8..689d9aaa3df 100644 --- a/starters/tailwind/src/Table.tsx +++ b/starters/tailwind/src/Table.tsx @@ -1,5 +1,5 @@ 'use client'; -import { ArrowUp } from 'lucide-react'; +import { ArrowUp, ChevronRight } from 'lucide-react'; import React from 'react'; import { Cell as AriaCell, @@ -138,8 +138,44 @@ const cellStyles = tv({ base: 'box-border [-webkit-tap-highlight-color:transparent] border-b border-b-neutral-200 dark:border-b-neutral-700 group-last/row:border-b-0 [--selected-border:var(--color-blue-200)] dark:[--selected-border:var(--color-blue-900)] group-selected/row:border-(--selected-border) [:is(:has(+[data-selected])_*)]:border-(--selected-border) p-2 truncate -outline-offset-2 group-last/row:first:rounded-bl-lg group-last/row:last:rounded-br-lg' }); +const expandButton = tv({ + extend: focusRing, + base: "border-0 p-0 pr-1 bg-transparent shrink-0 align-middle cursor-default [-webkit-tap-highlight-color:transparent]", + variants: { + isDisabled: { + true: 'text-neutral-300 dark:text-neutral-600 forced-colors:text-[GrayText]' + } + } +}); + +const chevron = tv({ + base: "w-4.5 h-4.5 text-neutral-500 dark:text-neutral-400 transition-transform duration-200 ease-in-out", + variants: { + isExpanded: { + true: "transform rotate-90", + }, + isDisabled: { + true: 'text-neutral-300 dark:text-neutral-600 forced-colors:text-[GrayText]' + } + } +}); + export function Cell(props: CellProps) { return ( - - ); + ({ + paddingInlineStart: isTreeColumn ? 4 + (hasChildItems ? 0 : 20) + (level - 1) * 16 : undefined + })}> + {composeRenderProps(props.children, (children, {hasChildItems, isTreeColumn, isExpanded, isDisabled}) => (<> + {hasChildItems && isTreeColumn && + + } + {children} + ))} + + ) } diff --git a/yarn.lock b/yarn.lock index 815ee641528..0d63538a20f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6124,6 +6124,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/table@workspace:packages/@react-aria/table" dependencies: + "@react-aria/button": "npm:^3.14.5" "@react-aria/focus": "npm:^3.21.5" "@react-aria/grid": "npm:^3.14.8" "@react-aria/i18n": "npm:^3.12.16"