Skip to content

feat: add highlight selection to S2 TableView#9811

Open
yihuiliao wants to merge 14 commits intomainfrom
highlight-selection-tableview
Open

feat: add highlight selection to S2 TableView#9811
yihuiliao wants to merge 14 commits intomainfrom
highlight-selection-tableview

Conversation

@yihuiliao
Copy link
Copy Markdown
Member

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@rspbot
Copy link
Copy Markdown

rspbot commented Mar 18, 2026

@rspbot
Copy link
Copy Markdown

rspbot commented Mar 23, 2026

);
}

function isNextSelected(id: Key | undefined, state: ListState<unknown>) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to the utils file since it was being shared by ListView, TreeView, and TableView

@yihuiliao yihuiliao changed the title wip: Highlight selection tableview feat: add highlight selection to S2 TableView Mar 24, 2026
@yihuiliao yihuiliao marked this pull request as ready for review March 24, 2026 00:26
@rspbot
Copy link
Copy Markdown

rspbot commented Mar 24, 2026

}
};

export const Highlight: StoryObj<typeof StaticTable> = {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need it added to docs

do we need a special one for storybook? or can the controls cover this case?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i added it only because it was easier to have an already made example of it rather than having to use the controls. we also do the same in ListView but i can remove it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, let's aim for less stories <3
We'll cover the initial rendering with chromatic

forcedColorAdjust: 'none'
});

// Unlike the other items, the first item needs to render a border on top when it is selected in highlight mode
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this true if the item is marked with inert? or aria-hidden?

Copy link
Copy Markdown
Member Author

@yihuiliao yihuiliao Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not sure if i understand what you mean by this. are you saying that this style might not appear if the row is marked as inert or aria-hidden? or are you saying that should the style be different if marked with inert or aria-hidden

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was more asking if the "count" is still off if the extra divs have those attributes

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this style will only apply if the selected item is the first row since it's kinda a special case. generally, we are rendering the bottom border, not the top, which is what makes the first row special. i also tested it out myself by adding inert and aria-hidden to the first row and the styles seem to be fine. not sure if that really answers you question. wasn't exactly sure what you meant by "count"

@rspbot
Copy link
Copy Markdown

rspbot commented Mar 24, 2026

@rspbot
Copy link
Copy Markdown

rspbot commented Mar 24, 2026

@yihuiliao
Copy link
Copy Markdown
Member Author

please ignore the 200+ changes in chromatic...pretty sure those aren't related to this PR

just wanted to check that the checkbox styles hadn't changed

