From 39de835b957237182f0834b043d5f9afe2f803e7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 12 Mar 2026 16:56:36 -0700 Subject: [PATCH 01/28] add dnd stories, drag preview, drag handle to S2 ListView --- packages/@react-spectrum/s2/src/ListView.tsx | 183 +++++++++- .../s2/stories/ListView.stories.tsx | 328 +++++++++++++++++- 2 files changed, 499 insertions(+), 12 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index b749a5a4404..e8449923735 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -13,9 +13,8 @@ import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; import {baseColor, colorMix, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; -import {centerBaseline} from './CenterBaseline'; -import {Checkbox} from './Checkbox'; import { + Button, CheckboxContext, Collection, CollectionRendererContext, @@ -37,10 +36,13 @@ import { useSlottedContext, Virtualizer } from 'react-aria-components'; +import {centerBaseline} from './CenterBaseline'; +import {Checkbox} from './Checkbox'; import Chevron from '../ui-icons/Chevron'; 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 DragHandle from '../ui-icons/DragHandle'; import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; import {IconContext} from './Icon'; import {ImageContext} from './Image'; @@ -51,11 +53,11 @@ import {ProgressCircle} from './ProgressCircle'; import {Text, TextContext} from './Content'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; -import {useLocale, useLocalizedStringFormatter} from 'react-aria'; +import {useLocale, useLocalizedStringFormatter, useVisuallyHidden} from 'react-aria'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -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' | keyof GlobalDOMAttributes>, DOMProps, UnsafeStyles, ListViewStylesProps, SlotProps { /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StylesPropWithHeight, /** The current loading state of the ListView. */ @@ -158,8 +160,14 @@ 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(); + + // TODO: how to get the actual item.rendered not just the plain text + if (dragAndDropHooks && dragAndDropHooks.renderDragPreview == null) { + dragAndDropHooks.renderDragPreview = (items) => ; + } + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let rowHeight = scale === 'large' ? 50 : 40; @@ -239,6 +247,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li ({ + 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' + } +}); + +// TODO: check the below drag preview styles, try to reuse styles perhaps later but for now just keep separate +// all of these are from v3 basically +let dragPreviewWrapper = style({ + position: 'relative' +}); + +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' +}); + +let dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ + boxSizing: 'border-box', + paddingX: 0, + paddingY: 8, + backgroundColor: 'gray-25', + color: baseColor('neutral'), + position: 'relative', + display: 'grid', + // TODO get rid of description and icon if we end up not being able to grab those from the node + 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' +}); + +let dragPreviewBadge = style({ + gridArea: 'badge', + alignSelf: 'center', + paddingX: 8, + paddingY: 2, + borderRadius: 'sm', + backgroundColor: 'blue-900', + font: 'ui-sm', + fontWeight: 'bold', + color: 'white' +}); + const centeredWrapper = style({ display: 'flex', alignItems: 'center', @@ -697,6 +804,48 @@ function isLastItem(id: Key | undefined, state: ListState) { return state.collection.getLastKey() === id; } +export function ListViewDragPreview(props) { + let {items, overflowMode} = props; + let isDraggingMultiple = items.length > 1; + // TODO: item here doesn't have rendered, cuz unlike in v3, we don't have access to the collection nodes at this level... + // let firstItem = items[0]; + let itemLabel = items[0]?.['text/plain'] ?? ''; + let scale = useScale(); + + return ( +
+ {isDraggingMultiple &&
} +
+ + {/* {typeof firstItem.rendered === 'string' ? {firstItem.rendered} : firstItem.rendered} */} + {itemLabel} + {isDraggingMultiple && ( +
{items.length}
+ )} + +
+ + +
+
+ ); +} + export function ListViewItem(props: ListViewItemProps): ReactNode { let ref = useRef(null); let {hasChildItems, ...otherProps} = props; @@ -706,6 +855,7 @@ 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(); return ( {(renderProps) => { let {children} = props; - let {selectionMode, selectionBehavior, isDisabled, id, state} = renderProps; + let {selectionMode, selectionBehavior, isDisabled, id, state, allowsDragging, isFocusVisible} = renderProps; return ( - {renderProps.isFocusVisible && + {renderProps.isFocusVisible &&
} + {allowsDragging && ( +
+ +
+ )} {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( )} @@ -828,4 +990,3 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { ); } - diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index d91c0a26820..03b28e0b0fb 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -24,7 +24,9 @@ import {Key} from 'react-aria'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactNode, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; -import {useAsyncList} from 'react-stately'; +import {useAsyncList, useListData} from 'react-stately'; +import {useDragAndDrop} from 'react-aria-components'; +import { ListViewDragPreview } from '../src/ListView'; const meta: Meta = { component: ListView, @@ -581,3 +583,327 @@ 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'} +]; + +function ReorderExample(props) { + let list = useListData({ + initialItems: reorderItems + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => { + return [...keys].map(key => ({'text/plain': list.getItem(key)?.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); + } + } + }); + // TODO: just for testing + let blah = [{'text/plain': 'awegaweg', rendered: 'awegaweg'}, {'text/plain': 'agwegawkjgakwjegbkawjgbe', rendered: 'agwegawkjgakwjegbkawjgbe'}]; + return ( + <> + + + {(item: Item) => ( + + {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 { + [`${item.type}`]: JSON.stringify(item), + 'text/plain': JSON.stringify(item) + }; + }), + 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, + }); + +// 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[`${item.type}`] = itemString; + if (item.type !== 'unique_type') { + dragItem['text/plain'] = itemString; + } + + 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 = 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 + }); + + 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' +}; From 0c644da59b842b6f0dcfd97a718bf8f917723fe9 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Mar 2026 11:51:04 -0700 Subject: [PATCH 02/28] add insertion indicator and debug the on drop styles --- packages/@react-spectrum/s2/src/ListView.tsx | 176 ++++++++++++++++-- .../s2/stories/ListView.stories.tsx | 36 ++-- 2 files changed, 182 insertions(+), 30 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index e8449923735..6c371e6b64e 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -21,6 +21,8 @@ import { ContextValue, DEFAULT_SLOT, DefaultCollectionRenderer, + DragAndDropContext, + DropIndicator, GridList, GridListItem, GridListItemProps, @@ -41,7 +43,7 @@ import {Checkbox} from './Checkbox'; import Chevron from '../ui-icons/Chevron'; 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, forwardRefType, GlobalDOMAttributes, ItemDropTarget, LoadingState} from '@react-types/shared'; import DragHandle from '../ui-icons/DragHandle'; import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'}; import {IconContext} from './Icon'; @@ -49,11 +51,11 @@ import {ImageContext} from './Image'; // @ts-ignore import intlMessages from '../intl/*.json'; import LinkOutIcon from '../ui-icons/LinkOut'; +import {mergeProps, useFocusRing, useLocale, useLocalizedStringFormatter, useVisuallyHidden} from 'react-aria'; import {ProgressCircle} from './ProgressCircle'; import {Text, TextContext} from './Content'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; -import {useLocale, useLocalizedStringFormatter, useVisuallyHidden} from 'react-aria'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -114,7 +116,9 @@ 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, @@ -132,16 +136,41 @@ const listView = style({ default: 'gray-25', isQuiet: 'transparent', forcedColors: 'Background' + // TODO: check this + // isDropTarget: dropTargetBackground, + // forcedColors: { + // default: 'Background' + // } }, borderRadius: { default: 'default', isQuiet: 'none' }, borderColor: 'gray-300', + // borderColor: { + // default: 'gray-300', + // // isDropTarget: 'blue-800', + // forcedColors: { + // default: 'ButtonBorder', + // isDropTarget: 'Highlight' + // } + // }, borderWidth: { default: 1, - isQuiet: 0 + isQuiet: 0, + // forcedColors: { + // isDropTarget: 0 + // } }, + // TODO: will need to update the borders since they shift content if we change the width + // for drop target highlighting + // boxShadow: { + // isDropTarget: 'emphasized', + // forcedColors: '[inset 0 0 0 2px var(--hcm-buttonborder, ButtonBorder)]' + // }, + // forcedColorAdjust: { + // isDropTarget: 'none' + // }, borderStyle: 'solid', '--trailing-icon-width': { type: 'width', @@ -168,6 +197,10 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li dragAndDropHooks.renderDragPreview = (items) => ; } + if (dragAndDropHooks) { + dragAndDropHooks.renderDropIndicator = (target) => ; + } + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); let rowHeight = scale === 'large' ? 50 : 40; @@ -241,7 +274,8 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li layout={ListLayout} layoutOptions={{ estimatedRowHeight: rowHeight, - loaderHeight: 60 + loaderHeight: 60, + dropIndicatorThickness: 12 // 8 + 2 + 2 aka circle height + the circle thickness * 2 }}> listView({ ...renderProps, - isQuiet + isQuiet, + isDropTarget: renderProps.isDropTarget })} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -287,7 +322,8 @@ const listitem = style({ outlineStyle: 'none', boxSizing: 'border-box', @@ -295,6 +331,11 @@ const listitem = style({ position: 'absolute', zIndex: -1, @@ -392,6 +449,7 @@ const listRowBackground = style({ + flexGrow: 1, + height: 2, + backgroundColor: { + default: 'transparent', + isDropTarget: 'blue-800', + forcedColors: { + default: 'transparent', + isDropTarget: 'Highlight' + } + }, + borderBottomWidth: { + default: 0, + isDropTarget: 2 + }, + borderColor: { + isDropTarget: 'blue-800', + forcedColors: { + isDropTarget: 'Highlight' + } + }, + forcedColorAdjust: { + isDropTarget: '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: { + isDropTarget: 'none' + } +}); + const centeredWrapper = style({ display: 'flex', alignItems: 'center', @@ -766,6 +886,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? +function InsertionIndicatorVisual({target}: {target: ItemDropTarget}) { + return ( + + {({isDropTarget}) => ( +
+
+
+
+
+ )} + + ); +} + function ListSelectionCheckbox({isDisabled}: {isDisabled: boolean}) { let selectionContext = useSlottedContext(CheckboxContext, 'selection'); let isSelectionDisabled = isDisabled || !!selectionContext?.isDisabled; @@ -856,10 +992,15 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { let {direction} = useLocale(); let hasTrailingIcon = hasChildItems || (isLinkOut && !hideLinkOutIcon); let {visuallyHiddenProps} = useVisuallyHidden(); + // TODO this doesn't seem to work + let { + isFocusVisible: isFocusVisibleWithin, + focusProps: focusWithinProps + } = useFocusRing({within: true}); return ( {(renderProps) => { let {children} = props; - let {selectionMode, selectionBehavior, isDisabled, id, state, allowsDragging, isFocusVisible} = renderProps; + let {selectionMode, selectionBehavior, isDisabled, id, state, allowsDragging} = renderProps; return ( } - {allowsDragging && ( + {allowsDragging && !isDisabled && (
diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index 03b28e0b0fb..b2e03e52618 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -21,12 +21,12 @@ 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'; import {Key} from 'react-aria'; +import {ListViewDragPreview} from '../src/ListView'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactNode, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; import {useAsyncList, useListData} from 'react-stately'; import {useDragAndDrop} from 'react-aria-components'; -import { ListViewDragPreview } from '../src/ListView'; const meta: Meta = { component: ListView, @@ -42,11 +42,18 @@ const meta: Meta = { styles: style({height: 320}) }, decorators: [ - (Story) => ( -
- -
- ) + (Story, context) => { + let {disableDecorator} = context.parameters; + if (disableDecorator) { + return ; + } + + return ( +
+ +
+ ); + } ] }; @@ -700,7 +707,7 @@ function BetweenLists(props) { let item = list1.getItem(key)!; return { [`${item.type}`]: JSON.stringify(item), - 'text/plain': JSON.stringify(item) + 'text/plain': item.name }; }), onReorder(e) { @@ -865,8 +872,8 @@ function BetweenLists(props) { aria-label="First ListView in drag between list example" items={list1.items} dragAndDropHooks={dragAndDropHooksList1} - styles={style({height: 320})} - {...props}> + {...props} + styles={style({height: 320, width: 320})}> {(item: any) => ( {item.name} @@ -881,11 +888,11 @@ function BetweenLists(props) { )} + {...props} + styles={style({height: 320, width: 320})}> {(item: any) => ( {item.name} @@ -905,5 +912,8 @@ function BetweenLists(props) { export const DragBetweenLists: Story = { render: (args) => , - name: 'Drag between lists' + name: 'Drag between lists', + parameters: { + disableDecorator: true + } }; From c209c73d144ffcaf87a04a6d8d402e5d1a6a7b3e Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Mar 2026 13:20:26 -0700 Subject: [PATCH 03/28] fix drop target focus styles to avoid shifting and in HCM --- packages/@react-spectrum/s2/src/ListView.tsx | 111 +++++++++---------- 1 file changed, 52 insertions(+), 59 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 6c371e6b64e..7d746ac6615 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -116,13 +116,13 @@ 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 dropTargetBackground = colorMix('gray-25', 'blue-900', 10); - +const dropTargetBackground = colorMix('gray-25', 'blue-900', 10); const listView = style({ ...focusRing(), outlineOffset: { default: -2, - isQuiet: -1 + isQuiet: -1, + isDropTarget: -2 }, userSelect: 'none', minHeight: 0, @@ -135,43 +135,37 @@ const listView = 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, paddingY: 8, backgroundColor: 'transparent', - // backgroundColor: { - // default: 'transparent', - // isDropTarget: dropTargetBackground, - // forcedColors: {default: 'transparent', isDropTarget: 'Highlight'} - // }, color: { default: baseColor('neutral-subdued'), isSelected: baseColor('neutral'), @@ -382,37 +386,25 @@ const listitem = style Date: Fri, 13 Mar 2026 14:43:26 -0700 Subject: [PATCH 04/28] cleanup, HCM fixes, chromatic tests --- .../s2/chromatic/ListView.stories.tsx | 44 ++++ packages/@react-spectrum/s2/src/ListView.tsx | 189 +++++++++--------- .../s2/stories/ListView.stories.tsx | 40 ++-- 3 files changed, 159 insertions(+), 114 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx index b59ee06e31d..ef31408b4ae 100644 --- a/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/ListView.stories.tsx @@ -13,7 +13,9 @@ import {ActionButton, ActionButtonGroup, ActionMenu, Content, Heading, IllustratedMessage, Image, ListView, ListViewItem, MenuItem, Text} from '../src'; import {checkers} from './check'; 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'; @@ -253,3 +255,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/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 7d746ac6615..acac3ce475e 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -21,7 +21,6 @@ import { ContextValue, DEFAULT_SLOT, DefaultCollectionRenderer, - DragAndDropContext, DropIndicator, GridList, GridListItem, @@ -51,11 +50,11 @@ import {ImageContext} from './Image'; // @ts-ignore import intlMessages from '../intl/*.json'; import LinkOutIcon from '../ui-icons/LinkOut'; -import {mergeProps, useFocusRing, useLocale, useLocalizedStringFormatter, useVisuallyHidden} from 'react-aria'; import {ProgressCircle} from './ProgressCircle'; import {Text, TextContext} from './Content'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; +import {useFocusRing, useLocale, useLocalizedStringFormatter, useVisuallyHidden} from 'react-aria'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -186,13 +185,12 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li let {children, isQuiet, selectionStyle = 'checkbox', overflowMode = 'truncate', loadingState, onLoadMore, renderEmptyState: userRenderEmptyState, hideLinkOutIcon = false, dragAndDropHooks, ...otherProps} = props; let scale = useScale(); - // TODO: how to get the actual item.rendered not just the plain text if (dragAndDropHooks && dragAndDropHooks.renderDragPreview == null) { dragAndDropHooks.renderDragPreview = (items) => ; } if (dragAndDropHooks) { - dragAndDropHooks.renderDropIndicator = (target) => ; + dragAndDropHooks.renderDropIndicator = (target) => ; } let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); @@ -601,6 +599,10 @@ let listRowFocusRing = style({ pointerEvents: 'none' }); +let rowWrapper = style({ + display: 'contents' +}); + export let label = style({ gridArea: 'label', alignSelf: 'center', @@ -735,8 +737,6 @@ let dragButton = style<{isFocusVisible?: boolean}>({ } }); -// TODO: check the below drag preview styles, try to reuse styles perhaps later but for now just keep separate -// all of these are from v3 basically let dragPreviewWrapper = style({ position: 'relative' }); @@ -790,10 +790,17 @@ let dragPreviewBadge = style({ paddingX: 8, paddingY: 2, borderRadius: 'sm', - backgroundColor: 'blue-900', + backgroundColor: { + default: 'blue-900', + forcedColors: 'Highlight' + }, font: 'ui-sm', fontWeight: 'bold', - color: 'white' + color: { + default: 'white', + forcedColors: 'HighlightText' + }, + forcedColorAdjust: 'none' }); let insertionIndicatorWrapper = style({ @@ -937,7 +944,8 @@ export function ListViewDragPreview(props) { let {items, overflowMode} = props; let isDraggingMultiple = items.length > 1; // TODO: item here doesn't have rendered, cuz unlike in v3, we don't have access to the collection nodes at this level... - // let firstItem = items[0]; + // alternatives are to perhaps export this and allow the user to pass in label/description/etc nodes as children or allow them to render + // anything they way and just provide the current as a default let itemLabel = items[0]?.['text/plain'] ?? ''; let scale = useScale(); @@ -961,15 +969,11 @@ export function ListViewDragPreview(props) { } }] ]}> - {/* {typeof firstItem.rendered === 'string' ? {firstItem.rendered} : firstItem.rendered} */} {itemLabel} {isDraggingMultiple && (
{items.length}
)} - - -
); @@ -985,7 +989,6 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { let {direction} = useLocale(); let hasTrailingIcon = hasChildItems || (isLinkOut && !hideLinkOutIcon); let {visuallyHiddenProps} = useVisuallyHidden(); - // TODO this doesn't seem to work let { isFocusVisible: isFocusVisibleWithin, focusProps: focusWithinProps @@ -993,7 +996,7 @@ export function ListViewItem(props: ListViewItemProps): ReactNode { return ( {(renderProps) => { let {children} = props; - let {selectionMode, selectionBehavior, isDisabled, id, state, allowsDragging} = renderProps; + let {selectionMode, selectionBehavior, isDisabled, id, state, allowsDragging, isFocusVisible} = renderProps; return ( -
- {renderProps.isFocusVisible && +
- } - {allowsDragging && !isDisabled && ( -
- -
- )} - {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/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index b2e03e52618..bc4dd0c90e2 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -21,7 +21,6 @@ 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'; import {Key} from 'react-aria'; -import {ListViewDragPreview} from '../src/ListView'; import type {Meta, StoryObj} from '@storybook/react'; import {ReactNode, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; @@ -605,7 +604,8 @@ let reorderItems: Item[] = [ {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: 'n', name: 'Adobe Acrobat', type: 'file'}, + {id: 'o', name: 'Really really really really really long name', type: 'file'} ]; function ReorderExample(props) { @@ -625,24 +625,20 @@ function ReorderExample(props) { } } }); - // TODO: just for testing - let blah = [{'text/plain': 'awegaweg', rendered: 'awegaweg'}, {'text/plain': 'agwegawkjgakwjegbkawjgbe', rendered: 'agwegawkjgakwjegbkawjgbe'}]; + return ( - <> - - - {(item: Item) => ( - - {item.type === 'folder' ? : } - {item.name} - - )} - - + + {(item: Item) => ( + + {item.type === 'folder' ? : } + {item.name} + + )} + ); } @@ -766,7 +762,7 @@ function BetweenLists(props) { } }, getAllowedDropOperations: () => ['move', 'copy'], - shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)!.childNodes, + shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)!.childNodes }); // List 2 should allow reordering, on folder drops, and on root drops @@ -777,7 +773,7 @@ function BetweenLists(props) { let itemString = JSON.stringify(item); dragItem[`${item.type}`] = itemString; if (item.type !== 'unique_type') { - dragItem['text/plain'] = itemString; + dragItem['text/plain'] = item.name; } return dragItem; @@ -808,7 +804,7 @@ function BetweenLists(props) { if (dropOperation === 'copy') { for (let key of keys) { let item: typeof folderList2[0] = {...list2.getItem(key)!}; - item = Math.random().toString(36).slice(2); + item.id = Math.random().toString(36).slice(2); itemsToCopy.push(item); } } From 0766a265cb6f4eb5c00024bbe7bc40db02d06a87 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 13 Mar 2026 17:10:12 -0700 Subject: [PATCH 05/28] fix lint --- packages/@react-spectrum/s2/src/ListView.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index bf69ef9b318..b2f04736002 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -162,9 +162,7 @@ const listView = style({ default: 'transparent', isDropTarget: 'blue-800', forcedColors: { - default: 'transparent', isDropTarget: 'Highlight' } }, @@ -871,9 +865,7 @@ let insertionIndicatorBar = style<{isDropTarget?: boolean}>({ isDropTarget: 'Highlight' } }, - forcedColorAdjust: { - isDropTarget: 'none' - } + forcedColorAdjust: 'none' }); let insertionIndicatorCircle = style<{isDropTarget: boolean}>({ @@ -899,9 +891,7 @@ let insertionIndicatorCircle = style<{isDropTarget: boolean}>({ isDropTarget: 'Background' } }, - forcedColorAdjust: { - isDropTarget: 'none' - } + forcedColorAdjust: 'none' }); const centeredWrapper = style({ From 75e9e2e65d55bab441ddb1a787b6c0807d737a13 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Thu, 19 Mar 2026 15:43:51 -0700 Subject: [PATCH 06/28] stopping point for table --- packages/@react-spectrum/s2/intl/en-US.json | 1 + packages/@react-spectrum/s2/src/ListView.tsx | 4 +- packages/@react-spectrum/s2/src/TableView.tsx | 154 +++++++- .../s2/stories/TableView.stories.tsx | 335 ++++++++++++++++++ 4 files changed, 480 insertions(+), 14 deletions(-) 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/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 47c37a8b0c9..7ecc489a2cf 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -188,7 +188,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li } if (dragAndDropHooks) { - dragAndDropHooks.renderDropIndicator = (target) => ; + dragAndDropHooks.renderDropIndicator = (target) => ; } let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); @@ -918,7 +918,7 @@ const emptyStateWrapper = style({ // 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? -function InsertionIndicatorVisual({target}: {target: ItemDropTarget}) { +export function InsertionIndicator({target}: {target: ItemDropTarget}) { return ( {({isDropTarget}) => ( diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 829e259ddeb..b087d8fcca9 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -60,10 +60,12 @@ import Chevron from '../ui-icons/Chevron'; import Close from '../s2wf-icons/S2_Icon_Close_20_N.svg'; import {ColumnSize} from '@react-types/table'; import {CustomDialog, DialogContainer} from '..'; -import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, LinkDOMProps, LoadingState, Node} from '@react-types/shared'; +import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, ItemDropTarget, LinkDOMProps, LoadingState, Node} from '@react-types/shared'; +import DragHandle from '../ui-icons/DragHandle'; import {getActiveElement, getOwnerDocument, isFocusWithin, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; +import {InsertionIndicator, ListViewDragPreview} from './ListView'; // @ts-ignore import intlMessages from '../intl/*.json'; import {LayoutNode} from '@react-stately/layout'; @@ -77,10 +79,10 @@ import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {Button as SpectrumButton} from './Button'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef, useMediaQuery} from '@react-spectrum/utils'; +import {useFocusRing, useVisuallyHidden, VisuallyHidden} from 'react-aria'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import {VisuallyHidden} from 'react-aria'; interface S2TableProps { /** Whether the Table should be displayed with a quiet style. */ @@ -122,7 +124,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 } @@ -140,7 +142,7 @@ const tableWrapper = style({ overflow: 'clip' }, getAllowedOverrides({height: true})); -const table = style({ +const table = style({ width: 'full', height: 'full', boxSizing: 'border-box', @@ -162,7 +164,23 @@ const table = style ; + } + + if (dragAndDropHooks) { + dragAndDropHooks.renderDropIndicator = (target) => ; + } + let domRef = useDOMRef(ref); let scale = useScale(); @@ -327,6 +358,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re let scrollRef = useRef(null); let isCheckboxSelection = selectionMode === 'multiple' || selectionMode === 'single'; + let isDragAndDrop = !!dragAndDropHooks?.useDraggableCollectionState; let {selectedKeys, onSelectionChange, actionBar, actionBarHeight} = useActionBarContainer({...props, scrollRef}); @@ -350,7 +382,11 @@ 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, + // TODO: figure out why this is cut off + // TODO: override the layout in RAC and have the dropIndicators get tabindex 1, do same for gridlist + // 8px circle + 2px top + 2px bottom padding + dropIndicatorThickness: 12 }}> table({ ...renderProps, isCheckboxSelection, + isDragAndDrop, isQuiet })} selectionBehavior="toggle" selectionMode={selectionMode} onRowAction={onAction} + dragAndDropHooks={dragAndDropHooks} {...otherProps} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -899,15 +937,28 @@ 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 + + {/* TODO: intl, need to grab for other locales */} + {({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 @@ -1017,6 +1068,42 @@ const checkboxCellStyle = style({ backgroundColor: '--rowBackgroundColor' }); +// const dragCellStyle = style({ +// ...commonCellStyles, +// ...stickyCell, +// paddingStart: 12, +// paddingEnd: 4, +// alignContent: 'center', +// height: 'calc(100% - 1px)', +// borderBottomWidth: 0, +// backgroundColor: '--rowBackgroundColor' +// }); + +const dragButton = style({ + alignItems: 'center', + justifyContent: 'center', + height: 22, + width: 16, + 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' + } +}); + const cellContent = style({ truncate: true, whiteSpace: { @@ -1080,7 +1167,7 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef {({id, isFocusVisible, hasChildItems, isTreeColumn, isExpanded, isDisabled}) => ( <> - {hasChildItems && isTreeColumn && + {hasChildItems && isTreeColumn && } {children} @@ -1253,7 +1340,7 @@ export const EditableCell = forwardRef(function EditableCell(props: EditableCell {...otherProps}> {({id, isFocusVisible, hasChildItems, isTreeColumn, isExpanded, isDisabled}) => ( <> - {hasChildItems && isTreeColumn && + {hasChildItems && isTreeColumn && } } /> @@ -1544,7 +1631,22 @@ const row = style({ // isFocusVisible: 'solid' // } // }, - outlineStyle: 'none', + outlineStyle: { + default: 'none', + isDropTarget: 'solid' + }, + outlineWidth: { + isDropTarget: 2 + }, + outlineOffset: { + isDropTarget: -2 + }, + outlineColor: { + isDropTarget: { + default: 'blue-800', + forcedColors: 'Highlight' + } + }, borderTopWidth: 0, borderBottomWidth: 1, borderStartWidth: 0, @@ -1570,9 +1672,19 @@ 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); + let {visuallyHiddenProps} = useVisuallyHidden(); + let { + // TODO: can't move these props to an internal wrapper unlike listview since it expects cell children... + // Row doesn't have a data selector for focus visible either... + // TODO: options -> add data-focusvisible withing to RAC row (render prop already exists) + // have cell render props also have row focus within provided to it + // have cell also have table row render props alongside cell render props + isFocusVisible: isFocusVisibleWithin, + focusProps: focusWithinProps + } = useFocusRing({within: true}); return ( + {allowsDragging && ( + // TODO: this isn't being sticky when selection isn't enabled + // @ts-ignore + + {/* TODO: check if this isDisabled is enough, should be if selection and action is disabled */} + {/* can try to move this into cell perhaps but focusvisible needs to be set on the row and we'd need + to only render it once via something similar to isTreeCOlumn */} + {!otherProps.isDisabled && ( + + ) + } + + )} {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/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 1bee8605955..c3fbf5166b5 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -36,6 +36,7 @@ import { Text, TextField } from '../src'; +import {useDragAndDrop} from 'react-aria-components'; import {categorizeArgTypes, getActionArgs} from './utils'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; @@ -1848,3 +1849,337 @@ function NestedInlineEditExample(args) { export const TableWithNestedRowsAndInlineEditing: StoryObj = { render: (args) => }; + +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 { + [`${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 + }); + + 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 { + [`${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, + }); + + // 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[`${item.type}`] = itemString; + if (item.type !== 'unique_type') { + dragItem['text/plain'] = itemString; + } + + 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 + }); + + + 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' +}; From 375ac9323598f31ca44afa9d9c5da77df1e9642b Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 23 Mar 2026 14:29:54 -0700 Subject: [PATCH 07/28] fix lint, listview drop indicator styling for root and insertion specifically when items are selected and you are trying to do a root or insertion betwen the selected items --- packages/@react-spectrum/s2/src/ListView.tsx | 52 +++++++++++-------- packages/@react-spectrum/s2/src/TableView.tsx | 2 +- .../s2/stories/TableView.stories.tsx | 2 +- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index b7ca7e1bd6e..20fc01ecd1b 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -12,7 +12,7 @@ import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; -import {baseColor, colorMix, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; +import {baseColor, color, 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'; @@ -22,8 +22,9 @@ 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 {createContext, forwardRef, ReactElement, ReactNode, useContext, useEffect, useRef, useState} from 'react'; import {DOMProps, DOMRef, DOMRefValue, 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 { @@ -50,12 +51,12 @@ import {useDOMRef} from './useDOMRef'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; -import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import {Virtualizer} from 'react-aria-components/Virtualizer'; +import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; +import {LayoutInfo, Virtualizer} from 'react-aria-components/Virtualizer'; -export interface ListViewProps extends Omit, 'className' | 'style' | 'children' | 'selectionBehavior' | '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. */ @@ -118,7 +119,6 @@ const listView = style 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. */ @@ -258,7 +268,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li className={(props.UNSAFE_className || '') + listViewWrapper(null, props.styles)} style={props.UNSAFE_style}> ['move', 'copy'], - shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)?.childNodes, + shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)?.childNodes }); // table 2 should allow reordering, on folder drops, and on root drops From 657338496778405dc976aa734654599009bef7b2 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 23 Mar 2026 15:25:16 -0700 Subject: [PATCH 08/28] export ListViewDragPreview so people can customize it --- packages/@react-spectrum/s2/exports/index.ts | 4 +- packages/@react-spectrum/s2/src/ListView.tsx | 19 +++++--- .../s2/stories/ListView.stories.tsx | 43 +++++++++++++++---- 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index a52502c85e6..149fc709a03 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'; @@ -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'; diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 20fc01ecd1b..bb26eab3506 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -23,7 +23,7 @@ import {CollectionRendererContext, DefaultCollectionRenderer} from 'react-aria-c 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, useEffect, useRef, useState} from 'react'; -import {DOMProps, DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, ItemDropTarget, 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'}; @@ -361,6 +361,8 @@ const listitem = style({ color: baseColor('neutral'), position: 'relative', display: 'grid', - // TODO get rid of description and icon if we end up not being able to grab those from the node gridTemplateAreas: [ '. icon label badge .', '. . description badge .' @@ -977,12 +978,18 @@ 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. */ + children: ReactNode +} + export function ListViewDragPreview(props) { let {items, overflowMode} = props; let isDraggingMultiple = items.length > 1; - // TODO: item here doesn't have rendered, cuz unlike in v3, we don't have access to the collection nodes at this level... - // alternatives are to perhaps export this and allow the user to pass in label/description/etc nodes as children or allow them to render - // anything they way and just provide the current as a default let itemLabel = items[0]?.['text/plain'] ?? ''; let scale = useScale(); @@ -1006,7 +1013,7 @@ export function ListViewDragPreview(props) { } }] ]}> - {itemLabel} + {props.children ?? {itemLabel}} {isDraggingMultiple && (
{items.length}
)} diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index 9bad524ec42..59fab0f3022 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -28,7 +28,7 @@ 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'; @@ -619,22 +619,45 @@ let reorderItems: Item[] = [ {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) => { - return [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''})); - }, + 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 ( @@ -643,7 +666,7 @@ function ReorderExample(props) { items={list.items} dragAndDropHooks={dragAndDropHooks} {...props}> - {(item: Item) => ( + {(item: any) => ( {item.type === 'folder' ? : } {item.name} @@ -713,6 +736,7 @@ function BetweenLists(props) { getItems: (keys) => [...keys].map(key => { let item = list1.getItem(key)!; return { + id: item.id, [`${item.type}`]: JSON.stringify(item), 'text/plain': item.name }; @@ -773,7 +797,8 @@ function BetweenLists(props) { } }, getAllowedDropOperations: () => ['move', 'copy'], - shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)!.childNodes + shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)!.childNodes, + renderDragPreview: (items) => }); // List 2 should allow reordering, on folder drops, and on root drops @@ -782,6 +807,7 @@ function BetweenLists(props) { 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; @@ -870,7 +896,8 @@ function BetweenLists(props) { } }, getAllowedDropOperations: () => ['move', 'copy'], - shouldAcceptItemDrop: (target) => !!list2.getItem(target.key)!.childNodes + shouldAcceptItemDrop: (target) => !!list2.getItem(target.key)!.childNodes, + renderDragPreview: (items) => }); return ( From 6835682d4b7657f128617bfc04178aa5e6ed46cc Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 23 Mar 2026 15:57:04 -0700 Subject: [PATCH 09/28] table drag preview and zindex update on indicator --- packages/@react-spectrum/s2/exports/index.ts | 4 +- packages/@react-spectrum/s2/src/ListView.tsx | 17 ++-- packages/@react-spectrum/s2/src/TableView.tsx | 84 ++++++++++++++++++- .../s2/stories/ListView.stories.tsx | 2 +- .../s2/stories/TableView.stories.tsx | 28 ++++++- 5 files changed, 117 insertions(+), 18 deletions(-) diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index 149fc709a03..2ef076767c1 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -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'; @@ -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/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index bb26eab3506..a95adc11332 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -781,11 +781,11 @@ let dragButton = style<{isFocusVisible?: boolean}>({ } }); -let dragPreviewWrapper = style({ +export let dragPreviewWrapper = style({ position: 'relative' }); -let dragPreviewCardBack = style({ +export let dragPreviewCardBack = style({ position: 'absolute', zIndex: -1, top: 4, @@ -799,7 +799,7 @@ let dragPreviewCardBack = style({ backgroundColor: 'gray-25' }); -let dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ +export let dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ boxSizing: 'border-box', paddingX: 0, paddingY: 8, @@ -827,7 +827,7 @@ let dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ borderColor: 'blue-900' }); -let dragPreviewBadge = style({ +export let dragPreviewBadge = style({ gridArea: 'badge', alignSelf: 'center', paddingX: 8, @@ -983,11 +983,14 @@ export interface ListViewDragPreviewProps { 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. */ - children: ReactNode + /** + * 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) { +export function ListViewDragPreview(props: ListViewDragPreviewProps) { let {items, overflowMode} = props; let isDraggingMultiple = items.length > 1; let itemLabel = items[0]?.['text/plain'] ?? ''; diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 5fdfcbd1c39..ae50774b4c7 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -52,14 +52,15 @@ 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, ItemDropTarget, 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 {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'; -import {InsertionIndicator, ListViewDragPreview} from './ListView'; +import {dragPreviewBadge, dragPreviewCardBack, dragPreviewWrapper, InsertionIndicator, label} from './ListView'; // @ts-ignore import intlMessages from '../intl/*.json'; import {Key} from '@react-types/shared'; @@ -73,6 +74,7 @@ import {CheckboxContext as RACCheckboxContext} from 'react-aria-components/Check 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} from 'react-stately/private/virtualizer/Rect'; +import {Text, TextContext} from './Content'; 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'; @@ -87,7 +89,7 @@ import {useObjectRef} from 'react-aria/useObjectRef'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; -import {Virtualizer} from 'react-aria-components/Virtualizer'; +import {LayoutInfo, Virtualizer} from 'react-aria-components/Virtualizer'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; interface S2TableProps { @@ -208,6 +210,74 @@ 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, @@ -302,6 +372,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); @@ -330,7 +406,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re } = props; if (dragAndDropHooks && dragAndDropHooks.renderDragPreview == null) { - dragAndDropHooks.renderDragPreview = (items) => ; + dragAndDropHooks.renderDragPreview = (items) => ; } if (dragAndDropHooks) { diff --git a/packages/@react-spectrum/s2/stories/ListView.stories.tsx b/packages/@react-spectrum/s2/stories/ListView.stories.tsx index 59fab0f3022..346711c5644 100644 --- a/packages/@react-spectrum/s2/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ListView.stories.tsx @@ -801,7 +801,7 @@ function BetweenLists(props) { renderDragPreview: (items) => }); -// List 2 should allow reordering, on folder drops, and on root drops + // 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)!; diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 07bb448df7c..65af393ca77 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -22,12 +22,15 @@ import { TableBody, TableHeader, TableView, + TableViewDragPreview, TableViewProps } from '../src/TableView'; import {Collection} from 'react-aria/private/collections/CollectionBuilder'; import {Content, Heading, Text} from '../src/Content'; import Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; +import File from '../s2wf-icons/S2_Icon_File_20_N.svg'; import Filter from '../s2wf-icons/S2_Icon_Filter_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'; @@ -1887,6 +1890,17 @@ 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'}, @@ -1920,6 +1934,7 @@ function ReorderableTableExample(props) { getItems: (keys) => [...keys].map(key => { let item = list.getItem(key)!; return { + id: item.id, [`${item.type}`]: JSON.stringify(item), 'text/plain': item.name }; @@ -1955,7 +1970,8 @@ function ReorderableTableExample(props) { } } }, - acceptedDragTypes + acceptedDragTypes, + renderDragPreview: (items) => }); return ( @@ -2016,6 +2032,7 @@ function BetweenTables(props) { getItems: (keys) => [...keys].map(key => { let item = list1.getItem(key)!; return { + id: item.id, [`${item.type}`]: JSON.stringify(item), 'text/plain': item.name }; @@ -2070,7 +2087,8 @@ function BetweenTables(props) { } }, getAllowedDropOperations: () => ['move', 'copy'], - shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)?.childNodes + shouldAcceptItemDrop: (target) => !!list1.getItem(target.key)?.childNodes, + renderDragPreview: (items) => }); // table 2 should allow reordering, on folder drops, and on root drops @@ -2079,9 +2097,10 @@ function BetweenTables(props) { 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'] = itemString; + dragItem['text/plain'] = item.name; } return dragItem; @@ -2167,7 +2186,8 @@ function BetweenTables(props) { } }, getAllowedDropOperations: () => ['move', 'copy'], - shouldAcceptItemDrop: (target) => !!list2.getItem(target.key)?.childNodes + shouldAcceptItemDrop: (target) => !!list2.getItem(target.key)?.childNodes, + renderDragPreview: (items) => }); From 9f12eb760a9767c7fcaef832fd19cc19b2fbbbe4 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 23 Mar 2026 17:14:21 -0700 Subject: [PATCH 10/28] fix drag cell styles but stuck on the visually hidden --- packages/@react-spectrum/s2/src/TableView.tsx | 60 ++++++++++--------- packages/react-aria-components/src/Table.tsx | 7 ++- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index ae50774b4c7..3fe26d7eb19 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -204,8 +204,9 @@ const table = style @@ -1031,7 +1030,7 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function className={tableHeader}> {allowsDragging && ( // @ts-ignore - + {/* TODO: intl, need to grab for other locales */} {({isFocusVisible}) => ( <> @@ -1150,16 +1149,16 @@ const checkboxCellStyle = style({ backgroundColor: '--rowBackgroundColor' }); -// const dragCellStyle = style({ -// ...commonCellStyles, -// ...stickyCell, -// paddingStart: 12, -// paddingEnd: 4, -// alignContent: 'center', -// height: 'calc(100% - 1px)', -// borderBottomWidth: 0, -// backgroundColor: '--rowBackgroundColor' -// }); +const dragCellStyle = style({ + ...commonCellStyles, + ...stickyCell, + paddingStart: 4, + paddingEnd: 4, + alignContent: 'center', + height: 'calc(100% - 1px)', + borderBottomWidth: 0, + backgroundColor: '--rowBackgroundColor' +}); const dragButton = style({ alignItems: 'center', @@ -1186,6 +1185,21 @@ const dragButton = style({ } }); +const visuallyHidden = css(` + &:not(:is([role="row"][data-focus-visible-within] *)) { + border: 0; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; + } +`); + const cellContent = style({ truncate: true, whiteSpace: { @@ -1757,16 +1771,6 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row add data-focusvisible withing to RAC row (render prop already exists) - // have cell render props also have row focus within provided to it - // have cell also have table row render props alongside cell render props - isFocusVisible: isFocusVisibleWithin, - focusProps: focusWithinProps - } = useFocusRing({within: true}); return ( ( {allowsDragging && ( - // TODO: this isn't being sticky when selection isn't enabled // @ts-ignore - + {/* TODO: check if this isDisabled is enough, should be if selection and action is disabled */} {/* can try to move this into cell perhaps but focusvisible needs to be set on the row and we'd need to only render it once via something similar to isTreeCOlumn */} {!otherProps.isDisabled && ( ) 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 Date: Tue, 24 Mar 2026 10:11:34 -0700 Subject: [PATCH 11/28] fix visually hidden for drag handle and begin root drop styles --- packages/@react-spectrum/s2/src/TableView.tsx | 94 +++++++++++-------- 1 file changed, 54 insertions(+), 40 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 3fe26d7eb19..9c22bd4c4ce 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -150,6 +150,7 @@ const tableWrapper = style({ overflow: 'clip' }, getAllowedOverrides({height: true})); +const dropTargetBackground = colorMix('gray-25', 'blue-900', 10); const table = style({ width: 'full', height: 'full', @@ -163,32 +164,27 @@ const table = style({ height: 'full', position: 'relative', boxSizing: 'border-box', - backgroundColor: '--rowBackgroundColor', + backgroundColor: { + default: '--rowBackgroundColor', + ':is([role="grid"][data-drop-target] *)': { + // TODO: these will need to have a blend, selected should be a bit darker, and teh default should be a bit darker than the background + // color on the body. Will need to apply the same color scheme to the checkbox cell + default: 'transparent', + isSelected: 'transparent' + }, + }, '--rowBackgroundColor': { type: 'backgroundColor', value: rowBackgroundColor @@ -1727,6 +1739,8 @@ const row = style({ // isFocusVisible: 'solid' // } // }, + // TODO: this literally runs into the same problem as noted above for the focusedColors experience when the user targets the + // row for a "on" drop operation outlineStyle: { default: 'none', isDropTarget: 'solid' @@ -1792,7 +1806,7 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row dragButton({isFocusVisible}) + visuallyHidden}> + className={dragButton}> ) From 1b8f434bb73ad0053fb0ea744e550fc2770e8147 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Mar 2026 11:37:38 -0700 Subject: [PATCH 12/28] add root drop styling --- packages/@react-spectrum/s2/src/TableView.tsx | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 9c22bd4c4ce..28dd2fb5d93 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -166,9 +166,8 @@ const table = style({ height: 'full', position: 'relative', boxSizing: 'border-box', - backgroundColor: { - default: '--rowBackgroundColor', - ':is([role="grid"][data-drop-target] *)': { - // TODO: these will need to have a blend, selected should be a bit darker, and teh default should be a bit darker than the background - // color on the body. Will need to apply the same color scheme to the checkbox cell - default: 'transparent', - isSelected: 'transparent' - }, - }, + backgroundColor: '--rowBackgroundColor', '--rowBackgroundColor': { type: 'backgroundColor', value: rowBackgroundColor From a6a22f2adde4fed61cd176e1b0d84e28be353cbe Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Mar 2026 14:34:31 -0700 Subject: [PATCH 13/28] extend row drop target outline style conditionall in sticky cells so it doesnt look cut off also does the same for HCM for general row focus --- packages/@react-spectrum/s2/src/ListView.tsx | 38 ++++++---- packages/@react-spectrum/s2/src/TableView.tsx | 74 +++++++++---------- 2 files changed, 59 insertions(+), 53 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index a95adc11332..fdf1699b161 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -306,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({ @@ -1134,9 +1135,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)', @@ -1147,6 +1165,7 @@ const checkboxCellStyle = style({ const dragCellStyle = style({ ...commonCellStyles, ...stickyCell, + ...rowDropTargetStickyOutline, paddingStart: 4, paddingEnd: 4, alignContent: 'center', @@ -1726,47 +1745,28 @@ 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' - // } - // }, - // TODO: this literally runs into the same problem as noted above for the focusedColors experience when the user targets the - // row for a "on" drop operation outlineStyle: { default: 'none', - isDropTarget: 'solid' + isDropTarget: 'solid', + forcedColors: { + isFocusVisible: 'solid' + } }, outlineWidth: { - isDropTarget: 2 + isDropTarget: 2, + forcedColors: { + isFocusVisible: 2 + } }, outlineOffset: { - isDropTarget: -2 + isDropTarget: -2, + forcedColors: { + isFocusVisible: -2 + } }, outlineColor: { - isDropTarget: { - default: 'blue-800', - forcedColors: 'Highlight' - } + isDropTarget: 'blue-800', + forcedColors: 'Highlight' }, borderTopWidth: 0, borderBottomWidth: 1, @@ -1806,7 +1806,7 @@ 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 From 8383fb0adf4cf97737aa979fd264f153e937b45d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 24 Mar 2026 16:07:53 -0700 Subject: [PATCH 14/28] fix various disabledBehavior cases and add translations --- packages/@react-spectrum/s2/intl/ar-AE.json | 1 + packages/@react-spectrum/s2/intl/bg-BG.json | 1 + packages/@react-spectrum/s2/intl/cs-CZ.json | 1 + packages/@react-spectrum/s2/intl/da-DK.json | 1 + packages/@react-spectrum/s2/intl/de-DE.json | 1 + packages/@react-spectrum/s2/intl/el-GR.json | 1 + packages/@react-spectrum/s2/intl/es-ES.json | 1 + packages/@react-spectrum/s2/intl/et-EE.json | 1 + packages/@react-spectrum/s2/intl/fi-FI.json | 1 + packages/@react-spectrum/s2/intl/fr-FR.json | 1 + packages/@react-spectrum/s2/intl/he-IL.json | 1 + packages/@react-spectrum/s2/intl/hr-HR.json | 1 + packages/@react-spectrum/s2/intl/hu-HU.json | 1 + packages/@react-spectrum/s2/intl/it-IT.json | 1 + packages/@react-spectrum/s2/intl/ja-JP.json | 1 + packages/@react-spectrum/s2/intl/ko-KR.json | 1 + packages/@react-spectrum/s2/intl/lt-LT.json | 1 + packages/@react-spectrum/s2/intl/lv-LV.json | 1 + packages/@react-spectrum/s2/intl/nb-NO.json | 1 + packages/@react-spectrum/s2/intl/nl-NL.json | 1 + packages/@react-spectrum/s2/intl/pl-PL.json | 1 + packages/@react-spectrum/s2/intl/pt-BR.json | 1 + packages/@react-spectrum/s2/intl/pt-PT.json | 1 + packages/@react-spectrum/s2/intl/ro-RO.json | 1 + packages/@react-spectrum/s2/intl/ru-RU.json | 1 + packages/@react-spectrum/s2/intl/sk-SK.json | 1 + packages/@react-spectrum/s2/intl/sl-SI.json | 1 + packages/@react-spectrum/s2/intl/sr-SP.json | 1 + packages/@react-spectrum/s2/intl/sv-SE.json | 1 + packages/@react-spectrum/s2/intl/tr-TR.json | 1 + packages/@react-spectrum/s2/intl/uk-UA.json | 1 + packages/@react-spectrum/s2/intl/zh-CN.json | 1 + packages/@react-spectrum/s2/intl/zh-TW.json | 1 + packages/@react-spectrum/s2/src/ListView.tsx | 26 +++++++-------- packages/@react-spectrum/s2/src/TableView.tsx | 33 ++++++++----------- .../s2/stories/ListView.stories.tsx | 3 ++ .../s2/stories/TableView.stories.tsx | 6 ++-- 37 files changed, 65 insertions(+), 36 deletions(-) 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/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 fdf1699b161..1b439f7b8af 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -12,7 +12,7 @@ import {ActionButtonGroupContext} from './ActionButtonGroup'; import {ActionMenuContext} from './ActionMenu'; -import {baseColor, color, colorMix, focusRing, fontRelative, space, style} from '../style' with {type: 'macro'}; +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'; @@ -22,7 +22,7 @@ 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, useEffect, useRef, useState} from 'react'; +import {createContext, forwardRef, ReactElement, ReactNode, useContext, useRef} from 'react'; 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'; @@ -41,6 +41,7 @@ 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'; import {ListState} from 'react-stately/useListState'; @@ -54,7 +55,6 @@ import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatte import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; -import {LayoutInfo, Virtualizer} from 'react-aria-components/Virtualizer'; 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. */ @@ -118,7 +118,7 @@ const listView = style } - {allowsDragging && !isDisabled && ( + {allowsDragging && (
- + {!isDisabled && ( + + )}
)} {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 478de833cc1..d2dae3abca7 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -54,16 +54,17 @@ import {CustomDialog} from './CustomDialog'; import {DialogContainer} from './DialogContainer'; 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'; -import {dragPreviewBadge, dragPreviewCardBack, dragPreviewWrapper, InsertionIndicator, label} from './ListView'; // @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'; @@ -74,13 +75,12 @@ import {CheckboxContext as RACCheckboxContext} from 'react-aria-components/Check 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} from 'react-stately/private/virtualizer/Rect'; -import {Text, TextContext} from './Content'; 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 {useFocusRing} from 'react-aria/useFocusRing'; import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; @@ -88,8 +88,6 @@ import {useMediaQuery} from './useMediaQuery'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import {useVisuallyHidden} from 'react-aria/VisuallyHidden'; -import {LayoutInfo, Virtualizer} from 'react-aria-components/Virtualizer'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; interface S2TableProps { @@ -164,12 +162,8 @@ const table = style(null); let isCheckboxSelection = selectionMode === 'multiple' || selectionMode === 'single'; @@ -486,6 +482,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re selectionMode={selectionMode} onRowAction={onAction} dragAndDropHooks={dragAndDropHooks} + disabledBehavior={disabledBehavior} {...otherProps} selectedKeys={selectedKeys} defaultSelectedKeys={undefined} @@ -1027,7 +1024,6 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function {allowsDragging && ( // @ts-ignore - {/* TODO: intl, need to grab for other locales */} {({isFocusVisible}) => ( <> {isFocusVisible && } @@ -1197,7 +1193,7 @@ const dragButton = style({ // TODO: no clip or clipPath, but this might be sufficient? height: { default: 1, - ':is([role="row"][data-focus-visible-within] *)': 22, + ':is([role="row"][data-focus-visible-within] *)': 22 }, width: { default: 1, @@ -1205,7 +1201,7 @@ const dragButton = style({ }, margin: { default: '[-1]', - ':is([role="row"][data-focus-visible-within] *)': 0 + ':is([role="row"][data-focus-visible-within] *)': 0 }, overflow: { default: 'hidden', @@ -1811,10 +1807,7 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row - {/* TODO: check if this isDisabled is enough, should be if selection and action is disabled */} - {/* can try to move this into cell perhaps but focusvisible needs to be set on the row and we'd need - to only render it once via something similar to isTreeCOlumn */} - {!otherProps.isDisabled && ( + {!(otherProps.isDisabled && tableVisualOptions.disabledBehavior === 'all') && ( + )} +
+ )} {selectionMode !== 'none' && selectionBehavior === 'toggle' && selectionStyle !== 'highlight' && ( // TODO: add transition? (
diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index b9bebd80f52..969fb49fb5f 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -19,9 +19,8 @@ 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 Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; - +import File from '../s2wf-icons/S2_Icon_File_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'; @@ -34,6 +33,7 @@ import React, {ReactElement, useCallback, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; import { TreeView, + TreeViewDragPreview, TreeViewItem, TreeViewItemContent, TreeViewItemProps, @@ -42,7 +42,9 @@ import { TreeViewProps } from '../src/TreeView'; import {useAsyncList} from 'react-stately/useAsyncList'; +import {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; @@ -927,3 +929,85 @@ 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 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, + onReorder(e) { + 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); + } + } + }, + renderDragPreview: (items) => + }); + + return ( +
+ + {(item: any) => ( + + )} + +
+ ); +} + +export const Reorderable: StoryObj = { + render: (args) => , + name: 'Drag and drop reordering' +}; diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index a754ba23d82..d4b582c7c34 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -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. */ From 9bd191d6be149f1fe297d7998a03a94c8a118d99 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 3 Apr 2026 16:52:45 -0700 Subject: [PATCH 23/28] lint --- packages/@react-spectrum/s2/src/ListView.tsx | 2 +- packages/@react-spectrum/s2/src/TableView.tsx | 4 ++-- packages/@react-spectrum/s2/stories/TreeView.stories.tsx | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 394f692ca8d..3ece19d4bfd 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -56,7 +56,7 @@ import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; 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. */ diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index e5d168bce58..3e78a2c9dcb 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -61,16 +61,16 @@ 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'; diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index 969fb49fb5f..faf3212d8ef 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -20,7 +20,6 @@ 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 Edit from '../s2wf-icons/S2_Icon_Edit_20_N.svg'; -import File from '../s2wf-icons/S2_Icon_File_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'; From a09386a9194f4c671ea6bd04742afdd0ae679b47 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 6 Apr 2026 12:44:48 -0700 Subject: [PATCH 24/28] add proper drop indicator indenting and reorder story --- packages/@react-spectrum/s2/src/ListView.tsx | 4 +- packages/@react-spectrum/s2/src/TreeView.tsx | 59 ++++++++++++++++--- .../s2/stories/TreeView.stories.tsx | 57 +++++++++++------- 3 files changed, 87 insertions(+), 33 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 3ece19d4bfd..40f439293a3 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -859,7 +859,7 @@ let insertionIndicatorWrapper = style({ alignItems: 'center' }); -let insertionIndicatorBar = style<{isDropTarget?: boolean}>({ +export let insertionIndicatorBar = style<{isDropTarget?: boolean}>({ flexGrow: 1, height: 2, backgroundColor: { @@ -882,7 +882,7 @@ let insertionIndicatorBar = style<{isDropTarget?: boolean}>({ forcedColorAdjust: 'none' }); -let insertionIndicatorCircle = style<{isDropTarget: boolean}>({ +export let insertionIndicatorCircle = style<{isDropTarget: boolean}>({ width: 8, height: 8, borderRadius: 'full', diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index e14697728f1..76f801ec0ff 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -19,8 +19,9 @@ import {Checkbox} from './Checkbox'; import Chevron from '../ui-icons/Chevron'; 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, InsertionIndicator, isFirstItem, isPrevSelected, label, S2ListLayout} from './ListView'; +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'; @@ -48,7 +49,7 @@ 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';; +import {Virtualizer} from 'react-aria-components/Virtualizer'; interface S2TreeProps { /** Handler that is called when a user performs an action on a row. */ @@ -93,7 +94,16 @@ 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 @@ -143,11 +153,11 @@ const tree = style({ }); // TODO: same as TableView, to update based on feedback -export let dragPreviewWrapper = style({ +const dragPreviewWrapper = style({ position: 'relative' }); -export let dragPreviewCardBack = style({ +const dragPreviewCardBack = style({ position: 'absolute', zIndex: -1, top: 4, @@ -161,7 +171,7 @@ export let dragPreviewCardBack = style({ backgroundColor: 'gray-25' }); -let dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ +const dragPreviewCard = style<{scale?: 'medium' | 'large'}>({ boxSizing: 'border-box', paddingX: 0, paddingY: 8, @@ -235,6 +245,36 @@ export function TreeViewDragPreview(props: TreeDragPreviewProps) { 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. */ @@ -246,10 +286,11 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr dragAndDropHooks.renderDragPreview = (items) => ; } + let hasCheckbox = props.selectionMode !== 'none' && selectionStyle !== 'highlight'; + if (dragAndDropHooks) { - dragAndDropHooks.renderDropIndicator = (target) => ; + dragAndDropHooks.renderDropIndicator = (target) => ; } - let renderer; if (typeof children === 'function') { renderer = children; @@ -263,7 +304,7 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr return (
) { 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)!; @@ -965,22 +973,27 @@ function ReorderableTree(props: TreeViewProps) { let {dragAndDropHooks} = useDragAndDrop({ getItems, - onReorder(e) { - 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); + 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); } - } else { - console.error('Target node not found for drop on:', e.target.key); } + } catch (error) { + console.error(error); } }, renderDragPreview: (items) => @@ -991,15 +1004,15 @@ function ReorderableTree(props: TreeViewProps) { - {(item: any) => ( + {(item) => ( + id={item.id} + icon={item.icon} + childItems={item.childItems} + textValue={item.name} + name={item.name} /> )}
From 98022f5cdbe4096e98433c9b5244c80b79bf930d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 6 Apr 2026 16:01:16 -0700 Subject: [PATCH 25/28] add drag between trees and fix issue with drop positions when scrolling when we had the tree dimensions dictated by the wrapper, there was weird broken behavior when draging over items further down the list. This doesnt happen when the height and what not are applied on the tree itself --- .../s2/stories/TreeView.stories.tsx | 205 +++++++++++++++++- 1 file changed, 196 insertions(+), 9 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index 4716699621e..feeaacaa64e 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -41,7 +41,7 @@ import { TreeViewProps } from '../src/TreeView'; import {useAsyncList} from 'react-stately/useAsyncList'; -import {useDragAndDrop} from 'react-aria-components/useDragAndDrop'; +import {isTextDropItem, useDragAndDrop} from 'react-aria-components/useDragAndDrop'; import {useListData} from 'react-stately/useListData'; import {useTreeData} from 'react-stately/useTreeData'; @@ -399,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: })) ]} ]; @@ -1000,12 +1000,194 @@ function ReorderableTree(props: TreeViewProps) { }); 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 processItem = (item) => ({ + ...item.value, + id: item.key, + childItems: item.children ? item.children.map(processItem) : [] + }); + + let items1 = treeData1.items.map(processItem); + let getItemsTree1 = (keys) => [...keys].map(key => { + let item = treeData1.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: dragHooksTree1} = useDragAndDrop({ + getItems: getItemsTree1, + getAllowedDropOperations: () => ['move'], + onMove(e: DroppableCollectionReorderEvent) { + try { + if (e.target.dropPosition === 'before') { + treeData1.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + treeData1.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + let targetNode = treeData1.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++) { + treeData1.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) => + }); + + + let treeData2 = useTreeData({ + initialItems: [], + getKey: item => item.id as Key, + getChildren: item => item.childItems as TreeViewItemType[] + }); + + 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 = (item) => ({ + ...item, + icon: undefined, + id: Math.random().toString(36), + childItems: item.childItems?.map(convertItem) + }); + return convertItem(parsed); + })); + }; + + let items2 = treeData2.items.map(processItem); + let getItemsTree2 = (keys) => [...keys].map(key => { + let item = treeData2.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 onInsert = async (e) => { + let items = await processIncomingItems(e); + if (e.target.dropPosition === 'before') { + treeData2.insertBefore(e.target.key, ...items); + } else if (e.target.dropPosition === 'after') { + treeData2.insertAfter(e.target.key, ...items); + } + }; + + let {dragAndDropHooks: dragHooksTree2} = useDragAndDrop({ + getItems: getItemsTree2, + getAllowedDropOperations: () => ['move'], + acceptedDragTypes: ['tree-item'], + onInsert, + async onItemDrop(e) { + let items = await processIncomingItems(e); + treeData2.insert(e.target.key, 0, ...items); + }, + async onRootDrop(e) { + let items = await processIncomingItems(e); + treeData2.insert(null, 0, ...items); + }, + // shouldAcceptItemDrop + onMove(e: DroppableCollectionReorderEvent) { + console.log(`moving [${[...e.keys].join(',')}] ${e.target.dropPosition} ${e.target.key} in SecondTree`); + try { + if (e.target.dropPosition === 'before') { + treeData2.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + treeData2.moveAfter(e.target.key, e.keys); + } else if (e.target.dropPosition === 'on') { + let targetNode = treeData2.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++) { + treeData2.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); + } + } + }); + + return ( +
+ + {(item) => ( + + )} + + styles={style({width: 300, height: 300})} + renderEmptyState={renderEmptyState} + aria-label="second draggable tree" + items={items2} + dragAndDropHooks={dragHooksTree2}> {(item) => ( ) { ); } -export const Reorderable: StoryObj = { - render: (args) => , - name: 'Drag and drop reordering' +export const DragBetweenTrees: StoryObj = { + render: (args) => , + name: 'Drag between trees', + parameters: { + docs: { + disable: true + } + } }; From a1cd427748351cea34263bf8cf9f56f022450957 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 6 Apr 2026 16:16:09 -0700 Subject: [PATCH 26/28] make story so you can drag in both directions --- .../s2/stories/TreeView.stories.tsx | 183 ++++++++---------- 1 file changed, 79 insertions(+), 104 deletions(-) diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index feeaacaa64e..de00765732f 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -1030,140 +1030,115 @@ function BetweenTrees(props: TreeViewProps) { 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 items1 = treeData1.items.map(processItem); - let getItemsTree1 = (keys) => [...keys].map(key => { - let item = treeData1.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: dragHooksTree1} = useDragAndDrop({ - getItems: getItemsTree1, - getAllowedDropOperations: () => ['move'], - onMove(e: DroppableCollectionReorderEvent) { - try { - if (e.target.dropPosition === 'before') { - treeData1.moveBefore(e.target.key, e.keys); - } else if (e.target.dropPosition === 'after') { - treeData1.moveAfter(e.target.key, e.keys); - } else if (e.target.dropPosition === 'on') { - let targetNode = treeData1.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++) { - treeData1.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) => - }); - - - let treeData2 = useTreeData({ - initialItems: [], - getKey: item => item.id as Key, - getChildren: item => item.childItems as TreeViewItemType[] + 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 = (item) => ({ - ...item, - icon: undefined, + let convertItem = (i) => ({ + ...i, id: Math.random().toString(36), - childItems: item.childItems?.map(convertItem) + childItems: i.childItems?.map(convertItem) }); return convertItem(parsed); })); }; - let items2 = treeData2.items.map(processItem); - let getItemsTree2 = (keys) => [...keys].map(key => { - let item = treeData2.getItem(key)!; + 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 serializeItem = (nodeItem) => ({ - ...nodeItem.value, - childItems: nodeItem.children ? [...nodeItem.children].map(serializeItem) : [] - }); + 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(serializeItem(item)) + 'tree-item': JSON.stringify(serializeNode(item)) }; }); - let onInsert = async (e) => { - let items = await processIncomingItems(e); - if (e.target.dropPosition === 'before') { - treeData2.insertBefore(e.target.key, ...items); - } else if (e.target.dropPosition === 'after') { - treeData2.insertAfter(e.target.key, ...items); - } - }; + 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: getItemsTree2, - getAllowedDropOperations: () => ['move'], - acceptedDragTypes: ['tree-item'], - onInsert, - async onItemDrop(e) { - let items = await processIncomingItems(e); - treeData2.insert(e.target.key, 0, ...items); - }, - async onRootDrop(e) { - let items = await processIncomingItems(e); - treeData2.insert(null, 0, ...items); - }, - // shouldAcceptItemDrop - onMove(e: DroppableCollectionReorderEvent) { - console.log(`moving [${[...e.keys].join(',')}] ${e.target.dropPosition} ${e.target.key} in SecondTree`); - try { - if (e.target.dropPosition === 'before') { - treeData2.moveBefore(e.target.key, e.keys); - } else if (e.target.dropPosition === 'after') { - treeData2.moveAfter(e.target.key, e.keys); - } else if (e.target.dropPosition === 'on') { - let targetNode = treeData2.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++) { - treeData2.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); + 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 (
Date: Mon, 6 Apr 2026 16:43:30 -0700 Subject: [PATCH 27/28] remove some todos after testing --- packages/@react-spectrum/s2/src/ListView.tsx | 2 -- packages/@react-spectrum/s2/src/TableView.tsx | 2 +- packages/@react-spectrum/s2/src/TreeView.tsx | 5 ----- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 40f439293a3..2afa3f0a63d 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -358,8 +358,6 @@ const listitem = style({ - // TODO: check these styles outlineStyle: { default: 'none', isDropTarget: 'solid' @@ -640,7 +639,6 @@ export const TreeViewItem = (props: TreeViewItemProps): ReactNode => { let { href } = props; - // let {selectionStyle} = useContext(InternalTreeViewContext); return ( { className={(renderProps) => treeRow({ ...renderProps, isLink: !!href - // TODO: don't think we need these? - // selectionStyle, - // isPreviousSelected: isPrevSelected(renderProps.id, renderProps.state) })} /> ); }; From cb18f2217869d3fcc6a665216b98ec8c5d2606a5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 6 Apr 2026 16:57:29 -0700 Subject: [PATCH 28/28] fix mobile insertion drop indicator heights --- packages/@react-spectrum/s2/src/ListView.tsx | 4 +++- packages/@react-spectrum/s2/src/TableView.tsx | 5 +++-- packages/@react-spectrum/s2/src/TreeView.tsx | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 2afa3f0a63d..b5fecf47498 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -200,6 +200,8 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li 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); @@ -272,7 +274,7 @@ export const ListView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Li layoutOptions={{ estimatedRowHeight: rowHeight, loaderHeight: 60, - dropIndicatorThickness: 12 // 8 + 2 + 2 aka circle height + the circle thickness * 2 + dropIndicatorThickness }}> (props: TreeViewProps, ref: DOMRef) { 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) => ; @@ -310,7 +312,7 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr layout={S2ListLayout} layoutOptions={{ rowHeight: scale === 'large' ? 50 : 40, - dropIndicatorThickness: 12 + dropIndicatorThickness }}>