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 a52502c85e6..2ef076767c1 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 6537b3cc437..1b439f7b8af 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -13,18 +13,19 @@ 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'; import Chevron from '../ui-icons/Chevron'; import {Collection} from 'react-aria/private/collections/CollectionBuilder'; import {CollectionRendererContext, DefaultCollectionRenderer} from 'react-aria-components/Collection'; - import {ContextValue, DEFAULT_SLOT, Provider, SlotProps, useSlottedContext} from 'react-aria-components/utils'; - 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 {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 {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; import { GridList, @@ -37,23 +38,25 @@ 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/private/layout/ListLayout'; -// @ts-ignore import {ListState} from 'react-stately/useListState'; 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' | 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. */ @@ -110,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, @@ -127,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: { @@ -148,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. */ @@ -156,8 +187,17 @@ 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; @@ -228,15 +268,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} @@ -263,6 +306,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, @@ -290,11 +351,6 @@ const listitem = style({ position: 'absolute', zIndex: -1, @@ -418,14 +492,11 @@ const listRowBackground = style({ + alignItems: 'center', + justifyContent: 'center', + // TODO: arbitrary, basically taken from v3 + height: 22, + width: 16, + 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: [edgeToText(40), 'auto', 'minmax(0, 1fr)', 'auto', edgeToText(40)], + 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' +}); + +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' +}); + +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', @@ -697,6 +930,22 @@ const emptyStateWrapper = style({ padding: 16 }); +// TODO: since I'm not using absolute positioning, the drop indicator at the very top isn't flush with the top edge of the listview +// maybe ok? +export function InsertionIndicator({target}: {target: ItemDropTarget}) { + return ( + + {({isDropTarget}) => ( +
+
+
+
+
+ )} + + ); +} + function ListSelectionCheckbox({isDisabled}: {isDisabled: boolean}) { let selectionContext = useSlottedContext(CheckboxContext, 'selection'); let isSelectionDisabled = isDisabled || !!selectionContext?.isDisabled; @@ -735,6 +984,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; @@ -744,6 +1041,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 547cfc6b917..a4c5f560933 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'; @@ -52,14 +52,19 @@ 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, Virtualizer} from 'react-aria-components/Virtualizer'; import {LayoutNode} from 'react-stately/private/layout/ListLayout'; import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; @@ -73,6 +78,7 @@ import {Rect} from 'react-stately/private/virtualizer/Rect'; 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'; @@ -82,7 +88,6 @@ import {useMediaQuery} from './useMediaQuery'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import {Virtualizer} from 'react-aria-components/Virtualizer'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; interface S2TableProps { @@ -125,7 +130,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 } @@ -143,7 +148,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', @@ -156,16 +162,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, @@ -277,6 +363,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); @@ -300,9 +392,19 @@ 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(); @@ -325,11 +427,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}); @@ -353,7 +457,9 @@ 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, + // 8px circle + 2px top + 2px bottom padding + dropIndicatorThickness: 12 }}> table({ ...renderProps, isCheckboxSelection, + isDragAndDrop, isQuiet })} selectionBehavior="toggle" selectionMode={selectionMode} onRowAction={onAction} + dragAndDropHooks={dragAndDropHooks} + disabledBehavior={disabledBehavior} {...otherProps} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -902,15 +1011,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 @@ -1010,9 +1131,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 outline 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 -1px 0 0 ${color('blue-800')}]`, + forcedColors: '[inset 0 2px 0 0 Highlight, inset 0 -1px 0 0 Highlight]' + }, + ':is([role="row"][data-focus-visible] *)': { + forcedColors: '[inset 0 2px 0 0 Highlight, inset 0 -1px 0 0 Highlight]' + } + } +} as const; + const checkboxCellStyle = style({ ...commonCellStyles, ...stickyCell, + ...rowDropTargetStickyOutline, paddingStart: 16, alignContent: 'center', height: 'calc(100% - 1px)', @@ -1020,6 +1158,65 @@ 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' + }, + // TODO: no clip or clipPath, but this might be sufficient? + height: { + default: 1, + ':is([role="row"][data-focus-visible-within] *)': 22 + }, + width: { + default: 1, + ':is([role="row"][data-focus-visible-within] *)': 16 + }, + 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' + } +}); + const cellContent = style({ truncate: true, whiteSpace: { @@ -1083,7 +1280,7 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef {({id, isFocusVisible, hasChildItems, isTreeColumn, isExpanded, isDisabled}) => ( <> - {hasChildItems && isTreeColumn && + {hasChildItems && isTreeColumn && } {children} @@ -1256,7 +1453,7 @@ export const EditableCell = forwardRef(function EditableCell(props: EditableCell {...otherProps}> {({id, isFocusVisible, hasChildItems, isTreeColumn, isExpanded, isDisabled}) => ( <> - {hasChildItems && isTreeColumn && + {hasChildItems && isTreeColumn && } } /> @@ -1474,6 +1671,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', @@ -1490,7 +1706,9 @@ const rowBackgroundColor = { }, forcedColors: { default: 'Background' - } + }, + ':is([role="grid"][data-drop-target] *)': rootRowDropStyles, + isDropTarget: rowDropStyles } as const; const rowTextColor = { @@ -1523,31 +1741,29 @@ 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', + outlineStyle: { + default: 'none', + isDropTarget: 'solid', + forcedColors: { + isFocusVisible: 'solid' + } + }, + outlineWidth: { + isDropTarget: 2, + forcedColors: { + isFocusVisible: 2 + } + }, + outlineOffset: { + isDropTarget: -2, + forcedColors: { + isFocusVisible: -2 + } + }, + outlineColor: { + isDropTarget: 'blue-800', + forcedColors: 'Highlight' + }, borderTopWidth: 0, borderBottomWidth: 1, borderStartWidth: 0, @@ -1573,7 +1789,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); @@ -1586,8 +1802,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/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index 30f42843fe5..b1976e11fce 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 d64c99799a0..4053c49290d 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/private/collections/CollectionBuilder'; @@ -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-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index e8b352c5871..5a22a94518a 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -260,7 +260,7 @@ class TableCollection extends BaseCollection implements ITableCollection