function CellFocusRing() {
return <div role="presentation" className={style({...cellFocus, position: 'absolute', inset: 0, pointerEvents: 'none'})({isFocusVisible: true})} />;
function CellFocusRing({selectionStyle} : {selectionStyle?: 'checkbox' | 'highlight'}) {
return <div role="presentation" className={style({...cellFocus, position: 'absolute', inset: 0, pointerEvents: 'none', top: 0, bottom: '[-1px]'})({isFocusVisible: true, selectionStyle})} />;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this just correcting how the cell focus ring is placed with respect to the bottom of the row?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was to change the color in HCM if it was highlight selection since the Highlight outline blends in with a selected row's background

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

guess this could be changed if we decide to update hcm

default: colorMix('gray-25', 'blue-900', 10),
isHovered: colorMix('gray-25', 'blue-900', 15),
isPressed: colorMix('gray-25', 'blue-900', 15),
forcedColors: 'Highlight'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually removed this in ListView because it made the drag handle/drag handle's focus ring hard in HCM. Do we need this or is the focus indicator at the left edge of the row sufficient? Not having a background color in HCM mirrors v3 I believe

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How're you indicating selection now. I assume it's this same issue. #9805 (comment)

Copy link
Copy Markdown
Member

@LFDanLu LFDanLu Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there was a outline around the selected items, which is how v3 did it as well apparently. It's pretty subtle though I admit. Same issue as the one you linked above
image
image

Copy link
Copy Markdown
Member Author

@yihuiliao yihuiliao Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah right now this highlight selection just matches what we have in ListView and TreeView. maybe that hcm can be done separately after we get the changes in for dnd and highlight selection and we can discuss what we want to do

Comment on lines +1545 to +1548
highlight: {
default: 'focus-ring',
forcedColors: 'ButtonBorder'
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can probably get rid of this if we end up getting rid of the HCM background color on the row

Comment on lines +1607 to +1611
// In order to prevent layout shifts, we use box shadows to render the borders since we can't add an absolute position div (it messes up the cell count due to the way Table collections are built)
// In highlight mode, selected groups also have gray borders between the items in addition to having a blue outer border
// Having a border have two colors is possible, the issue is that the browser will render a diagonal line where the two borders meet
// Using box shadows gives us a bit more control on how the border colors appear
boxShadow: {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I entirely understand this part, at the moment each table row renders just the bottom border right so could we just change the color of that border based on if the row is selected and is the last/first row in its selection group?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh is it because of the top most row and if it is selected? Could we use a box shadow for that case only or something?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For highlight selection, the borders actually have a width of 0px. The reasoning for the borders having a width of 0px for highlight is for two reasons: 1) layout shifts 2) border rendering in browsers

  1. For ListView or TreeView, we render a border on all four sides and then only conditionally change the width to 0px if the next or previous item was selected. This didn't cause any layout shifts because this was done to an absolute position div. However, we can't conditionally change the border width in TableView without causing a layout shift because we can't add an absolute position div. As a result, we use box-shadows to avoid this issue.

  2. In the case for a selected item (assuming the next item is also selected), the left and right borders will be blue while the bottom border will be gray. Using borders, the blue and gray borders meet at a diagonal edge due to how browsers render border joins. See codepen. This wasn't an issue in ListView or TreeView because selected items don't have any borders between them.

Copy link
Copy Markdown
Member Author

@yihuiliao yihuiliao Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The border for the first row is handled slightly differently due to issues with the CSS paint order. Initially, I also just used a box shadow to render the top border of the first item if it was selected, but if a cell has a divider, the divider gets rendered on top of it so you have a gray line on top of the blue line. So in order to fix that, I had to use a pseudo-element to basically force it on top of the divider. I tried isolation and z-indexes which didn't work

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm gotcha, I actually used a outline here (kinda like the commented out code) for the "on" drop indicators, could we do the same here instead then?

outlineStyle: {
default: 'none',
isDropTarget: 'solid',
forcedColors: {
isFocusVisible: 'solid'
}
},
outlineWidth: {
isDropTarget: 2,
forcedColors: {
isFocusVisible: 2
}
},
outlineOffset: {
isDropTarget: -2,
forcedColors: {
isFocusVisible: -2
}
},
outlineColor: {
isDropTarget: 'blue-800',
forcedColors: 'Highlight'
},

If not, I'm happy to switch my implementation to use box shadows completely, but whichever way we choose should be used for both "on" drop and highlight selection for sure

@rspbot
Copy link
Copy Markdown

rspbot commented Mar 25, 2026

@rspbot
Copy link
Copy Markdown

rspbot commented Mar 25, 2026

## API Changes

react-aria-components

/react-aria-components:RowRenderProps

 RowRenderProps {
   allowsDragging?: boolean
   hasChildItems: boolean
   id?: Key
   isDisabled: boolean
   isDragging?: boolean
   isDropTarget?: boolean
   isExpanded: boolean
   isFocusVisible: boolean
   isFocusVisibleWithin: boolean
   isFocused: boolean
   isHovered: boolean
   isPressed: boolean
   isSelected: boolean
   level: number
   selectionBehavior: SelectionBehavior
   selectionMode: SelectionMode
+  state: TableState<unknown>
 }

@internationalized/date

/@internationalized/date:DateValue

-DateValue {
-  D: undefined
-}

@react-spectrum/s2

/@react-spectrum/s2:TableView

 TableView {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   density?: 'compact' | 'spacious' | 'regular' = 'regular'
   disabledBehavior?: DisabledBehavior = "all"
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   isQuiet?: boolean
   loadingState?: LoadingState
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onLoadMore?: () => any
   onResize?: (Map<Key, ColumnSize>) => void
   onResizeEnd?: (Map<Key, ColumnSize>) => void
   onResizeStart?: (Map<Key, ColumnSize>) => void
   onSelectionChange?: (Selection) => void
   onSortChange?: (SortDescriptor) => any
   overflowMode?: 'wrap' | 'truncate' = 'truncate'
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
+  selectionStyle?: 'checkbox' | 'highlight' = 'checkbox'
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   sortDescriptor?: SortDescriptor
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:TableViewProps

 TableViewProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   density?: 'compact' | 'spacious' | 'regular' = 'regular'
   disabledBehavior?: DisabledBehavior = "all"
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   isQuiet?: boolean
   loadingState?: LoadingState
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onLoadMore?: () => any
   onResize?: (Map<Key, ColumnSize>) => void
   onResizeEnd?: (Map<Key, ColumnSize>) => void
   onResizeStart?: (Map<Key, ColumnSize>) => void
   onSelectionChange?: (Selection) => void
   onSortChange?: (SortDescriptor) => any
   overflowMode?: 'wrap' | 'truncate' = 'truncate'
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
+  selectionStyle?: 'checkbox' | 'highlight' = 'checkbox'
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   sortDescriptor?: SortDescriptor
   styles?: StylesPropWithHeight
 }

@react-spectrum/toast

/@react-spectrum/toast:CloseFunction

-CloseFunction {
-  C: undefined
-}

@react-stately/data

/@react-stately/data:AsyncListLoadFunction

-AsyncListLoadFunction {
-  A: undefined
-}

/@react-stately/data:AsyncListLoadOptions

-AsyncListLoadOptions <C, T> {
-  cursor?: C
-  filterText?: string
-  items: Array<T>
-  loadingState?: LoadingState
-  selectedKeys: Selection
-  signal: AbortSignal
-  sortDescriptor?: SortDescriptor
-}

/@react-stately/data:AsyncListStateUpdate

-AsyncListStateUpdate <C, T> {
-  cursor?: C
-  filterText?: string
-  items: Iterable<T>
-  selectedKeys?: Iterable<Key>
-  sortDescriptor?: SortDescriptor
-}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants