diff --git a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx index de0d453601f..52b441d3a0b 100644 --- a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx @@ -17,7 +17,9 @@ import {ActionMenu} from '../src/ActionMenu'; import {checkers} from './check'; import {Content, Heading, Text} from '../src/Content'; import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; +import {DragBetweenLists, Reorderable} from '../stories/ListView.stories'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import {expect, userEvent, within} from 'storybook/test'; import File from '../s2wf-icons/S2_Icon_File_20_N.svg'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; @@ -261,3 +263,45 @@ export const EmptyState: Story = { ) }; + +export const InsertionIndicator: Story = { + ...Reorderable, + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('[Tab]'); + // TODO: strangely enough tabbing via user event actually focuses the drag handle and not just the row + // can't reproduce manually + // await userEvent.keyboard('[ArrowRight]'); + await userEvent.keyboard('[Enter]'); + let body = canvasElement.ownerDocument.body; + await within(body).findByText('Insert between Adobe Photoshop and Adobe XD'); + } +}; + +export const RootDrop: Story = { + ...DragBetweenLists, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('[Tab]'); + // await userEvent.keyboard('[ArrowRight]'); + await userEvent.keyboard('[Enter]'); + await userEvent.keyboard('[Tab]'); + expect(document.activeElement).toHaveRole('button'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on'); + } +}; + +export const OnFolderDrop: Story = { + ...DragBetweenLists, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('[Tab]'); + // await userEvent.keyboard('[ArrowRight]'); + await userEvent.keyboard('[Enter]'); + await userEvent.keyboard('[Tab]'); + await userEvent.keyboard('[ArrowDown]'); + await userEvent.keyboard('[ArrowDown]'); + expect(document.activeElement).toHaveRole('button'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Pictures'); + } +}; diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index 57dbf4fdde1..66d09dfb2a3 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -56,7 +56,7 @@ export {Image, ImageContext} from '../src/Image'; export {ImageCoordinator} from '../src/ImageCoordinator'; export {InlineAlert, InlineAlertContext} from '../src/InlineAlert'; export {Link, LinkContext} from '../src/Link'; -export {ListView, ListViewContext, ListViewItem} from '../src/ListView'; +export {ListView, ListViewContext, ListViewItem, ListViewDragPreview} from '../src/ListView'; export {MenuItem, MenuTrigger, Menu, MenuSection, SubmenuTrigger, UnavailableMenuItemTrigger, MenuContext} from '../src/Menu'; export {Meter, MeterContext} from '../src/Meter'; export {NotificationBadge, NotificationBadgeContext} from '../src/NotificationBadge'; @@ -77,7 +77,7 @@ export {Skeleton, useIsSkeleton} from '../src/Skeleton'; export {SkeletonCollection} from '../src/SkeletonCollection'; export {StatusLight, StatusLightContext} from '../src/StatusLight'; export {Switch, SwitchContext} from '../src/Switch'; -export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell} from '../src/TableView'; +export {TableView, TableHeader, TableBody, Row, Cell, Column, TableContext, EditableCell, TableViewDragPreview} from '../src/TableView'; export {Tabs, TabList, Tab, TabPanel, TabsContext} from '../src/Tabs'; export {TagGroup, Tag, TagGroupContext} from '../src/TagGroup'; export {TextArea, TextField, TextAreaContext, TextFieldContext} from '../src/TextField'; @@ -144,7 +144,7 @@ export type {InlineAlertProps} from '../src/InlineAlert'; export type {ImageProps} from '../src/Image'; export type {ImageCoordinatorProps} from '../src/ImageCoordinator'; export type {LinkProps} from '../src/Link'; -export type {ListViewProps, ListViewItemProps} from '../src/ListView'; +export type {ListViewProps, ListViewItemProps, ListViewDragPreviewProps} from '../src/ListView'; export type {MenuTriggerProps, MenuProps, MenuItemProps, MenuSectionProps, SubmenuTriggerProps, UnavailableMenuItemTriggerProps} from '../src/Menu'; export type {MeterProps} from '../src/Meter'; export type {NotificationBadgeProps} from '../src/NotificationBadge'; @@ -164,7 +164,7 @@ export type {SkeletonProps} from '../src/Skeleton'; export type {SkeletonCollectionProps} from '../src/SkeletonCollection'; export type {StatusLightProps} from '../src/StatusLight'; export type {SwitchProps} from '../src/Switch'; -export type {TableViewProps, TableHeaderProps, TableBodyProps, RowProps, CellProps, ColumnProps} from '../src/TableView'; +export type {TableViewProps, TableHeaderProps, TableBodyProps, RowProps, CellProps, ColumnProps, TableDragPreviewProps} from '../src/TableView'; export type {TabsProps, TabProps, TabListProps, TabPanelProps} from '../src/Tabs'; export type {TagGroupProps, TagProps} from '../src/TagGroup'; export type {TextFieldProps, TextAreaProps, TextFieldRef} from '../src/TextField'; diff --git a/packages/@react-spectrum/s2/intl/ar-AE.json b/packages/@react-spectrum/s2/intl/ar-AE.json index a66391647e6..fd9c15149d8 100644 --- a/packages/@react-spectrum/s2/intl/ar-AE.json +++ b/packages/@react-spectrum/s2/intl/ar-AE.json @@ -31,6 +31,7 @@ "slider.maximum": "أقصى", "slider.minimum": "أدنى", "table.cancel": "إلغاء", + "table.drag": "سحب", "table.editCell": "تعديل الخلية", "table.loading": "جارٍ التحميل...", "table.loadingMore": "جارٍ تحميل المزيد...", diff --git a/packages/@react-spectrum/s2/intl/bg-BG.json b/packages/@react-spectrum/s2/intl/bg-BG.json index c70ca77f057..e8c88d92877 100644 --- a/packages/@react-spectrum/s2/intl/bg-BG.json +++ b/packages/@react-spectrum/s2/intl/bg-BG.json @@ -31,6 +31,7 @@ "slider.maximum": "Максимум", "slider.minimum": "Минимум", "table.cancel": "Отказ", + "table.drag": "Плъзнете", "table.editCell": "Редактиране на клетка", "table.loading": "Зареждане...", "table.loadingMore": "Зареждане на още...", diff --git a/packages/@react-spectrum/s2/intl/cs-CZ.json b/packages/@react-spectrum/s2/intl/cs-CZ.json index 60ccac47a4c..e16ea158b89 100644 --- a/packages/@react-spectrum/s2/intl/cs-CZ.json +++ b/packages/@react-spectrum/s2/intl/cs-CZ.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Zrušit", + "table.drag": "Přetáhnout", "table.editCell": "Upravit buňku", "table.loading": "Načítání...", "table.loadingMore": "Načítání dalších...", diff --git a/packages/@react-spectrum/s2/intl/da-DK.json b/packages/@react-spectrum/s2/intl/da-DK.json index 005336329b0..7c32d0692ee 100644 --- a/packages/@react-spectrum/s2/intl/da-DK.json +++ b/packages/@react-spectrum/s2/intl/da-DK.json @@ -31,6 +31,7 @@ "slider.maximum": "Maksimum", "slider.minimum": "Minimum", "table.cancel": "Annuller", + "table.drag": "Træk", "table.editCell": "Rediger celle", "table.loading": "Indlæser...", "table.loadingMore": "Indlæser flere...", diff --git a/packages/@react-spectrum/s2/intl/de-DE.json b/packages/@react-spectrum/s2/intl/de-DE.json index 8e696210662..331ba998331 100644 --- a/packages/@react-spectrum/s2/intl/de-DE.json +++ b/packages/@react-spectrum/s2/intl/de-DE.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Abbrechen", + "table.drag": "Ziehen", "table.editCell": "Zelle bearbeiten", "table.loading": "Laden...", "table.loadingMore": "Mehr laden ...", diff --git a/packages/@react-spectrum/s2/intl/el-GR.json b/packages/@react-spectrum/s2/intl/el-GR.json index f4f7d60e37a..5a4bbe0f93c 100644 --- a/packages/@react-spectrum/s2/intl/el-GR.json +++ b/packages/@react-spectrum/s2/intl/el-GR.json @@ -31,6 +31,7 @@ "slider.maximum": "Μέγιστο", "slider.minimum": "Ελάχιστο", "table.cancel": "Ακύρωση", + "table.drag": "Μεταφορά", "table.editCell": "Επεξεργασία κελιού", "table.loading": "Φόρτωση...", "table.loadingMore": "Φόρτωση περισσότερων...", diff --git a/packages/@react-spectrum/s2/intl/en-US.json b/packages/@react-spectrum/s2/intl/en-US.json index c8375745930..1a1e1570bae 100644 --- a/packages/@react-spectrum/s2/intl/en-US.json +++ b/packages/@react-spectrum/s2/intl/en-US.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Cancel", + "table.drag": "Drag", "table.editCell": "Edit cell", "table.loading": "Loading…", "table.loadingMore": "Loading more…", diff --git a/packages/@react-spectrum/s2/intl/es-ES.json b/packages/@react-spectrum/s2/intl/es-ES.json index 6b6551ee497..04cacae4880 100644 --- a/packages/@react-spectrum/s2/intl/es-ES.json +++ b/packages/@react-spectrum/s2/intl/es-ES.json @@ -31,6 +31,7 @@ "slider.maximum": "Máximo", "slider.minimum": "Mínimo", "table.cancel": "Cancelar", + "table.drag": "Arrastrar", "table.editCell": "Editar celda", "table.loading": "Cargando…", "table.loadingMore": "Cargando más…", diff --git a/packages/@react-spectrum/s2/intl/et-EE.json b/packages/@react-spectrum/s2/intl/et-EE.json index a9ac34575f6..c5ef8d69641 100644 --- a/packages/@react-spectrum/s2/intl/et-EE.json +++ b/packages/@react-spectrum/s2/intl/et-EE.json @@ -31,6 +31,7 @@ "slider.maximum": "Maksimaalne", "slider.minimum": "Minimaalne", "table.cancel": "Tühista", + "table.drag": "Lohista", "table.editCell": "Muuda lahtrit", "table.loading": "Laadimine...", "table.loadingMore": "Laadi rohkem...", diff --git a/packages/@react-spectrum/s2/intl/fi-FI.json b/packages/@react-spectrum/s2/intl/fi-FI.json index 2abfc9a4c84..06a7af7a2bd 100644 --- a/packages/@react-spectrum/s2/intl/fi-FI.json +++ b/packages/@react-spectrum/s2/intl/fi-FI.json @@ -31,6 +31,7 @@ "slider.maximum": "Maksimi", "slider.minimum": "Minimi", "table.cancel": "Peruuta", + "table.drag": "Vedä", "table.editCell": "Muokkaa solua", "table.loading": "Ladataan…", "table.loadingMore": "Ladataan lisää…", diff --git a/packages/@react-spectrum/s2/intl/fr-FR.json b/packages/@react-spectrum/s2/intl/fr-FR.json index afef2ad5752..67907cc37ce 100644 --- a/packages/@react-spectrum/s2/intl/fr-FR.json +++ b/packages/@react-spectrum/s2/intl/fr-FR.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Annuler", + "table.drag": "Faire glisser", "table.editCell": "Modifier la cellule", "table.loading": "Chargement...", "table.loadingMore": "Chargement supplémentaire...", diff --git a/packages/@react-spectrum/s2/intl/he-IL.json b/packages/@react-spectrum/s2/intl/he-IL.json index 9fe25ac115b..4e20ed953c0 100644 --- a/packages/@react-spectrum/s2/intl/he-IL.json +++ b/packages/@react-spectrum/s2/intl/he-IL.json @@ -31,6 +31,7 @@ "slider.maximum": "מקסימום", "slider.minimum": "מינימום", "table.cancel": "ביטול", + "table.drag": "גרור", "table.editCell": "עריכת תא", "table.loading": "טוען...", "table.loadingMore": "טוען עוד...", diff --git a/packages/@react-spectrum/s2/intl/hr-HR.json b/packages/@react-spectrum/s2/intl/hr-HR.json index c566c400924..47c1d6efb7d 100644 --- a/packages/@react-spectrum/s2/intl/hr-HR.json +++ b/packages/@react-spectrum/s2/intl/hr-HR.json @@ -31,6 +31,7 @@ "slider.maximum": "Najviše", "slider.minimum": "Najmanje", "table.cancel": "Poništi", + "table.drag": "Povucite", "table.editCell": "Uredi ćeliju", "table.loading": "Učitavam...", "table.loadingMore": "Učitavam još...", diff --git a/packages/@react-spectrum/s2/intl/hu-HU.json b/packages/@react-spectrum/s2/intl/hu-HU.json index f82e54bec92..96421940d0b 100644 --- a/packages/@react-spectrum/s2/intl/hu-HU.json +++ b/packages/@react-spectrum/s2/intl/hu-HU.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Mégse", + "table.drag": "Húzás", "table.editCell": "Cella szerkesztése", "table.loading": "Betöltés folyamatban…", "table.loadingMore": "Továbbiak betöltése folyamatban…", diff --git a/packages/@react-spectrum/s2/intl/it-IT.json b/packages/@react-spectrum/s2/intl/it-IT.json index a66e379f5af..f3a63076f15 100644 --- a/packages/@react-spectrum/s2/intl/it-IT.json +++ b/packages/@react-spectrum/s2/intl/it-IT.json @@ -31,6 +31,7 @@ "slider.maximum": "Massimo", "slider.minimum": "Minimo", "table.cancel": "Annulla", + "table.drag": "Trascina", "table.editCell": "Modifica cella", "table.loading": "Caricamento...", "table.loadingMore": "Caricamento altri...", diff --git a/packages/@react-spectrum/s2/intl/ja-JP.json b/packages/@react-spectrum/s2/intl/ja-JP.json index bb06130fef8..7b5481cd72c 100644 --- a/packages/@react-spectrum/s2/intl/ja-JP.json +++ b/packages/@react-spectrum/s2/intl/ja-JP.json @@ -31,6 +31,7 @@ "slider.maximum": "最大", "slider.minimum": "最小", "table.cancel": "キャンセル", + "table.drag": "ドラッグ", "table.editCell": "セルを編集", "table.loading": "読み込み中...", "table.loadingMore": "さらに読み込み中...", diff --git a/packages/@react-spectrum/s2/intl/ko-KR.json b/packages/@react-spectrum/s2/intl/ko-KR.json index e010ac6591c..ed1cdefd539 100644 --- a/packages/@react-spectrum/s2/intl/ko-KR.json +++ b/packages/@react-spectrum/s2/intl/ko-KR.json @@ -31,6 +31,7 @@ "slider.maximum": "최대", "slider.minimum": "최소", "table.cancel": "취소", + "table.drag": "드래그", "table.editCell": "셀 편집", "table.loading": "로드 중…", "table.loadingMore": "추가 로드 중…", diff --git a/packages/@react-spectrum/s2/intl/lt-LT.json b/packages/@react-spectrum/s2/intl/lt-LT.json index e52c74583a6..14a5de70039 100644 --- a/packages/@react-spectrum/s2/intl/lt-LT.json +++ b/packages/@react-spectrum/s2/intl/lt-LT.json @@ -31,6 +31,7 @@ "slider.maximum": "Daugiausia", "slider.minimum": "Mažiausia", "table.cancel": "Atšaukti", + "table.drag": "Vilkti", "table.editCell": "Redaguoti langelį", "table.loading": "Įkeliama...", "table.loadingMore": "Įkeliama daugiau...", diff --git a/packages/@react-spectrum/s2/intl/lv-LV.json b/packages/@react-spectrum/s2/intl/lv-LV.json index 389ea6f8b33..ac009435241 100644 --- a/packages/@react-spectrum/s2/intl/lv-LV.json +++ b/packages/@react-spectrum/s2/intl/lv-LV.json @@ -31,6 +31,7 @@ "slider.maximum": "Maksimālā vērtība", "slider.minimum": "Minimālā vērtība", "table.cancel": "Atcelt", + "table.drag": "Vilkšana", "table.editCell": "Rediģēt šūnu", "table.loading": "Notiek ielāde...", "table.loadingMore": "Tiek ielādēts vēl...", diff --git a/packages/@react-spectrum/s2/intl/nb-NO.json b/packages/@react-spectrum/s2/intl/nb-NO.json index d53f0d8aa59..5a0cac5d422 100644 --- a/packages/@react-spectrum/s2/intl/nb-NO.json +++ b/packages/@react-spectrum/s2/intl/nb-NO.json @@ -31,6 +31,7 @@ "slider.maximum": "Maksimum", "slider.minimum": "Minimum", "table.cancel": "Avbryt", + "table.drag": "Dra", "table.editCell": "Rediger celle", "table.loading": "Laster inn...", "table.loadingMore": "Laster inn flere...", diff --git a/packages/@react-spectrum/s2/intl/nl-NL.json b/packages/@react-spectrum/s2/intl/nl-NL.json index a861126de64..b5efe3224ae 100644 --- a/packages/@react-spectrum/s2/intl/nl-NL.json +++ b/packages/@react-spectrum/s2/intl/nl-NL.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Annuleren", + "table.drag": "Slepen", "table.editCell": "Cel bewerken", "table.loading": "Laden...", "table.loadingMore": "Meer laden...", diff --git a/packages/@react-spectrum/s2/intl/pl-PL.json b/packages/@react-spectrum/s2/intl/pl-PL.json index 14104c6cbbc..f66771ac302 100644 --- a/packages/@react-spectrum/s2/intl/pl-PL.json +++ b/packages/@react-spectrum/s2/intl/pl-PL.json @@ -31,6 +31,7 @@ "slider.maximum": "Maksimum", "slider.minimum": "Minimum", "table.cancel": "Anuluj", + "table.drag": "Przeciągnij", "table.editCell": "Edytuj komórkę", "table.loading": "Wczytywanie...", "table.loadingMore": "Wczytywanie większej liczby...", diff --git a/packages/@react-spectrum/s2/intl/pt-BR.json b/packages/@react-spectrum/s2/intl/pt-BR.json index b9f826287db..fc920093184 100644 --- a/packages/@react-spectrum/s2/intl/pt-BR.json +++ b/packages/@react-spectrum/s2/intl/pt-BR.json @@ -31,6 +31,7 @@ "slider.maximum": "Máximo", "slider.minimum": "Mínimo", "table.cancel": "Cancelar", + "table.drag": "Arraste", "table.editCell": "Editar célula", "table.loading": "Carregando...", "table.loadingMore": "Carregando mais...", diff --git a/packages/@react-spectrum/s2/intl/pt-PT.json b/packages/@react-spectrum/s2/intl/pt-PT.json index bb6acd6c981..0c309cd582e 100644 --- a/packages/@react-spectrum/s2/intl/pt-PT.json +++ b/packages/@react-spectrum/s2/intl/pt-PT.json @@ -31,6 +31,7 @@ "slider.maximum": "Máximo", "slider.minimum": "Mínimo", "table.cancel": "Cancelar", + "table.drag": "Arrastar", "table.editCell": "Editar célula", "table.loading": "A carregar...", "table.loadingMore": "A carregar mais...", diff --git a/packages/@react-spectrum/s2/intl/ro-RO.json b/packages/@react-spectrum/s2/intl/ro-RO.json index 050df91e413..a194f3dd837 100644 --- a/packages/@react-spectrum/s2/intl/ro-RO.json +++ b/packages/@react-spectrum/s2/intl/ro-RO.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Anulare", + "table.drag": "Trageți", "table.editCell": "Editați celula", "table.loading": "Se încarcă...", "table.loadingMore": "Se încarcă mai multe...", diff --git a/packages/@react-spectrum/s2/intl/ru-RU.json b/packages/@react-spectrum/s2/intl/ru-RU.json index cfb6c4d1ded..1548b59d0b0 100644 --- a/packages/@react-spectrum/s2/intl/ru-RU.json +++ b/packages/@react-spectrum/s2/intl/ru-RU.json @@ -31,6 +31,7 @@ "slider.maximum": "Максимум", "slider.minimum": "Минимум", "table.cancel": "Отмена", + "table.drag": "Перетаскивание", "table.editCell": "Редактировать ячейку", "table.loading": "Загрузка...", "table.loadingMore": "Дополнительная загрузка...", diff --git a/packages/@react-spectrum/s2/intl/sk-SK.json b/packages/@react-spectrum/s2/intl/sk-SK.json index a29590ac115..26bea942988 100644 --- a/packages/@react-spectrum/s2/intl/sk-SK.json +++ b/packages/@react-spectrum/s2/intl/sk-SK.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Zrušiť", + "table.drag": "Presunúť", "table.editCell": "Upraviť bunku", "table.loading": "Načítava sa...", "table.loadingMore": "Načítava sa viac...", diff --git a/packages/@react-spectrum/s2/intl/sl-SI.json b/packages/@react-spectrum/s2/intl/sl-SI.json index 39656e853b5..75cd20ed807 100644 --- a/packages/@react-spectrum/s2/intl/sl-SI.json +++ b/packages/@react-spectrum/s2/intl/sl-SI.json @@ -31,6 +31,7 @@ "slider.maximum": "Največji", "slider.minimum": "Najmanj", "table.cancel": "Prekliči", + "table.drag": "Povleci", "table.editCell": "Uredi celico", "table.loading": "Nalaganje...", "table.loadingMore": "Nalaganje več vsebine...", diff --git a/packages/@react-spectrum/s2/intl/sr-SP.json b/packages/@react-spectrum/s2/intl/sr-SP.json index d6e89eb94fb..4bfa3ae75e1 100644 --- a/packages/@react-spectrum/s2/intl/sr-SP.json +++ b/packages/@react-spectrum/s2/intl/sr-SP.json @@ -31,6 +31,7 @@ "slider.maximum": "Najviše", "slider.minimum": "Najmanje", "table.cancel": "Otkaži", + "table.drag": "Prevuci", "table.editCell": "Uredi ćeliju", "table.loading": "Učitavam...", "table.loadingMore": "Učitavam još...", diff --git a/packages/@react-spectrum/s2/intl/sv-SE.json b/packages/@react-spectrum/s2/intl/sv-SE.json index 12026081606..c7229bc8e55 100644 --- a/packages/@react-spectrum/s2/intl/sv-SE.json +++ b/packages/@react-spectrum/s2/intl/sv-SE.json @@ -31,6 +31,7 @@ "slider.maximum": "Maximum", "slider.minimum": "Minimum", "table.cancel": "Avbryt", + "table.drag": "Dra", "table.editCell": "Redigera cell", "table.loading": "Läser in...", "table.loadingMore": "Läser in mer...", diff --git a/packages/@react-spectrum/s2/intl/tr-TR.json b/packages/@react-spectrum/s2/intl/tr-TR.json index ee8f9b014a6..e0c2c26654d 100644 --- a/packages/@react-spectrum/s2/intl/tr-TR.json +++ b/packages/@react-spectrum/s2/intl/tr-TR.json @@ -31,6 +31,7 @@ "slider.maximum": "Maksimum", "slider.minimum": "Minimum", "table.cancel": "İptal et", + "table.drag": "Sürükle", "table.editCell": "Hücreyi düzenle", "table.loading": "Yükleniyor...", "table.loadingMore": "Daha fazla yükleniyor...", diff --git a/packages/@react-spectrum/s2/intl/uk-UA.json b/packages/@react-spectrum/s2/intl/uk-UA.json index 1446a24e72e..cd6a894956b 100644 --- a/packages/@react-spectrum/s2/intl/uk-UA.json +++ b/packages/@react-spectrum/s2/intl/uk-UA.json @@ -31,6 +31,7 @@ "slider.maximum": "Максимум", "slider.minimum": "Мінімум", "table.cancel": "Скасувати", + "table.drag": "Перетягнути", "table.editCell": "Редагувати клітинку", "table.loading": "Завантаження…", "table.loadingMore": "Завантаження інших об’єктів...", diff --git a/packages/@react-spectrum/s2/intl/zh-CN.json b/packages/@react-spectrum/s2/intl/zh-CN.json index d2d266cbc94..a385d658555 100644 --- a/packages/@react-spectrum/s2/intl/zh-CN.json +++ b/packages/@react-spectrum/s2/intl/zh-CN.json @@ -31,6 +31,7 @@ "slider.maximum": "最大", "slider.minimum": "最小", "table.cancel": "取消", + "table.drag": "拖动", "table.editCell": "编辑单元格", "table.loading": "正在加载...", "table.loadingMore": "正在加载更多...", diff --git a/packages/@react-spectrum/s2/intl/zh-TW.json b/packages/@react-spectrum/s2/intl/zh-TW.json index ed50a588af8..48caecc340c 100644 --- a/packages/@react-spectrum/s2/intl/zh-TW.json +++ b/packages/@react-spectrum/s2/intl/zh-TW.json @@ -31,6 +31,7 @@ "slider.maximum": "最大值", "slider.minimum": "最小值", "table.cancel": "取消", + "table.drag": "拖曳", "table.editCell": "編輯儲存格", "table.loading": "載入中…", "table.loadingMore": "正在載入更多…", diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 6e5c8673fb4..b5fecf47498 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -13,6 +13,7 @@ import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; import {baseColor, colorMix, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; +import {Button} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; import {Checkbox} from './Checkbox'; import {CheckboxContext} from 'react-aria-components/Checkbox'; @@ -22,8 +23,9 @@ import {CollectionRendererContext, DefaultCollectionRenderer} from 'react-aria-c import {ContextValue, DEFAULT_SLOT, Provider, SlotProps, useSlottedContext} from 'react-aria-components/slots'; import {controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {createContext, forwardRef, ReactElement, ReactNode, useContext, useRef} from 'react'; -import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LoadingState} from '@react-types/shared'; -import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; +import {DOMProps, DOMRef, DOMRefValue, DragItem, forwardRefType, GlobalDOMAttributes, ItemDropTarget, LoadingState} from '@react-types/shared'; +import DragHandle from '../ui-icons/DragHandle'; +import {DropIndicator} from 'react-aria-components/useDragAndDrop'; import { GridList, GridListItem, @@ -35,8 +37,10 @@ import { } from 'react-aria-components/GridList'; import {IconContext} from './Icon'; import {ImageContext} from './Image'; +// @ts-ignore import intlMessages from '../intl/*.json'; import {Key} from '@react-types/shared'; +import {LayoutInfo, Virtualizer} from 'react-aria-components/Virtualizer'; import LinkOutIcon from '../ui-icons/LinkOut'; import {ListLayout} from 'react-stately/useVirtualizerState'; // @ts-ignore @@ -45,13 +49,14 @@ import {ProgressCircle} from './ProgressCircle'; import {Text, TextContext} from './Content'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from './useDOMRef'; +import {useFocusRing} from 'react-aria/useFocusRing'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import {Virtualizer} from 'react-aria-components/Virtualizer'; +import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; -export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | 'selectionBehavior' | 'dragAndDropHooks' | 'layout' | 'render' | 'keyboardNavigationBehavior' | 'orientation' | keyof GlobalDOMAttributes>, DOMProps, UnsafeStyles, ListViewStylesProps, SlotProps { +export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | 'selectionBehavior' | 'layout' | 'render' | 'keyboardNavigationBehavior' | 'orientation' | keyof GlobalDOMAttributes>, DOMProps, UnsafeStyles, ListViewStylesProps, SlotProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight, /** The current loading state of the ListView. */ @@ -108,7 +113,8 @@ const listViewWrapper = style({ // When any row has a trailing icon, reserve space so actions align. const hasTrailingIconRows = ':has([data-has-trailing-icon]) [role="row"]'; -const listView = style({ +const dropTargetBackground = colorMix('gray-25', 'blue-900', 10); +const listView = style({ ...focusRing(), outlineOffset: { default: -2, @@ -125,18 +131,37 @@ const listView = style({ backgroundColor: { default: 'gray-25', isQuiet: 'transparent', - forcedColors: 'Background' + isDropTarget: { + default: dropTargetBackground, + forcedColors: 'Background' + } }, borderRadius: { default: 'default', isQuiet: 'none' }, - borderColor: 'gray-300', + borderColor: { + default: 'gray-300', + isDropTarget: 'blue-800', + forcedColors: { + isDropTarget: 'Highlight' + } + }, borderWidth: { default: 1, isQuiet: 0 }, borderStyle: 'solid', + // TODO: cant do a external box shadow due to the clipping that is applied on the wrapper element... + // an inset box shadow here runs into problems with the item background clipping the box shadow... + // do we wanna hack it to support a 2px indicator for root drop or is 1px enough + // boxShadow: { + // isDropTarget: `[inset 0 0 0 1px ${color('blue-800')}]`, + // forcedColors: { + // isDropTarget: '[inset 0 0 0 1px Highlight]' + // } + // }, + forcedColorAdjust: 'none', '--trailing-icon-width': { type: 'width', value: { @@ -146,6 +171,14 @@ const listView = style({ } }); +export class S2ListLayout extends ListLayout { + getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + let layoutInfo = super.getDropTargetLayoutInfo(target); + layoutInfo.zIndex = 1; + return layoutInfo; + } +} + /** * A ListView displays a list of interactive items, and allows a user to navigate, select, or perform an action. */ @@ -154,10 +187,21 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li ref: DOMRef ) { [props, ref] = useSpectrumContextProps(props, ref, ListViewContext); - let {children, isQuiet, selectionStyle = 'checkbox', overflowMode = 'truncate', loadingState, onLoadMore, renderEmptyState: userRenderEmptyState, hideLinkOutIcon = false, ...otherProps} = props; + let {children, isQuiet, selectionStyle = 'checkbox', overflowMode = 'truncate', loadingState, onLoadMore, renderEmptyState: userRenderEmptyState, hideLinkOutIcon = false, dragAndDropHooks, ...otherProps} = props; let scale = useScale(); + + if (dragAndDropHooks && dragAndDropHooks.renderDragPreview == null) { + dragAndDropHooks.renderDragPreview = (items) => ; + } + + if (dragAndDropHooks) { + dragAndDropHooks.renderDropIndicator = (target) => ; + } + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let rowHeight = scale === 'large' ? 50 : 40; + // 8 + 2 + 2 aka circle height + the circle thickness * 2 + let dropIndicatorThickness = scale === 'large' ? 15 : 12; let domRef = useDOMRef(ref); let scrollRef = useRef(null); @@ -226,15 +270,17 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li className={(props.UNSAFE_className || '') + listViewWrapper(null, props.styles)} style={props.UNSAFE_style}> listView({ ...renderProps, - isQuiet + isQuiet, + isDropTarget: renderProps.isDropTarget })} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -261,6 +308,8 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li const selectedBackground = colorMix('gray-25', 'gray-900', 7); const selectedActiveBackground = colorMix('gray-25', 'gray-900', 10); +// TODO: removed the background color in HCM for highlight selection since it made it hard to see the focus +// ring of the drag button, this matches v3 anyways. thoughts? const listitem = style({ - outlineStyle: 'none', + outlineStyle: { + default: 'none', + isDropTarget: 'solid' + }, + outlineWidth: { + isDropTarget: 2 + }, + outlineOffset: { + isDropTarget: -2 + }, + outlineColor: { + isDropTarget: 'blue-800', + forcedColors: { + isDropTarget: 'Highlight' + } + }, boxSizing: 'border-box', columnGap: 0, paddingX: 0, @@ -288,11 +353,6 @@ const listitem = style({ position: 'absolute', zIndex: -1, @@ -416,14 +492,11 @@ const listRowBackground = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + // TODO: arbitrary, basically taken from v3 + height: 22, + width: 10, + padding: 0, + margin: 0, + backgroundColor: 'transparent', + borderStyle: 'none', + borderRadius: 'sm', + // TODO: this mimicks v3 too, do we want halo focus ring? + outlineStyle: { + default: 'none', + isFocusVisible: 'solid' + }, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + outlineWidth: 2, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); + +export let dragPreviewWrapper = style({ + position: 'relative' +}); + +export let dragPreviewCardBack = style({ + position: 'absolute', + zIndex: -1, + top: 4, + left: 4, + width: 200, + height: 'full', + borderRadius: 'default', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'blue-900', + backgroundColor: 'gray-25' +}); + +export let dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ + boxSizing: 'border-box', + paddingX: 0, + paddingY: 8, + backgroundColor: 'gray-25', + color: baseColor('neutral'), + position: 'relative', + display: 'grid', + gridTemplateAreas: [ + '. icon label badge .', + '. . description badge .' + ], + gridTemplateColumns: [12, 'auto', 'minmax(0, 1fr)', 'auto', 6], + gridTemplateRows: '1fr auto', + alignItems: 'baseline', + minHeight: { + default: 40, + scale: { + large: 50 + } + }, + width: 200, + borderRadius: 'default', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'blue-900' +}); + +export let dragPreviewBadge = style({ + gridArea: 'badge', + alignSelf: 'center', + paddingX: 8, + paddingY: 2, + borderRadius: 'sm', + backgroundColor: { + default: 'blue-900', + forcedColors: 'Highlight' + }, + font: 'ui-sm', + fontWeight: 'bold', + color: { + default: 'white', + forcedColors: 'HighlightText' + }, + forcedColorAdjust: 'none' +}); + +let insertionIndicatorWrapper = style({ + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center' +}); + +export let insertionIndicatorBar = style<{isDropTarget?: boolean}>({ + flexGrow: 1, + height: 2, + backgroundColor: { + default: 'transparent', + isDropTarget: 'blue-800', + forcedColors: { + isDropTarget: 'Highlight' + } + }, + borderBottomWidth: { + default: 0, + isDropTarget: 2 + }, + borderColor: { + isDropTarget: 'blue-800', + forcedColors: { + isDropTarget: 'Highlight' + } + }, + forcedColorAdjust: 'none' +}); + +export let insertionIndicatorCircle = style<{isDropTarget: boolean}>({ + width: 8, + height: 8, + borderRadius: 'full', + borderWidth: { + isDropTarget: 2 + }, + borderStyle: { + isDropTarget: 'solid' + }, + borderColor: { + isDropTarget: 'blue-800', + forcedColors: { + isDropTarget: 'Highlight' + } + }, + backgroundColor: { + isDropTarget: 'gray-25', + forcedColors: { + default: 'transparent', + isDropTarget: 'Background' + } + }, + forcedColorAdjust: 'none' +}); + const centeredWrapper = style({ display: 'flex', alignItems: 'center', @@ -695,6 +930,25 @@ const emptyStateWrapper = style({ padding: 16 }); +// TODO new problem with the styling here is that the last insertion indicator wants to have a reduced height +// so it doesn't cause the collection height to increase when it appears. This however makes the styling of the indicator difficult since +// it shouldn't have align items center anymore then... We also don't have enough information at this level to know if the drop +// insertion indicator is for the last one in the list since we don't have access to the whole collection. is it worth it or should +// we just be fine with the bottom indicator causing a bit of a height increase? +export function InsertionIndicator({target}: {target: ItemDropTarget}) { + return ( + + {({isDropTarget}) => ( +
+
+
+
+
+ )} + + ); +} + function ListSelectionCheckbox({isDisabled}: {isDisabled: boolean}) { let selectionContext = useSlottedContext(CheckboxContext, 'selection'); let isSelectionDisabled = isDisabled || !!selectionContext?.isDisabled; @@ -733,6 +987,54 @@ function isLastItem(id: Key | undefined, state: ListState) { return state.collection.getLastKey() === id; } +export interface ListViewDragPreviewProps { + /** The currently dragged items, sourced from renderDragPreview. */ + items: DragItem[], + /** The overflow mode to be applied on the drag preview. */ + overflowMode: ListViewStylesProps['overflowMode'], + /** + * The contents of the drag preview. Supports the "label", "description", and "icon" slots. + * If no children are provided, defaults to the first drag item's plain text content. + */ + children?: ReactNode +} + +export function ListViewDragPreview(props: ListViewDragPreviewProps) { + let {items, overflowMode} = props; + let isDraggingMultiple = items.length > 1; + let itemLabel = items[0]?.['text/plain'] ?? ''; + let scale = useScale(); + + return ( +
+ {isDraggingMultiple &&
} +
+ + {props.children ?? {itemLabel}} + {isDraggingMultiple && ( +
{items.length}
+ )} +
+
+
+ ); +} + export function ListViewItem(props: ListViewItemProps): ReactNode { let ref = useRef(null); let {hasChildItems, ...otherProps} = props; @@ -742,6 +1044,11 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { let textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); let {direction} = useLocale(); let hasTrailingIcon = hasChildItems || (isLinkOut && !hideLinkOutIcon); + let {visuallyHiddenProps} = useVisuallyHidden(); + let { + isFocusVisible: isFocusVisibleWithin, + focusProps: focusWithinProps + } = useFocusRing({within: true}); return ( {(renderProps) => { let {children} = props; - let {selectionMode, selectionBehavior, isDisabled, id, state} = renderProps; + let {selectionMode, selectionBehavior, isDisabled, id, state, allowsDragging, isFocusVisible} = renderProps; return ( -
- {renderProps.isFocusVisible && +
- } - {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( - - )} - {typeof children === 'string' ? {children} : children} - {isLinkOut && !hideLinkOutIcon && ( -
- + {renderProps.isFocusVisible && +
+ } + {allowsDragging && ( +
+ {!isDisabled && ( + + )} +
+ )} + {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( + + )} + {typeof children === 'string' ? {children} : children} + {isLinkOut && !hideLinkOutIcon && ( +
+ -
- )} - {hasChildItems && !isLinkOut && ( -
- +
+ )} + {hasChildItems && !isLinkOut && ( +
+ -
- )} + })({direction})} /> +
+ )} +
); }} diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index cdb2a1ecf04..de916f011b5 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -11,7 +11,7 @@ */ import {ActionButton, ActionButtonContext} from './ActionButton'; -import {baseColor, centerPadding, colorMix, focusRing, fontRelative, lightDark, setColorScheme, space, style} from '../style' with {type: 'macro'}; +import {baseColor, centerPadding, color, colorMix, focusRing, fontRelative, lightDark, setColorScheme, space, style} from '../style' with {type: 'macro'}; import {Button, ButtonContext} from 'react-aria-components/Button'; import {ButtonGroup} from './ButtonGroup'; import { @@ -49,27 +49,32 @@ import {controlFont, getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} fr import {css} from '../style/style-macro' with {type: 'macro'}; import {CustomDialog} from './CustomDialog'; import {DialogContainer} from './DialogContainer'; -import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LinkDOMProps, LoadingState, Node} from '@react-types/shared'; +import {DOMProps, DOMRef, DOMRefValue, DragItem, forwardRefType, GlobalDOMAttributes, ItemDropTarget, LinkDOMProps, LoadingState, Node} from '@react-types/shared'; +import DragHandle from '../ui-icons/DragHandle'; +import {dragPreviewBadge, dragPreviewCardBack, dragPreviewWrapper, InsertionIndicator, label} from './ListView'; +import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; import {Form} from 'react-aria-components/Form'; import {getActiveElement, isFocusWithin, nodeContains} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from 'react-aria/private/utils/domHelpers'; import {GridNode} from 'react-stately/private/grid/GridCollection'; import {IconContext} from './Icon'; +// @ts-ignore import intlMessages from '../intl/*.json'; import {Key} from '@react-types/shared'; +import {LayoutInfo, Rect, TableLayout, Virtualizer} from 'react-aria-components/Virtualizer'; import {LayoutNode} from 'react-stately/useVirtualizerState'; import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; import {OverlayTriggerStateContext} from 'react-aria-components/Dialog'; import {ProgressCircle} from './ProgressCircle'; -import {CheckboxContext as RACCheckboxContext} from 'react-aria-components/Checkbox'; // @ts-ignore +import {CheckboxContext as RACCheckboxContext} from 'react-aria-components/Checkbox'; import {Popover as RACPopover} from 'react-aria-components/Popover'; import React, {createContext, CSSProperties, FormEvent, FormHTMLAttributes, ForwardedRef, forwardRef, ReactElement, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import {Rect, TableLayout, Virtualizer} from 'react-aria-components/Virtualizer'; import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {Button as SpectrumButton} from './Button'; +import {Text, TextContext} from './Content'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from './useDOMRef'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; @@ -121,7 +126,7 @@ interface S2TableProps { } // TODO: Note that loadMore and loadingState are now on the Table instead of on the TableBody -export interface TableViewProps extends Omit, DOMProps, UnsafeStyles, S2TableProps { +export interface TableViewProps extends Omit, DOMProps, UnsafeStyles, S2TableProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight } @@ -139,7 +144,8 @@ const tableWrapper = style({ overflow: 'clip' }, getAllowedOverrides({height: true})); -const table = style({ +const dropTargetBackground = colorMix('gray-25', 'blue-900', 10); +const table = style({ width: 'full', height: 'full', boxSizing: 'border-box', @@ -152,16 +158,22 @@ const table = style({ + boxSizing: 'border-box', + paddingX: 0, + paddingY: 8, + backgroundColor: 'gray-25', + color: baseColor('neutral'), + position: 'relative', + display: 'grid', + gridTemplateAreas: [ + '. label badge .' + ], + gridTemplateColumns: [edgeToText(40), 'minmax(0, 1fr)', 'auto', edgeToText(40)], + gridTemplateRows: 'auto', + alignItems: 'baseline', + minHeight: { + default: 40, + scale: { + large: 50 + } + }, + width: 200, + borderRadius: 'default', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'blue-900' +}); + +export interface TableDragPreviewProps { + /** The currently dragged items, sourced from renderDragPreview. */ + items: DragItem[], + /** The overflow mode to be applied on the drag preview. */ + overflowMode: S2TableProps['overflowMode'], + /** + * The contents of the drag preview. Supports the default text slot. + * If no children are provided, defaults to the first drag item's plain text content. + */ + children?: ReactNode +} + +export function TableViewDragPreview(props: TableDragPreviewProps) { + let {items, overflowMode} = props; + let isDraggingMultiple = items.length > 1; + let itemLabel = items[0]?.['text/plain'] ?? ''; + let scale = useScale(); + + return ( +
+ {isDraggingMultiple &&
} +
+ + {props.children ?? {itemLabel}} + {isDraggingMultiple && ( +
{items.length}
+ )} +
+
+
+ ); +} + // component-height-100 const DEFAULT_HEADER_HEIGHT = { medium: 32, @@ -273,6 +358,12 @@ export class S2TableLayout extends TableLayout { layoutNode.layoutInfo.allowOverflow = true; return layoutNode; } + + getDropTargetLayoutInfo(target: ItemDropTarget): LayoutInfo { + let layoutInfo = super.getDropTargetLayoutInfo(target); + layoutInfo.zIndex = 1; + return layoutInfo; + } } export const TableContext = createContext, DOMRefValue>>(null); @@ -296,11 +387,23 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onAction, onLoadMore, selectionMode = 'none', + dragAndDropHooks, + disabledBehavior = 'all', ...otherProps } = props; + if (dragAndDropHooks && dragAndDropHooks.renderDragPreview == null) { + dragAndDropHooks.renderDragPreview = (items) => ; + } + + if (dragAndDropHooks) { + dragAndDropHooks.renderDropIndicator = (target) => ; + } + let domRef = useDOMRef(ref); let scale = useScale(); + // 8px circle + 2px top + 2px bottom padding + let dropIndicatorThickness = scale === 'large' ? 15 : 12; // Starts when the user selects resize from the menu, ends when resizing ends // used to control the visibility of the resizer Nubbin @@ -321,11 +424,13 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onLoadMore, isInResizeMode, setIsInResizeMode, - selectionMode - }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionMode]); + selectionMode, + disabledBehavior + }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionMode, disabledBehavior]); let scrollRef = useRef(null); let isCheckboxSelection = selectionMode === 'multiple' || selectionMode === 'single'; + let isDragAndDrop = !!dragAndDropHooks?.useDraggableCollectionState; let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); @@ -349,7 +454,8 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re : undefined, // No need for estimated headingHeight since the headers aren't affected by overflow mode: wrap headingHeight: DEFAULT_HEADER_HEIGHT[scale], - loaderHeight: 60 + loaderHeight: 60, + dropIndicatorThickness }}> table({ ...renderProps, isCheckboxSelection, + isDragAndDrop, isQuiet })} selectionBehavior="toggle" selectionMode={selectionMode} onRowAction={onAction} + dragAndDropHooks={dragAndDropHooks} + disabledBehavior={disabledBehavior} {...otherProps} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -908,15 +1017,27 @@ export interface TableHeaderProps extends Omit, 'style */ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function TableHeader({columns, dependencies, children}: TableHeaderProps, ref: DOMRef) { let scale = useScale(); - let {selectionBehavior, selectionMode} = useTableOptions(); + let {selectionBehavior, selectionMode, allowsDragging} = useTableOptions(); let {isQuiet} = useContext(InternalTableContext); let domRef = useDOMRef(ref); + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); return ( ( + {allowsDragging && ( + // @ts-ignore + + {({isFocusVisible}) => ( + <> + {isFocusVisible && } + {stringFormatter.format('table.drag')} + + )} + + )} {/* Add extra columns for selection. */} {selectionBehavior === 'toggle' && ( // Also isSticky prop is applied just for the layout, will decide what the RAC api should be later @@ -1016,9 +1137,26 @@ const stickyCell = { backgroundColor: 'gray-25' } as const; +// Bit gross but this is needed because the sticky cells currently cover/partially cover styles that the row applies so that +// they don't appear when the table is scrolled. The below basically just continues the inset box-shadow that the row has when +// it is focused as a drop target +const rowDropTargetStickyOutline = { + boxShadow: { + default: 'none', + ':is([role="row"][data-drop-target] *)': { + default: `[inset 0 2px 0 0 ${color('blue-800')}, inset 0 -2px 0 0 ${color('blue-800')}]`, + forcedColors: '[inset 0 2px 0 0 Highlight, inset 0 -2px 0 0 Highlight]' + }, + ':is([role="row"][data-focus-visible] *)': { + forcedColors: '[inset 0 2px 0 0 Highlight, inset 0 -2px 0 0 Highlight]' + } + } +} as const; + const checkboxCellStyle = style({ ...commonCellStyles, ...stickyCell, + ...rowDropTargetStickyOutline, display: 'flex', paddingStart: 16, paddingEnd: 8, @@ -1030,6 +1168,68 @@ const checkboxCellStyle = style({ backgroundColor: '--rowBackgroundColor' }); +const dragCellStyle = style({ + ...commonCellStyles, + ...stickyCell, + ...rowDropTargetStickyOutline, + paddingStart: 4, + paddingEnd: 4, + alignContent: 'center', + height: 'calc(100% - 1px)', + borderBottomWidth: 0, + backgroundColor: '--rowBackgroundColor' +}); + +const dragButton = style({ + alignItems: 'center', + justifyContent: 'center', + padding: 0, + backgroundColor: 'transparent', + borderStyle: 'none', + borderRadius: 'sm', + outlineStyle: { + default: 'none', + isFocusVisible: 'solid' + }, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + outlineWidth: 2, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, + // note that this doesn't have clip or clipPath, but this seems to be sufficient + height: { + default: 1, + ':is([role="row"][data-focus-visible-within] *)': 22 + }, + width: { + default: 1, + ':is([role="row"][data-focus-visible-within] *)': 10 + }, + margin: { + default: '[-1]', + ':is([role="row"][data-focus-visible-within] *)': 0 + }, + overflow: { + default: 'hidden', + ':is([role="row"][data-focus-visible-within] *)': 'visible' + }, + position: { + default: 'absolute', + ':is([role="row"][data-focus-visible-within] *)': 'relative' + }, + whiteSpace: { + default: 'nowrap', + ':is([role="row"][data-focus-visible-within] *)': 'normal' + }, + display: { + ':is([role="row"][data-focus-visible-within] *)': 'flex' + } +}); + const cellContent = style({ truncate: { default: true, @@ -1497,6 +1697,25 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, // Use color-mix instead of transparency so sticky cells work correctly. const selectedBackground = colorMix('gray-25', 'gray-900', 7); const selectedActiveBackground = colorMix('gray-25', 'gray-900', 10); +// TODO: I made these up, not sure if there is a great way to go from v3 values to +// S2. Overally the root drop color should be lighter than the row color during a root drop +// which should be lighter than a selected row during root drop. Those root drop row colors should also be darker +// than if the row is the drop target itself +const rootDropRowBackground = colorMix('gray-25', 'blue-900', 17); +const rootDropSelectedRowBackground = colorMix('gray-25', 'blue-900', 28); +const rowDropBackground = colorMix('gray-25', 'blue-900', 10); +const rowDropSelectedBackground = colorMix('gray-25', 'blue-900', 15); +const rootRowDropStyles = { + default: rootDropRowBackground, + isSelected: rootDropSelectedRowBackground, + forcedColors: 'Background' +} as const; +const rowDropStyles = { + default: rowDropBackground, + isSelected: rowDropSelectedBackground, + forcedColors: 'Background' +} as const; + const rowBackgroundColor = { default: { default: 'gray-25', @@ -1513,7 +1732,9 @@ const rowBackgroundColor = { }, forcedColors: { default: 'Background' - } + }, + ':is([role="grid"][data-drop-target] *)': rootRowDropStyles, + isDropTarget: rowDropStyles } as const; const rowTextColor = { @@ -1546,31 +1767,14 @@ const row = style({ forcedColors: 'Highlight' } }, - // TODO: outline here is to emulate v3 forcedColors experience but runs into the same problem where the sticky column covers the outline - // This doesn't quite work because it gets cut off by the checkbox cell background masking element, figure out another way. Could shrink the checkbox cell's content even more - // and offset it by margin top but that messes up the checkbox centering a bit - // outlineWidth: { - // forcedColors: { - // isFocusVisible: 2 - // } - // }, - // outlineOffset: { - // forcedColors: { - // isFocusVisible: -1 - // } - // }, - // outlineColor: { - // forcedColors: { - // isFocusVisible: 'ButtonBorder' - // } - // }, - // outlineStyle: { - // default: 'none', - // forcedColors: { - // isFocusVisible: 'solid' - // } - // }, outlineStyle: 'none', + boxShadow: { + isDropTarget: `[inset 0 0 0 2px ${color('blue-800')}]`, + forcedColors: { + isDropTarget: '[inset 0 0 0 2px Highlight]', + isFocusVisible: '[inset 0 0 0 2px Highlight]' + } + }, borderTopWidth: 0, borderBottomWidth: 1, borderStartWidth: 0, @@ -1596,7 +1800,7 @@ export interface RowProps extends Pick, 'id' | 'columns' | 'is * A row within a ``. */ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row({id, columns, children, dependencies = [], ...otherProps}: RowProps, ref: DOMRef) { - let {selectionBehavior, selectionMode} = useTableOptions(); + let {selectionBehavior, selectionMode, allowsDragging} = useTableOptions(); let tableVisualOptions = useContext(InternalTableContext); let domRef = useDOMRef(ref); @@ -1609,8 +1813,21 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row row({ ...renderProps, ...tableVisualOptions - }) + (renderProps.isFocusVisible ? ' ' + css('&:before { content: ""; display: inline-block; position: sticky; inset-inline-start: 0; width: 3px; height: 100%; margin-inline-end: -3px; margin-block-end: 1px; z-index: 3; background-color: var(--rowFocusIndicatorColor)') : '')} + }) + (renderProps.isFocusVisible || renderProps.isDropTarget ? ' ' + css('&:before { content: ""; display: inline-block; position: sticky; inset-inline-start: 0; width: 3px; height: 100%; margin-inline-end: -3px; margin-block-end: 1px; z-index: 3; background-color: var(--rowFocusIndicatorColor)') : '')} {...otherProps}> + {allowsDragging && ( + // @ts-ignore + + {!(otherProps.isDisabled && tableVisualOptions.disabledBehavior === 'all') && ( + + ) + } + + )} {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // Not sure what we want to do with this className, in Cell it currently overrides the className that would have been applied. // The `spread` otherProps must be after className in Cell. diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 1515dd47384..25342da1031 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -16,18 +16,18 @@ import {baseColor, colorMix, focusRing, fontRelative, style} from '../style' wit import {Button, ButtonContext} from 'react-aria-components/Button'; import {centerBaseline} from './CenterBaseline'; import {Checkbox} from './Checkbox'; - import Chevron from '../ui-icons/Chevron'; - -import {DOMRef, forwardRefType, GlobalDOMAttributes, Key, LoadingState} from '@react-types/shared'; +import {DEFAULT_SLOT, Provider, useContextProps} from 'react-aria-components/slots'; +import {DOMRef, DragItem, forwardRefType, GlobalDOMAttributes, ItemDropTarget, Key, LoadingState} from '@react-types/shared'; +import {DragAndDropContext, DropIndicator} from 'react-aria-components/useDragAndDrop'; +import DragHandle from '../ui-icons/DragHandle'; +import {dragPreviewBadge, icon, iconCenterWrapper, insertionIndicatorBar, insertionIndicatorCircle, isFirstItem, isPrevSelected, label, S2ListLayout} from './ListView'; +import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {IconContext} from './Icon'; +// @ts-ignore import intlMessages from '../intl/*.json'; -import {isFirstItem, isPrevSelected} from './ListView'; -import {ListLayout} from 'react-stately/useVirtualizerState'; import {ProgressCircle} from './ProgressCircle'; -import {Provider, useContextProps} from 'react-aria-components/slots'; -// @ts-ignore import { TreeItemProps as RACTreeItemProps, TreeProps as RACTreeProps, @@ -35,8 +35,10 @@ import { TreeItem, TreeItemContent, TreeItemContentProps, + TreeItemRenderProps, TreeLoadMoreItem, - TreeLoadMoreItemProps + TreeLoadMoreItemProps, + TreeRenderProps } from 'react-aria-components/Tree'; import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useContext, useRef} from 'react'; import {Text, TextContext} from './Content'; @@ -46,6 +48,7 @@ import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useScale} from './utils'; +import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; import {Virtualizer} from 'react-aria-components/Virtualizer'; interface S2TreeProps { @@ -63,7 +66,7 @@ interface TreeViewStyleProps { selectionStyle?: 'highlight' | 'checkbox' } -export interface TreeViewProps extends Omit, 'style' | 'className' | 'render' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps, TreeViewStyleProps { +export interface TreeViewProps extends Omit, 'style' | 'className' | 'render' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps, TreeViewStyleProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight } @@ -91,13 +94,37 @@ const treeViewWrapper = style({ isolation: 'isolate', disableTapHighlight: true, position: 'relative', - overflow: 'clip' + overflow: 'clip', + '--indicator-level-padding': { + type: 'width', + value: { + // 4 (start gap) + 10 (drag handle) + (hasCheckbox ? 16 + 8 : 0) + 40 (expand button) + // keep in sync with treeCellGrid gridTemplateColumns + default: 54, + hasCheckbox: 78 + } + } }, getAllowedOverrides({height: true})); +// These are the same as ListView. we didn't have v3 tree dnd and dont have designs so to be adjusted later +const dropTargetBackground = colorMix('gray-25', 'blue-900', 10); +const rootDropSelectedRowBackground = colorMix('gray-25', 'blue-900', 28); +const rowDropBackground = colorMix('gray-25', 'blue-900', 10); +const rootRowDropStyles = { + default: dropTargetBackground, + isSelected: rootDropSelectedRowBackground, + forcedColors: 'Background' +} as const; +const rowDropStyles = { + default: rowDropBackground, + isSelected: rowDropBackground, + forcedColors: 'Background' +} as const; + // TODO: the below is needed so the borders of the top and bottom row isn't cut off if the TreeView is wrapped within a container by always reserving the 2px needed for the // keyboard focus ring. Perhaps find a different way of rendering the outlines since the top of the item doesn't // scroll into view due to how the ring is offset. Alternatively, have the tree render the top/bottom outline like it does in Listview -const tree = style({ +const tree = style({ ...focusRing(), outlineOffset: -2, // make certain we are visible inside overflow hidden containers userSelect: 'none', @@ -107,6 +134,12 @@ const tree = style({ height: 'full', overflow: 'auto', boxSizing: 'border-box', + backgroundColor: { + isDropTarget: { + default: dropTargetBackground, + forcedColors: 'Background' + } + }, justifyContent: { isEmpty: 'center' }, @@ -119,15 +152,147 @@ const tree = style({ } }); +// TODO: same as TableView, to update based on feedback +const dragPreviewWrapper = style({ + position: 'relative' +}); + +const dragPreviewCardBack = style({ + position: 'absolute', + zIndex: -1, + top: 4, + left: 4, + width: 200, + height: 'full', + borderRadius: 'default', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'blue-900', + backgroundColor: 'gray-25' +}); + +const dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ + boxSizing: 'border-box', + paddingX: 0, + paddingY: 8, + backgroundColor: 'gray-25', + color: baseColor('neutral'), + position: 'relative', + display: 'grid', + // TODO update this per designs, maybe should look like ListView's? Same for tableview + gridTemplateColumns: [edgeToText(40), 'auto', 'minmax(0, 1fr)', 'auto', edgeToText(40)], + gridTemplateRows: '1fr', + gridTemplateAreas: [ + '. icon label badge .' + ], + alignItems: 'baseline', + minHeight: { + default: 40, + scale: { + large: 50 + } + }, + width: 200, + borderRadius: 'default', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'blue-900' +}); + +export interface TreeDragPreviewProps { + /** The currently dragged items, sourced from renderDragPreview. */ + items: DragItem[], + /** + * The contents of the drag preview. Supports the default text slot. + * If no children are provided, defaults to the first drag item's plain text content. + */ + children?: ReactNode +} + +export function TreeViewDragPreview(props: TreeDragPreviewProps) { + let {items} = props; + let isDraggingMultiple = items.length > 1; + let itemLabel = items[0]?.['text/plain'] ?? ''; + let scale = useScale(); + + return ( +
+ {isDraggingMultiple &&
} +
+ + {props.children ?? {itemLabel}} + {isDraggingMultiple && ( +
{items.length}
+ )} +
+
+
+ ); +} + let InternalTreeViewContext = createContext<{selectionStyle?: 'highlight' | 'checkbox'}>({}); + +const insertionIndicatorWrapper = style({ + display: 'flex', + alignItems: 'center', + marginStart: { + default: 'calc((var(--tree-item-level, 1) - 1) * var(--indent) + var(--indicator-level-padding, 0px))', + isRoot: 0 + } +}); + +function TreeInsertionIndicator({target}: {target: ItemDropTarget}) { + let {dropState} = useContext(DragAndDropContext) ?? {}; + let level = 0; + if (target.type === 'item' && dropState?.collection) { + level = dropState.collection.getItem(target.key)?.level ?? 0; + } + + return ( + + {({isDropTarget}) => ( +
+
+
+
+
+ )} + + ); +} + /** * A tree view provides users with a way to navigate nested hierarchical information. */ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { - let {children, selectionStyle = 'checkbox', UNSAFE_className, UNSAFE_style} = props; + let {children, selectionStyle = 'checkbox', UNSAFE_className, UNSAFE_style, dragAndDropHooks} = props; let scale = useScale(); + // 8 + 2 + 2 aka circle height + the circle thickness * 2 + let dropIndicatorThickness = scale === 'large' ? 15 : 12; + + if (dragAndDropHooks && dragAndDropHooks.renderDragPreview == null) { + dragAndDropHooks.renderDragPreview = (items) => ; + } + let hasCheckbox = props.selectionMode !== 'none' && selectionStyle !== 'highlight'; + + if (dragAndDropHooks) { + dragAndDropHooks.renderDropIndicator = (target) => ; + } let renderer; if (typeof children === 'function') { renderer = children; @@ -141,22 +306,24 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr return (
0 ? actionBarHeight + 8 : 0, scrollPaddingBottom: actionBarHeight > 0 ? actionBarHeight + 8 : 0 }} - className={tree} + className={(renderProps) => tree(renderProps)} selectionBehavior={selectionStyle === 'highlight' ? 'replace' : 'toggle'} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -188,8 +355,23 @@ const rowBackgroundColor = { } } as const; -const treeRow = style({ - outlineStyle: 'none', +const treeRow = style({ + outlineStyle: { + default: 'none', + isDropTarget: 'solid' + }, + outlineWidth: { + isDropTarget: 2 + }, + outlineOffset: { + isDropTarget: -2 + }, + outlineColor: { + isDropTarget: 'blue-800', + forcedColors: { + isDropTarget: 'Highlight' + } + }, position: 'relative', display: 'flex', height: 40, @@ -224,10 +406,11 @@ const treeCellGrid = style({ borderRadius: 'sm', alignContent: 'center', alignItems: 'center', - gridTemplateColumns: ['auto', 'auto', 'auto', 'auto', 'auto', '1fr', 'minmax(0, auto)', 'auto'], + // TODO: will have to update these to match design + gridTemplateColumns: [4, 'auto', 'auto', 'auto', 'auto', 'auto', '1fr', 'minmax(0, auto)', 'auto'], gridTemplateRows: '1fr', gridTemplateAreas: [ - 'drag-handle checkbox level-padding expand-button icon content actions actionmenu' + '. drag-handle checkbox level-padding expand-button icon content actions actionmenu' ], paddingEnd: 4, // account for any focus rings on the last item in the cell color: { @@ -279,6 +462,8 @@ const treeRowBackground = style({ backgroundColor: { default: '--rowBackgroundColor', forcedColors: 'Background', + ':is([role="treegrid"][data-drop-target] *)': rootRowDropStyles, + isDropTarget: rowDropStyles, selectionStyle: { highlight: { default: '--rowBackgroundColor', @@ -339,7 +524,7 @@ const treeRowBackground = style({ const treeCheckbox = style({ gridArea: 'checkbox', - marginStart: 12, + marginStart: 8, marginEnd: 0, paddingEnd: 0, visibility: { @@ -375,6 +560,38 @@ const treeActionMenu = style({ gridArea: 'actionmenu' }); +const treeDragButtonContainer = style({ + gridArea: 'drag-handle', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 10 +}); + +const treeDragButton = style({ + alignItems: 'center', + justifyContent: 'center', + height: 22, + width: 10, + padding: 0, + margin: 0, + backgroundColor: 'transparent', + borderStyle: 'none', + borderRadius: 'sm', + outlineStyle: { + default: 'none', + isFocusVisible: 'solid' + }, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + outlineWidth: 2, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + } +}); let treeRowFocusRing = style({ ...focusRing(), @@ -424,16 +641,13 @@ export const TreeViewItem = (props: TreeViewItemProps): ReactNode => { let { href } = props; - let {selectionStyle} = useContext(InternalTreeViewContext); return ( treeRow({ ...renderProps, - isLink: !!href, - selectionStyle, - isPreviousSelected: isPrevSelected(renderProps.id, renderProps.state) + isLink: !!href })} /> ); }; @@ -450,14 +664,27 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode let scale = useScale(); let {selectionStyle} = useContext(InternalTreeViewContext); - + let {visuallyHiddenProps} = useVisuallyHidden(); + return ( ( - {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isSelected, id, state, isHovered, isFocusVisible}) => { + {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isSelected, id, state, isHovered, isFocusVisible, isFocusVisibleWithin, allowsDragging, isDropTarget}) => { return ( (
-
+
{isFocusVisible &&
} + {allowsDragging && ( +
+ {!isDisabled && ( + + )} +
+ )} {selectionMode !== 'none' && selectionBehavior === 'toggle' && selectionStyle !== 'highlight' && ( // TODO: add transition? (
diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index 0dc94bd8b10..7e487dfa76c 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -28,12 +28,14 @@ import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import {IllustratedMessage} from '../src/IllustratedMessage'; import {Image} from '../src/Image'; import {Key} from '@react-types/shared'; -import {ListView, ListViewItem} from '../src/ListView'; +import {ListView, ListViewDragPreview, ListViewItem} from '../src/ListView'; import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactNode, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; import {useAsyncList} from 'react-stately/useAsyncList'; +import {useDragAndDrop} from 'react-aria-components/useDragAndDrop'; +import {useListData} from 'react-stately/useListData'; const meta: Meta = { component: ListView, @@ -50,11 +52,18 @@ const meta: Meta = { styles: style({height: 320}) }, decorators: [ - (Story) => ( -
- -
- ) + (Story, context) => { + let {disableDecorator} = context.parameters; + if (disableDecorator) { + return ; + } + + return ( +
+ +
+ ); + } ] }; @@ -591,3 +600,357 @@ export const WithActionBarEmphasized: Story = { }, name: 'with ActionBar (emphasized)' }; + +let reorderItems: Item[] = [ + {id: 'a', name: 'Adobe Photoshop', type: 'file'}, + {id: 'b', name: 'Adobe XD', type: 'file'}, + {id: 'c', name: 'Documents', type: 'folder'}, + {id: 'd', name: 'Adobe InDesign', type: 'file'}, + {id: 'e', name: 'Utilities', type: 'folder'}, + {id: 'f', name: 'Adobe AfterEffects', type: 'file'}, + {id: 'g', name: 'Adobe Illustrator', type: 'file'}, + {id: 'h', name: 'Adobe Lightroom', type: 'file'}, + {id: 'i', name: 'Adobe Premiere Pro', type: 'file'}, + {id: 'j', name: 'Adobe Fresco', type: 'file'}, + {id: 'k', name: 'Adobe Dreamweaver', type: 'file'}, + {id: 'l', name: 'Adobe Connect', type: 'file'}, + {id: 'm', name: 'Pictures', type: 'folder'}, + {id: 'n', name: 'Adobe Acrobat', type: 'file'}, + {id: 'o', name: 'Really really really really really long name', type: 'file'} +]; + +function CustomDragPreview(props) { + let {items, parentList} = props; + let id = items[0].id; + let item = parentList.getItem(id); + return ( + + {item.name} + {item.type === 'folder' && + <> + + {items.childNodes && {`contains ${item.childNodes.length} dropped item(s)`}} + + } + {item.type === 'file' && } + + ); +} + +function ReorderExample(props) { + let list = useListData({ + initialItems: reorderItems + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => [...keys].map(key => { + let item = list.getItem(key)!; + return { + id: item.id.toString(), + 'text/plain': item?.name ?? '' + }; + }), + onReorder(e) { + if (e.target.dropPosition === 'before') { + list.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + list.moveAfter(e.target.key, e.keys); + } + }, + renderDragPreview: (items) => + }); + + return ( + + {(item: any) => ( + + {item.type === 'folder' ? : } + {item.name} + + )} + + ); +} + +export const Reorderable: Story = { + render: (args) => , + name: 'Drag and drop reordering' +}; + +let folderList1 = [ + {id: '1', type: 'file', name: 'Adobe Photoshop'}, + {id: '2', type: 'file', name: 'Adobe XD'}, + {id: '3', type: 'folder', name: 'Documents', childNodes: [] as any[]}, + {id: '4', type: 'file', name: 'Adobe InDesign'}, + {id: '5', type: 'folder', name: 'Utilities', childNodes: []}, + {id: '6', type: 'file', name: 'Adobe AfterEffects'} +]; + +let folderList2 = [ + {id: '7', type: 'folder', name: 'Pictures', childNodes: [] as any[]}, + {id: '8', type: 'file', name: 'Adobe Fresco'}, + {id: '9', type: 'folder', name: 'Apps', childNodes: []}, + {id: '10', type: 'file', name: 'Adobe Illustrator'}, + {id: '11', type: 'file', name: 'Adobe Lightroom'}, + {id: '12', type: 'file', name: 'Adobe Dreamweaver'}, + {id: '13', type: 'unique_type', name: 'invalid drag item'} +]; + +let itemProcessor = async (items, acceptedDragTypes) => { + let processedItems: any[] = []; + let text = ''; + for (let item of items) { + for (let type of acceptedDragTypes) { + if (item.kind === 'text' && item.types.has(type)) { + text = await item.getText(type); + processedItems.push(JSON.parse(text)); + break; + } else if (item.types.size === 1 && item.types.has('text/plain')) { + // Fallback for Chrome Android case: https://bugs.chromium.org/p/chromium/issues/detail?id=1293803 + // Multiple drag items are contained in a single string so we need to split them out + text = await item.getText('text/plain'); + processedItems = text.split('\n').map(val => JSON.parse(val)); + break; + } + } + } + return processedItems; +}; + +function BetweenLists(props) { + let list1 = useListData({ + initialItems: folderList1 + }); + + let list2 = useListData({ + initialItems: folderList2 + }); + let acceptedDragTypes = ['file', 'folder', 'text/plain']; + + // List 1 should allow on item drops and external drops, but disallow reordering/internal drops + let {dragAndDropHooks: dragAndDropHooksList1} = useDragAndDrop({ + getItems: (keys) => [...keys].map(key => { + let item = list1.getItem(key)!; + return { + id: item.id, + [`${item.type}`]: JSON.stringify(item), + 'text/plain': item.name + }; + }), + onReorder(e) { + if (e.target.dropPosition === 'before') { + list1.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + list1.moveAfter(e.target.key, e.keys); + } + }, + onInsert: async (e) => { + let { + items, + target + } = e; + action('onInsertList1')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + + if (target.dropPosition === 'before') { + list1.insertBefore(target.key, ...processedItems); + } else if (target.dropPosition === 'after') { + list1.insertAfter(target.key, ...processedItems); + } + }, + onRootDrop: async (e) => { + action('onRootDropList1')(e); + let processedItems = await itemProcessor(e.items, acceptedDragTypes); + list1.append(...processedItems); + }, + onItemDrop: async (e) => { + let { + items, + target, + isInternal, + dropOperation + } = e; + action('onItemDropList1')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + let targetItem = list1.getItem(target.key)!; + list1.update(target.key, {...targetItem, childNodes: [...(targetItem.childNodes || []), ...processedItems]}); + + if (isInternal && dropOperation === 'move') { + let keysToRemove = processedItems.map(item => item.id); + list1.remove(...keysToRemove); + } + }, + acceptedDragTypes, + onDragEnd: (e) => { + let { + dropOperation, + isInternal, + keys + } = e; + action('onDragEndList1')(e); + if (dropOperation === 'move' && !isInternal) { + list1.remove(...keys); + } + }, + getAllowedDropOperations: () => ['move', 'copy'], + shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)!.childNodes, + renderDragPreview: (items) => + }); + + // List 2 should allow reordering, on folder drops, and on root drops + let {dragAndDropHooks: dragAndDropHooksList2} = useDragAndDrop({ + getItems: (keys) => [...keys].map(key => { + let item = list2.getItem(key)!; + let dragItem = {}; + let itemString = JSON.stringify(item); + dragItem['id'] = item.id; + dragItem[`${item.type}`] = itemString; + if (item.type !== 'unique_type') { + dragItem['text/plain'] = item.name; + } + + return dragItem; + }), + onInsert: async (e) => { + let { + items, + target + } = e; + action('onInsertList2')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + + if (target.dropPosition === 'before') { + list2.insertBefore(target.key, ...processedItems); + } else if (target.dropPosition === 'after') { + list2.insertAfter(target.key, ...processedItems); + } + }, + onReorder: async (e) => { + let { + keys, + target, + dropOperation + } = e; + action('onReorderList2')(e); + + let itemsToCopy: typeof folderList2 = []; + if (dropOperation === 'copy') { + for (let key of keys) { + let item: typeof folderList2[0] = {...list2.getItem(key)!}; + item.id = Math.random().toString(36).slice(2); + itemsToCopy.push(item); + } + } + + if (target.dropPosition === 'before') { + if (dropOperation === 'move') { + list2.moveBefore(target.key, [...keys]); + } else if (dropOperation === 'copy') { + list2.insertBefore(target.key, ...itemsToCopy); + } + } else if (target.dropPosition === 'after') { + if (dropOperation === 'move') { + list2.moveAfter(target.key, [...keys]); + } else if (dropOperation === 'copy') { + list2.insertAfter(target.key, ...itemsToCopy); + } + } + }, + onRootDrop: async (e) => { + action('onRootDropList2')(e); + let processedItems = await itemProcessor(e.items, acceptedDragTypes); + list2.prepend(...processedItems); + }, + onItemDrop: async (e) => { + let { + items, + target, + isInternal, + dropOperation + } = e; + action('onItemDropList2')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + let targetItem = list2.getItem(target.key)!; + list2.update(target.key, {...targetItem, childNodes: [...(targetItem.childNodes || []), ...processedItems]}); + + if (isInternal && dropOperation === 'move') { + let keysToRemove = processedItems.map(item => item.id); + list2.remove(...keysToRemove); + } + }, + acceptedDragTypes, + onDragEnd: (e) => { + let { + dropOperation, + isInternal, + keys + } = e; + action('onDragEndList2')(e); + if (dropOperation === 'move' && !isInternal) { + let keysToRemove = [...keys].filter(key => list2.getItem(key)!.type !== 'unique_type'); + list2.remove(...keysToRemove); + } + }, + getAllowedDropOperations: () => ['move', 'copy'], + shouldAcceptItemDrop: (target) => !!list2.getItem(target.key)!.childNodes, + renderDragPreview: (items) => + }); + + return ( +
+ + {(item: any) => ( + + {item.name} + {item.type === 'folder' && + <> + + {`contains ${item.childNodes.length} dropped item(s)`} + + } + {item.type === 'file' && } + + )} + + + {(item: any) => ( + + {item.name} + {item.type === 'folder' && + <> + + {`contains ${item.childNodes.length} dropped item(s)`} + + } + {item.type === 'file' && } + + )} + +
+ ); +} + +export const DragBetweenLists: Story = { + render: (args) => , + name: 'Drag between lists', + parameters: { + disableDecorator: true + } +}; diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 45ce91272e3..db82766e2b7 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -12,9 +12,7 @@ import {action} from 'storybook/actions'; import {ActionButton} from '../src/ActionButton'; - import {categorizeArgTypes, getActionArgs} from './utils'; - import { Cell, Column, @@ -24,6 +22,7 @@ import { TableBody, TableHeader, TableView, + TableViewDragPreview, TableViewProps } from '../src/TableView'; import {Collection} from 'react-aria/Collection'; @@ -43,6 +42,7 @@ import {StatusLight} from '../src/StatusLight'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; import {TextField} from '../src/TextField'; import {useAsyncList} from 'react-stately/useAsyncList'; +import {useDragAndDrop} from 'react-aria-components/useDragAndDrop'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useListData} from 'react-stately/useListData'; import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; @@ -817,7 +817,7 @@ function ManyItemsTable(args) { )} - ); + ); } export const ManyItems: StoryObj = { @@ -1887,3 +1887,356 @@ function NestedInlineEditExample(args) { export const TableWithNestedRowsAndInlineEditing: StoryObj = { render: (args) => }; + +function CustomDragPreview(props) { + let {items, parentList} = props; + let id = items[0].id; + let item = parentList.getItem(id); + return ( + + {`${item.name} (${item.type})`} + + ); +} + +let folderList1 = [ + {id: '1', type: 'file', name: 'Adobe Photoshop'}, + {id: '2', type: 'file', name: 'Adobe XD'}, + {id: '3', type: 'folder', name: 'Documents', childNodes: [] as any[]}, + {id: '4', type: 'file', name: 'Adobe InDesign'}, + {id: '5', type: 'folder', name: 'Utilities', childNodes: [] as any[]}, + {id: '6', type: 'file', name: 'Adobe AfterEffects'} +]; + +let folderList2 = [ + {id: '7', type: 'folder', name: 'Pictures', childNodes: [] as any[]}, + {id: '8', type: 'file', name: 'Adobe Fresco'}, + {id: '9', type: 'folder', name: 'Apps', childNodes: [] as any[]}, + {id: '10', type: 'file', name: 'Adobe Illustrator'}, + {id: '11', type: 'file', name: 'Adobe Lightroom'}, + {id: '12', type: 'file', name: 'Adobe Dreamweaver'}, + {id: '13', type: 'unique_type', name: 'invalid drag item'} +]; + +let dragColumns = [ + {name: 'ID', id: 'id', width: 40}, + {name: 'Name', id: 'name', width: 300, isRowHeader: true}, + {name: 'Type', id: 'type'} +]; + +function ReorderableTableExample(props) { + let list = useListData({initialItems: folderList1}); + + let acceptedDragTypes = ['file', 'folder', 'text/plain']; + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => [...keys].map(key => { + let item = list.getItem(key)!; + return { + id: item.id, + [`${item.type}`]: JSON.stringify(item), + 'text/plain': item.name + }; + }), + onReorder: async (e) => { + let { + keys, + target, + dropOperation + } = e; + action('onReorder')(e); + + let itemsToCopy: typeof folderList1 = []; + if (dropOperation === 'copy') { + for (let key of keys) { + let item: typeof folderList1[0] = {...list.getItem(key)!}; + item.id = Math.random().toString(36).slice(2); + itemsToCopy.push(item); + } + } + + if (target.dropPosition === 'before') { + if (dropOperation === 'move') { + list.moveBefore(target.key, [...keys]); + } else if (dropOperation === 'copy') { + list.insertBefore(target.key, ...itemsToCopy); + } + } else if (target.dropPosition === 'after') { + if (dropOperation === 'move') { + list.moveAfter(target.key, [...keys]); + } else if (dropOperation === 'copy') { + list.insertAfter(target.key, ...itemsToCopy); + } + } + }, + acceptedDragTypes, + renderDragPreview: (items) => + }); + + return ( + + + {column => {column.name}} + + + {item => ( + + {(column) => { + return {item[column.id]}; + }} + + )} + + + ); +} + +export const DragAndDropReorder: StoryObj = { + render: (args) => , + name: 'Drag and drop reorder' +}; + +let itemProcessor = async (items, acceptedDragTypes) => { + let processedItems: any[] = []; + let text = ''; + for (let item of items) { + for (let type of acceptedDragTypes) { + if (item.kind === 'text' && item.types.has(type)) { + text = await item.getText(type); + processedItems.push(JSON.parse(text)); + break; + } else if (item.types.size === 1 && item.types.has('text/plain')) { + // Fallback for Chrome Android case: https://bugs.chromium.org/p/chromium/issues/detail?id=1293803 + // Multiple drag items are contained in a single string so we need to split them out + text = await item.getText('text/plain'); + processedItems = text.split('\n').map(val => JSON.parse(val)); + break; + } + } + } + return processedItems; +}; + +function BetweenTables(props) { + let list1 = useListData({initialItems: folderList1}); + let list2 = useListData({initialItems: folderList2}); + let acceptedDragTypes = ['file', 'folder', 'text/plain']; + + // table 1 should allow on item drops and external drops, but disallow reordering/internal drops + let {dragAndDropHooks: dragAndDropHooksTable1} = useDragAndDrop({ + getItems: (keys) => [...keys].map(key => { + let item = list1.getItem(key)!; + return { + id: item.id, + [`${item.type}`]: JSON.stringify(item), + 'text/plain': item.name + }; + }), + onInsert: async (e) => { + let { + items, + target + } = e; + action('onInsertTable1')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + + if (target.dropPosition === 'before') { + list1.insertBefore(target.key, ...processedItems); + } else if (target.dropPosition === 'after') { + list1.insertAfter(target.key, ...processedItems); + } + + }, + onRootDrop: async (e) => { + action('onRootDropTable1')(e); + let processedItems = await itemProcessor(e.items, acceptedDragTypes); + list1.append(...processedItems); + }, + onItemDrop: async (e) => { + let { + items, + target, + isInternal, + dropOperation + } = e; + action('onItemDropTable1')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + let targetItem = list1.getItem(target.key)!; + list1.update(target.key, {...targetItem, childNodes: [...(targetItem.childNodes || []), ...processedItems]}); + + if (isInternal && dropOperation === 'move') { + let keysToRemove = processedItems.map(item => item.id); + list1.remove(...keysToRemove); + } + }, + acceptedDragTypes, + onDragEnd: (e) => { + let { + dropOperation, + isInternal, + keys + } = e; + action('onDragEndTable1')(e); + if (dropOperation === 'move' && !isInternal) { + list1.remove(...keys); + } + }, + getAllowedDropOperations: () => ['move', 'copy'], + shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)?.childNodes, + renderDragPreview: (items) => + }); + + // table 2 should allow reordering, on folder drops, and on root drops + let {dragAndDropHooks: dragAndDropHooksTable2} = useDragAndDrop({ + getItems: (keys) => [...keys].map(key => { + let item = list2.getItem(key)!; + let dragItem = {}; + let itemString = JSON.stringify(item); + dragItem['id'] = item.id; + dragItem[`${item.type}`] = itemString; + if (item.type !== 'unique_type') { + dragItem['text/plain'] = item.name; + } + + return dragItem; + }), + onInsert: async (e) => { + let { + items, + target + } = e; + action('onInsertTable2')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + + if (target.dropPosition === 'before') { + list2.insertBefore(target.key, ...processedItems); + } else if (target.dropPosition === 'after') { + list2.insertAfter(target.key, ...processedItems); + } + }, + onReorder: async (e) => { + let { + keys, + target, + dropOperation + } = e; + action('onReorderTable2')(e); + + let itemsToCopy: typeof folderList1 = []; + if (dropOperation === 'copy') { + for (let key of keys) { + let item: typeof folderList1[0] = {...list2.getItem(key)!}; + item.id = Math.random().toString(36).slice(2); + itemsToCopy.push(item); + } + } + + if (target.dropPosition === 'before') { + if (dropOperation === 'move') { + list2.moveBefore(target.key, [...keys]); + } else if (dropOperation === 'copy') { + list2.insertBefore(target.key, ...itemsToCopy); + } + } else if (target.dropPosition === 'after') { + if (dropOperation === 'move') { + list2.moveAfter(target.key, [...keys]); + } else if (dropOperation === 'copy') { + list2.insertAfter(target.key, ...itemsToCopy); + } + } + }, + onRootDrop: async (e) => { + action('onRootDropTable2')(e); + let processedItems = await itemProcessor(e.items, acceptedDragTypes); + list2.prepend(...processedItems); + }, + onItemDrop: async (e) => { + let { + items, + target, + isInternal, + dropOperation + } = e; + action('onItemDropTable2')(e); + let processedItems = await itemProcessor(items, acceptedDragTypes); + let targetItem = list2.getItem(target.key)!; + list2.update(target.key, {...targetItem, childNodes: [...(targetItem.childNodes || []), ...processedItems]}); + + if (isInternal && dropOperation === 'move') { + let keysToRemove = processedItems.map(item => item.id); + list2.remove(...keysToRemove); + } + }, + acceptedDragTypes, + onDragEnd: (e) => { + let { + dropOperation, + isInternal, + keys + } = e; + action('onDragEndTable2')(e); + if (dropOperation === 'move' && !isInternal) { + let keysToRemove = [...keys].filter(key => list2.getItem(key)!.type !== 'unique_type'); + list2.remove(...keysToRemove); + } + }, + getAllowedDropOperations: () => ['move', 'copy'], + shouldAcceptItemDrop: (target) => !!list2.getItem(target.key)?.childNodes, + renderDragPreview: (items) => + }); + + + return ( +
+ + + {column => {column.name}} + + + {item => ( + + {(column) => { + return {item[column.id]}; + }} + + )} + + + + + {column => {column.name}} + + + {item => ( + + {(column) => { + return {item[column.id]}; + }} + + )} + + +
+ ); +} + +export const DragBetweenTables: StoryObj = { + render: (args) => , + name: 'Drag between tables' +}; diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index b9bebd80f52..de00765732f 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -19,14 +19,12 @@ import {Collection} from 'react-aria/Collection'; import {Content, Heading, Text} from '../src/Content'; import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; import Delete from '../s2wf-icons/S2_Icon_Delete_20_N.svg'; - +import {DroppableCollectionReorderEvent, Key} from '@react-types/shared'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; - import FileTxt from '../s2wf-icons/S2_Icon_FileText_20_N.svg'; import Folder from '../s2wf-icons/S2_Icon_Folder_20_N.svg'; import FolderOpen from '../spectrum-illustrations/linear/FolderOpen'; import {IllustratedMessage} from '../src/IllustratedMessage'; -import {Key} from '@react-types/shared'; import {Link} from '../src/Link'; import {MenuItem} from '../src/Menu'; import type {Meta, StoryObj} from '@storybook/react'; @@ -34,6 +32,7 @@ import React, {ReactElement, useCallback, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; import { TreeView, + TreeViewDragPreview, TreeViewItem, TreeViewItemContent, TreeViewItemProps, @@ -42,7 +41,9 @@ import { TreeViewProps } from '../src/TreeView'; import {useAsyncList} from 'react-stately/useAsyncList'; +import {isTextDropItem, useDragAndDrop} from 'react-aria-components/useDragAndDrop'; import {useListData} from 'react-stately/useListData'; +import {useTreeData} from 'react-stately/useTreeData'; let onActionFunc = action('onAction'); let noOnAction = null; @@ -398,7 +399,7 @@ let rows: TreeViewItemType[] = [ {id: 'reports-1C', name: 'Reports 1C', icon: } ]}, {id: 'reports-2', name: 'Reports 2', icon: }, - ...Array.from({length: 100}, (_, i) => ({id: `reports-repeat-${i}`, name: `Reports ${i}`, icon: })) + ...Array.from({length: 100}, (_, i) => ({id: `reports-repeat-${i + 3}`, name: `Reports ${i + 3}`, icon: })) ]} ]; @@ -927,3 +928,260 @@ export const WithActionBarEmphasized: StoryObj + {item.value.icon} + {item.value.name} + + ); +} + +function ReorderableTree(props: TreeViewProps) { + let treeData = useTreeData({ + initialItems: rows, + getKey: item => item.id as Key, + getChildren: item => item.childItems as TreeViewItemType[] + }); + + let processItem = (item) => ({ + ...item.value, + id: item.key, + childItems: item.children ? item.children.map(processItem) : [] + }); + + let items = treeData.items.map(processItem); + + let getItems = (keys) => [...keys].map(key => { + let item = treeData.getItem(key)!; + + let serializeItem = (nodeItem) => ({ + ...nodeItem.value, + childItems: nodeItem.children ? [...nodeItem.children].map(serializeItem) : [] + }); + + return { + id: item.value.id!.toString(), + 'text/plain': item.value.name, + 'tree-item': JSON.stringify(serializeItem(item)) + }; + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems, + getAllowedDropOperations: () => ['move'], + onMove(e: DroppableCollectionReorderEvent) { + try { + if (e.target.dropPosition === 'before') { + treeData.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + treeData.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + let targetNode = treeData.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++) { + treeData.move(keyArray[i], e.target.key, targetIndex + i); + } + } else { + console.error('Target node not found for drop on:', e.target.key); + } + } + } catch (error) { + console.error(error); + } + }, + renderDragPreview: (items) => + }); + + return ( + + {(item) => ( + + )} + + ); +} + +export const Reorderable: StoryObj = { + render: (args) => , + name: 'Drag and drop reordering' +}; + +function BetweenTrees(props: TreeViewProps) { + let treeData1 = useTreeData({ + initialItems: rows, + getKey: item => item.id as Key, + getChildren: item => item.childItems as TreeViewItemType[] + }); + + let treeData2 = useTreeData({ + initialItems: [], + getKey: item => item.id as Key, + getChildren: item => item.childItems as TreeViewItemType[] + }); + + let processItem = (item) => ({ + ...item.value, + id: item.key, + childItems: item.children ? item.children.map(processItem) : [] + }); + + let serializeNode = (node) => ({ + ...node.value, + icon: undefined, + childItems: node.children ? [...node.children].map(serializeNode) : [] + }); + + let processIncomingItems = async (e) => { + return await Promise.all(e.items.filter(isTextDropItem).map(async item => { + let parsed = JSON.parse(await item.getText('tree-item')); + let convertItem = (i) => ({ + ...i, + id: Math.random().toString(36), + childItems: i.childItems?.map(convertItem) + }); + return convertItem(parsed); + })); + }; + + let makeOnMove = (treeData) => (e: DroppableCollectionReorderEvent) => { + try { + if (e.target.dropPosition === 'before') { + treeData.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + treeData.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + let targetNode = treeData.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++) { + treeData.move(keyArray[i], e.target.key, targetIndex + i); + } + } + } + } catch (error) { + console.error(error); + } + }; + + let makeDropHandlers = (treeData) => ({ + acceptedDragTypes: ['tree-item'] as string[], + async onInsert(e) { + let items = await processIncomingItems(e); + if (e.target.dropPosition === 'before') { + treeData.insertBefore(e.target.key, ...items); + } else if (e.target.dropPosition === 'after') { + treeData.insertAfter(e.target.key, ...items); + } + }, + async onItemDrop(e) { + let items = await processIncomingItems(e); + treeData.insert(e.target.key, 0, ...items); + }, + async onRootDrop(e) { + let items = await processIncomingItems(e); + treeData.insert(null, 0, ...items); + } + }); + + let makeGetItems = (treeData) => (keys) => [...keys].map(key => { + let item = treeData.getItem(key)!; + return { + id: item.value.id!.toString(), + 'text/plain': item.value.name, + 'tree-item': JSON.stringify(serializeNode(item)) + }; + }); + + let {dragAndDropHooks: dragHooksTree1} = useDragAndDrop({ + getItems: makeGetItems(treeData1), + getAllowedDropOperations: () => ['move', 'copy'], + onDragEnd(e) { + if (e.dropOperation === 'move' && !e.isInternal) { + treeData1.remove(...e.keys); + } + }, + onMove: makeOnMove(treeData1), + ...makeDropHandlers(treeData1), + renderDragPreview: (items) => + }); + + let {dragAndDropHooks: dragHooksTree2} = useDragAndDrop({ + getItems: makeGetItems(treeData2), + getAllowedDropOperations: () => ['move', 'copy'], + onDragEnd(e) { + if (e.dropOperation === 'move' && !e.isInternal) { + treeData2.remove(...e.keys); + } + }, + onMove: makeOnMove(treeData2), + ...makeDropHandlers(treeData2), + renderDragPreview: (items) => + }); + + let items1 = treeData1.items.map(processItem); + let items2 = treeData2.items.map(processItem); + + return ( +
+ + {(item) => ( + + )} + + + {(item) => ( + + )} + +
+ ); +} + +export const DragBetweenTrees: StoryObj = { + render: (args) => , + name: 'Drag between trees', + parameters: { + docs: { + disable: true + } + } +}; diff --git a/packages/@react-types/shared/src/collections.d.ts b/packages/@react-types/shared/src/collections.d.ts index 9ca04fdc7d8..db3d4a14b6a 100644 --- a/packages/@react-types/shared/src/collections.d.ts +++ b/packages/@react-types/shared/src/collections.d.ts @@ -96,16 +96,16 @@ export type SortDirection = 'ascending' | 'descending'; export interface KeyboardDelegate { /** Returns the key visually below the given one, or `null` for none. */ - getKeyBelow?(key: Key): Key | null, + getKeyBelow?(key: Key, options?: {includeDisabled?: boolean}): Key | null, /** Returns the key visually above the given one, or `null` for none. */ - getKeyAbove?(key: Key): Key | null, + getKeyAbove?(key: Key, options?: {includeDisabled?: boolean}): Key | null, /** Returns the key visually to the left of the given one, or `null` for none. */ - getKeyLeftOf?(key: Key): Key | null, + getKeyLeftOf?(key: Key, options?: {includeDisabled?: boolean}): Key | null, /** Returns the key visually to the right of the given one, or `null` for none. */ - getKeyRightOf?(key: Key): Key | null, + getKeyRightOf?(key: Key, options?: {includeDisabled?: boolean}): Key | null, /** Returns the key visually one page below the given one, or `null` for none. */ getKeyPageBelow?(key: Key): Key | null, diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index fb481428de5..4b3878452b7 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -361,6 +361,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext); let ref = useObjectRef(forwardedRef); let {isVirtualized} = useContext(CollectionRendererContext); + let isDraggable = dragState && !(dragState.isDisabled || dragState.selectionManager.isDisabled(item.key)); let {rowProps, gridCellProps, descriptionProps, ...states} = useGridListItem( { node: item, @@ -372,7 +373,10 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function ); let {hoverProps, isHovered} = useHover({ - isDisabled: !states.allowsSelection && !states.hasAction, + // TODO: bit iffy about needing to calculate if it is draggable yourself, alternative would + // be to pass the drag state enitrely to useGridListItem. I had initially passed isDraggable to useGridListItem but + // that kinda defeats the point since you could just do the below instead + isDisabled: !states.allowsSelection && !states.hasAction && !isDraggable, onHoverStart: item.props.onHoverStart, onHoverChange: item.props.onHoverChange, onHoverEnd: item.props.onHoverEnd diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 863b9797269..7f489b4a717 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -403,6 +403,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function let ref = useObjectRef(forwardedRef); let state = useContext(ListStateContext)!; let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; + let isDraggable = dragState && !(dragState.isDisabled || dragState.selectionManager.isDisabled(item.key)); let {optionProps, labelProps, descriptionProps, ...states} = useOption( {key: item.key, 'aria-label': props?.['aria-label']}, state, @@ -410,7 +411,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function ); let {hoverProps, isHovered} = useHover({ - isDisabled: !states.allowsSelection && !states.hasAction, + isDisabled: !states.allowsSelection && !states.hasAction && !isDraggable, onHoverStart: item.props.onHoverStart, onHoverChange: item.props.onHoverChange, onHoverEnd: item.props.onHoverEnd diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 6d7179d6cd4..15a0e1173cf 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1278,7 +1278,10 @@ export const TableBody = /*#__PURE__*/ createBranchComponent(TableBodyNode, extends BaseCollection { // Clone ancestor section nodes so React knows to re-render since the same item won't cause a new render but a clone creating a new object with the same value will // Without this change, the items won't expand and collapse when virtualized inside a section TreeCollection.cloneAncestorSections(expandedKeys, lastExpandedKeys, collection); - TreeCollection.cloneAncestorSections(lastExpandedKeys, expandedKeys, collection); + TreeCollection.cloneAncestorSections(lastExpandedKeys, expandedKeys, collection); collection.frozen = this.frozen; return collection; @@ -238,6 +238,11 @@ export interface TreeRenderProps { * @selector [data-allows-dragging] */ allowsDragging: boolean, + /** + * Whether the table is currently the active drop target. + * @selector [data-drop-target] + */ + isDropTarget: boolean, /** * State of the tree. */ @@ -608,6 +613,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, (ref); let {dragAndDropHooks, dragState, dropState} = useContext(DragAndDropContext)!; + let isDraggable = dragState && !(dragState.isDisabled || dragState.selectionManager.isDisabled(item.key)); // TODO: remove this when we support description in tree row // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -620,7 +626,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, ) => { return ( {item => {item.name}} diff --git a/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts b/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts index e60e60b7f0f..b06585ec032 100644 --- a/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts +++ b/packages/react-aria/src/dnd/DropTargetKeyboardNavigation.ts @@ -53,9 +53,9 @@ function nextDropTarget( if (target.type === 'item') { let nextKey: Key | null | undefined = null; if (horizontal) { - nextKey = horizontal === 'right' ? keyboardDelegate.getKeyRightOf?.(target.key) : keyboardDelegate.getKeyLeftOf?.(target.key); + nextKey = horizontal === 'right' ? keyboardDelegate.getKeyRightOf?.(target.key, {includeDisabled: true}) : keyboardDelegate.getKeyLeftOf?.(target.key, {includeDisabled: true}); } else { - nextKey = keyboardDelegate.getKeyBelow?.(target.key); + nextKey = keyboardDelegate.getKeyBelow?.(target.key, {includeDisabled: true}); } let nextCollectionKey = getNextItem(collection, target.key, key => collection.getKeyAfter(key)); @@ -181,9 +181,9 @@ function previousDropTarget( if (target.type === 'item') { let prevKey: Key | null | undefined = null; if (horizontal) { - prevKey = horizontal === 'left' ? keyboardDelegate.getKeyLeftOf?.(target.key) : keyboardDelegate.getKeyRightOf?.(target.key); + prevKey = horizontal === 'left' ? keyboardDelegate.getKeyLeftOf?.(target.key, {includeDisabled: true}) : keyboardDelegate.getKeyRightOf?.(target.key, {includeDisabled: true}); } else { - prevKey = keyboardDelegate.getKeyAbove?.(target.key); + prevKey = keyboardDelegate.getKeyAbove?.(target.key, {includeDisabled: true}); } let prevCollectionKey = getNextItem(collection, target.key, key => collection.getKeyBefore(key)); diff --git a/packages/react-aria/src/grid/GridKeyboardDelegate.ts b/packages/react-aria/src/grid/GridKeyboardDelegate.ts index 8216abbc0f4..ac43362cf0d 100644 --- a/packages/react-aria/src/grid/GridKeyboardDelegate.ts +++ b/packages/react-aria/src/grid/GridKeyboardDelegate.ts @@ -62,7 +62,7 @@ export class GridKeyboardDelegate> implements Key return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)); } - protected findPreviousKey(fromKey?: Key, pred?: (item: Node) => boolean): Key | null { + protected findPreviousKey(fromKey?: Key, pred?: (item: Node) => boolean, includeDisabled = false): Key | null { let key = fromKey != null ? this.collection.getKeyBefore(fromKey) : this.collection.getLastKey(); @@ -72,7 +72,7 @@ export class GridKeyboardDelegate> implements Key if (!item) { return null; } - if (!this.isDisabled(item) && (!pred || pred(item))) { + if ((includeDisabled || !this.isDisabled(item)) && (!pred || pred(item))) { return key; } @@ -81,7 +81,7 @@ export class GridKeyboardDelegate> implements Key return null; } - protected findNextKey(fromKey?: Key, pred?: (item: Node) => boolean): Key | null { + protected findNextKey(fromKey?: Key, pred?: (item: Node) => boolean, includeDisabled = false): Key | null { let key = fromKey != null ? this.collection.getKeyAfter(fromKey) : this.collection.getFirstKey(); @@ -91,7 +91,7 @@ export class GridKeyboardDelegate> implements Key if (!item) { return null; } - if (!this.isDisabled(item) && (!pred || pred(item))) { + if ((includeDisabled || !this.isDisabled(item)) && (!pred || pred(item))) { return key; } @@ -132,7 +132,7 @@ export class GridKeyboardDelegate> implements Key return null; } - getKeyBelow(fromKey: Key): Key | null { + getKeyBelow(fromKey: Key, options?: {includeDisabled?: boolean}): Key | null { let key: Key | null = fromKey; let startItem = this.collection.getItem(key); if (!startItem) { @@ -148,7 +148,7 @@ export class GridKeyboardDelegate> implements Key } // Find the next item - key = this.findNextKey(key, (item => item.type === 'item')); + key = this.findNextKey(key, (item => item.type === 'item'), options?.includeDisabled); if (key != null) { // If focus was on a cell, focus the cell with the same index in the next row. if (this.isCell(startItem)) { @@ -164,7 +164,7 @@ export class GridKeyboardDelegate> implements Key return null; } - getKeyAbove(fromKey: Key): Key | null { + getKeyAbove(fromKey: Key, options?: {includeDisabled?: boolean}): Key | null { let key: Key | null = fromKey; let startItem = this.collection.getItem(key); if (!startItem) { @@ -180,7 +180,7 @@ export class GridKeyboardDelegate> implements Key } // Find the previous item - key = this.findPreviousKey(key, item => item.type === 'item'); + key = this.findPreviousKey(key, item => item.type === 'item', options?.includeDisabled); if (key != null) { // If focus was on a cell, focus the cell with the same index in the previous row. if (this.isCell(startItem)) { diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index 5a75a4129ae..946f299c1bf 100644 --- a/packages/react-aria/src/selection/ListKeyboardDelegate.ts +++ b/packages/react-aria/src/selection/ListKeyboardDelegate.ts @@ -74,11 +74,11 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key)); } - private findNextNonDisabled(key: Key | null, getNext: (key: Key) => Key | null): Key | null { + private findNextNonDisabled(key: Key | null, getNext: (key: Key) => Key | null, includeDisabled = false): Key | null { let nextKey = key; while (nextKey != null) { let item = this.collection.getItem(nextKey); - if (item?.type === 'item' && !this.isDisabled(item)) { + if (item?.type === 'item' && (includeDisabled || !this.isDisabled(item))) { return nextKey; } @@ -88,16 +88,16 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return null; } - getNextKey(key: Key): Key | null { + getNextKey(key: Key, options?: {includeDisabled?: boolean}): Key | null { let nextKey: Key | null = key; nextKey = this.collection.getKeyAfter(nextKey); - return this.findNextNonDisabled(nextKey, key => this.collection.getKeyAfter(key)); + return this.findNextNonDisabled(nextKey, key => this.collection.getKeyAfter(key), options?.includeDisabled); } - getPreviousKey(key: Key): Key | null { + getPreviousKey(key: Key, options?: {includeDisabled?: boolean}): Key | null { let nextKey: Key | null = key; nextKey = this.collection.getKeyBefore(nextKey); - return this.findNextNonDisabled(nextKey, key => this.collection.getKeyBefore(key)); + return this.findNextNonDisabled(nextKey, key => this.collection.getKeyBefore(key), options?.includeDisabled); } private findKey( @@ -132,63 +132,63 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return prevRect.x === itemRect.x || prevRect.y !== itemRect.y; } - getKeyBelow(key: Key): Key | null { + getKeyBelow(key: Key, options?: {includeDisabled?: boolean}): Key | null { if (this.layout === 'grid' && this.orientation === 'vertical') { - return this.findKey(key, (key) => this.getNextKey(key), this.isSameRow); + return this.findKey(key, (key) => this.getNextKey(key, options), this.isSameRow); } else { - return this.getNextKey(key); + return this.getNextKey(key, options); } } - getKeyAbove(key: Key): Key | null { + getKeyAbove(key: Key, options?: {includeDisabled?: boolean}): Key | null { if (this.layout === 'grid' && this.orientation === 'vertical') { - return this.findKey(key, (key) => this.getPreviousKey(key), this.isSameRow); + return this.findKey(key, (key) => this.getPreviousKey(key, options), this.isSameRow); } else { - return this.getPreviousKey(key); + return this.getPreviousKey(key, options); } } - private getNextColumn(key: Key, right: boolean) { - return right ? this.getPreviousKey(key) : this.getNextKey(key); + private getNextColumn(key: Key, right: boolean, options?: {includeDisabled?: boolean}) { + return right ? this.getPreviousKey(key, options) : this.getNextKey(key, options); } - getKeyRightOf?(key: Key): Key | null { + getKeyRightOf?(key: Key, options?: {includeDisabled?: boolean}): Key | null { // This is a temporary solution for CardView until we refactor useSelectableCollection. // https://github.com/orgs/adobe/projects/19/views/32?pane=issue&itemId=77825042 let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyRightOf' : 'getKeyLeftOf'; if (this.layoutDelegate[layoutDelegateMethod]) { key = this.layoutDelegate[layoutDelegateMethod](key); - return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key)); + return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key), options?.includeDisabled); } if (this.layout === 'grid') { if (this.orientation === 'vertical') { - return this.getNextColumn(key, this.direction === 'rtl'); + return this.getNextColumn(key, this.direction === 'rtl', options); } else { - return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'rtl'), this.isSameColumn); + return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'rtl', options), this.isSameColumn); } } else if (this.orientation === 'horizontal') { - return this.getNextColumn(key, this.direction === 'rtl'); + return this.getNextColumn(key, this.direction === 'rtl', options); } return null; } - getKeyLeftOf?(key: Key): Key | null { + getKeyLeftOf?(key: Key, options?: {includeDisabled?: boolean}): Key | null { let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyLeftOf' : 'getKeyRightOf'; if (this.layoutDelegate[layoutDelegateMethod]) { key = this.layoutDelegate[layoutDelegateMethod](key); - return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key)); + return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key), options?.includeDisabled); } if (this.layout === 'grid') { if (this.orientation === 'vertical') { - return this.getNextColumn(key, this.direction === 'ltr'); + return this.getNextColumn(key, this.direction === 'ltr', options); } else { - return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'ltr'), this.isSameColumn); + return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'ltr', options), this.isSameColumn); } } else if (this.orientation === 'horizontal') { - return this.getNextColumn(key, this.direction === 'ltr'); + return this.getNextColumn(key, this.direction === 'ltr', options); } return null; diff --git a/packages/react-aria/src/selection/useSelectableItem.ts b/packages/react-aria/src/selection/useSelectableItem.ts index bc90da7cb89..73464edc755 100644 --- a/packages/react-aria/src/selection/useSelectableItem.ts +++ b/packages/react-aria/src/selection/useSelectableItem.ts @@ -76,7 +76,11 @@ export interface SelectableItemOptions extends DOMProps { * - 'none': links are disabled for both selection and actions (e.g. handled elsewhere). * @default 'action' */ - linkBehavior?: 'action' | 'selection' | 'override' | 'none' + linkBehavior?: 'action' | 'selection' | 'override' | 'none', + /** + * Whether this item is draggable. + */ + isDraggable?: boolean } export interface SelectableItemStates { diff --git a/packages/react-aria/src/table/TableKeyboardDelegate.ts b/packages/react-aria/src/table/TableKeyboardDelegate.ts index 65c5c69e7e3..569ebd1ad25 100644 --- a/packages/react-aria/src/table/TableKeyboardDelegate.ts +++ b/packages/react-aria/src/table/TableKeyboardDelegate.ts @@ -21,7 +21,7 @@ export class TableKeyboardDelegate extends GridKeyboardDelegate extends GridKeyboardDelegate extends GridKeyboardDelegate exte let rect: Rect; if (target.dropPosition === 'before') { rect = this.orientation === 'horizontal' ? - new Rect(Math.max(0, layoutInfo.rect.x - this.dropIndicatorThickness / 2), layoutInfo.rect.y, this.dropIndicatorThickness, layoutInfo.rect.height) - : new Rect(layoutInfo.rect.x, Math.max(0, layoutInfo.rect.y - this.dropIndicatorThickness / 2), layoutInfo.rect.width, this.dropIndicatorThickness); + new Rect(layoutInfo.rect.x - this.dropIndicatorThickness / 2, layoutInfo.rect.y, this.dropIndicatorThickness, layoutInfo.rect.height) + : new Rect(layoutInfo.rect.x, layoutInfo.rect.y - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness); + } else if (target.dropPosition === 'after') { // Render after last visible descendant of the drop target. let targetNode = this.collection.getItem(target.key); @@ -711,6 +712,12 @@ export class ListLayout exte currentKey = this.collection.getKeyAfter(currentKey); } } + + // TODO: ideally the last drop indicator rect's "end" needs to match the virtualizer's height/width so that the appearance/disappearance of the drop indicator + // doesn't cause the height/width of the collection to increase + // Additionally, we'd only want to do this if the collection's height is flush with the scroll container, if you + // have ListView whose contents don't completely fill the height of the container, we are actually fine with having the full height drop indicator... + // Not a great way to do this it feels though... rect = this.orientation === 'horizontal' ? new Rect(layoutInfo.rect.maxX - this.dropIndicatorThickness / 2, layoutInfo.rect.y, this.dropIndicatorThickness, layoutInfo.rect.height) : new Rect(layoutInfo.rect.x, layoutInfo.rect.maxY - this.dropIndicatorThickness / 2, layoutInfo.rect.width, this.dropIndicatorThickness);