Skip to content

feat: add virtualization to S2 combobox and picker #8110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 36 commits into
base: main
Choose a base branch
from

Conversation

yihuiliao
Copy link
Member

Closes

✅ 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

rspbot commented Apr 17, 2025

@rspbot
Copy link

rspbot commented Apr 17, 2025

@rspbot
Copy link

rspbot commented Apr 17, 2025

);
}

const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef<HTMLElement>) {
Copy link
Member Author

Choose a reason for hiding this comment

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

it does feel a little overkill to import separator separately but it's at least a pretty simple component ...i can probably simplify the code since this isn't being used anywhere else except for picker + combobox. given that it's not interactive and just a visual thing, i don't think it has any accessibility requirements.

otherwise, i experimented a little bit with adding a bottom border which would work i think? i would need to play around with the linear-gradient so that the visible border aligns with the item text and it'd require some additional targeting of first-child/last-child but i think it should be possible. that said, not sure if it's worth the time figuring this out if this current solution works

Copy link
Member

Choose a reason for hiding this comment

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

What is the reason we can't use the S2 Divider which already renders the RAC Separator which is a LeafComponent?
Is it just the extra size requirement?

Copy link
Member Author

@yihuiliao yihuiliao May 7, 2025

Choose a reason for hiding this comment

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

No, it was that it wasn't measuring the height correctly. We had some additional padding on the divider that wasn't being accounted for when measuring the layout. By copying over the S2 Divider, wrapping it in a div, we could account for the height correctly. The reason I had to copy it over was because if I just wrapped the S2 Divider in a div, it would throw an error. I had to do it inside the createLeafComponent.

I tried updating the ListLayout to have a separatorHeight but the reason that didn't work out was because even if we had display: 'none' set on the last separator, the collection would still account for the separatorHeight so there was additional empty space on the bottom that shouldn't have been there. In that case, we would need it to be an estimatedSeparatorHeight and involve slightly different logic similar to buildItem but I didn't test that out.

Overall, we decided that it was probably not worth our time spending too much time on just a separator so this is what we came up with.

Copy link
Member

Choose a reason for hiding this comment

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

what was the reason for the extra div? where was the extra padding coming from?

Copy link
Member

Choose a reason for hiding this comment

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

actually I think ListBox doesn't even allow separators. aXe currently fails on S2 prod storybook. In v3 we overrode the role to presentation, but RAC doesn't support that. Any reason it needs to be a separate element at all? Can it just be a border-bottom on the section?

Copy link
Member Author

@yihuiliao yihuiliao May 8, 2025

Choose a reason for hiding this comment

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

Or actually it was margins, not padding. It was coming from here. The extra div was so I could set an explicit height.

We could potentially use border-bottom instead, but in order to align the separator with the margins we have on the menu items + heading on the left + right, we'd probably need something like border-image. For example, if we added a border (this just added it to the top, very basic, no styling, for demonstration purposes), understandably, the length of it is the width of the section. However, the separator has a length that is less than the width of the section. Right now, I'm able to account for that by adding a margin with edgeToText(). I suppose we could just calculate it with a percentage tho?

Screenshot 2025-05-07 at 4 58 35 PM

Copy link
Member

Choose a reason for hiding this comment

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

Since it doesn't need to be an actual divider, can we make a stripped down version like this?

const dividerStyle = style({
  backgroundColor: {
    default: 'gray-200',
    forcedColors: 'ButtonBorder'
  },
  borderRadius: 'full',
  height: '[2px]',
  width: 'full'
});

export const Divider = /*#__PURE__*/ createLeafComponent('separator', function Divider({size}: {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef<HTMLDivElement>) {
  return (
    <div className={separatorWrapper({size})}>
      <div ref={ref} className={dividerStyle} />
    </div>
  );
});

needs alignItems: 'center' in separatorWrapper too

@rspbot
Copy link

rspbot commented Apr 18, 2025

@adobe adobe deleted a comment from rspbot Apr 21, 2025
@yihuiliao yihuiliao changed the title wip: S2 combobox picker virtualizer feat: add virtualization to S2 combobox and picker Apr 22, 2025
@yihuiliao yihuiliao marked this pull request as ready for review April 22, 2025 00:37
@rspbot
Copy link

rspbot commented Apr 22, 2025

@adobe adobe deleted a comment from rspbot Apr 23, 2025
@rspbot
Copy link

rspbot commented Apr 29, 2025

@rspbot
Copy link

rspbot commented May 1, 2025

@rspbot
Copy link

rspbot commented May 5, 2025

@rspbot
Copy link

rspbot commented May 6, 2025

setPressed(false);
}, {once: true, capture: true});
};
let layout = new ListLayout({estimatedRowHeight: 32, estimatedHeadingHeight: 50, padding: 8});
Copy link
Member

Choose a reason for hiding this comment

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

Interesting that this had to be pulled out of the Virtualizer layout and layoutOptions since now this means we generate a new ListLayout every render. I assume this was to fix the scroll into view behavior?

Copy link
Member Author

@yihuiliao yihuiliao May 6, 2025

Choose a reason for hiding this comment

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

Yeah, this was to fix the scroll into view. Without the change, the view would only scroll when we were closing the popover which isn't very useful.

It seemed like what was happening prior to this change was that when we initially open the picker without any selected item, we call layoutIfNeeded as we scroll to select an item which sets this.requestedRect. We want to preserve that value even when we close the popover but was happening was that we were basically resetting this value every time we closed the popover so this.requestedRect didn't have a size (everything was just zero). Then when we called this.buildCollection, at this line we would exit the loop early so we only had one node when we called getVisibleLayoutInfos

Might have some gaps in my understanding but that's what I found if that's helpful at all

Copy link
Member

Choose a reason for hiding this comment

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

Can we memo the ListLayout? looks like it doesn't have any dependencies

Copy link
Member

Choose a reason for hiding this comment

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

hmm this also goes against our common pattern for Virtualizer now
Could we instead move the Virtualizer up to wrap the PopoverBase?

@rspbot
Copy link

rspbot commented May 6, 2025

@@ -143,6 +143,7 @@ describe('SearchAutocomplete', function () {
user = userEvent.setup({delay: null, pointerMap});
jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 1000);
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000);
jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 50);
Copy link
Member

Choose a reason for hiding this comment

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

is this going to be problematic for users of v3 in their own tests?
will we need to call out in release?

Copy link
Member Author

@yihuiliao yihuiliao May 7, 2025

Choose a reason for hiding this comment

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

Probably something we'd like to call out but we also we have this section in are docs where we do mock the scrollHeight for virtualized components

width: 'full',
boxSizing: 'border-box',
maxHeight: '[inherit]',
// TODO: Might help with horizontal scrolling happening on Windows, will need to check somehow. Otherwise, revert back to overflow: auto
Copy link
Member

Choose a reason for hiding this comment

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

what is the todo here? was something else being tried?

Copy link
Member Author

Choose a reason for hiding this comment

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

It was something that I thought might fix the issue with the horizontal scrollbar but I'm unable to see if it actually works since I can't reproduce the original issue

}
},
position: 'relative',
// each menu item should take up the entire width, the subgrid will handle within the item
Copy link
Member

Choose a reason for hiding this comment

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

no longer relevant comment?

Comment on lines +190 to +191
'. checkmark icon label .',
'. . . description .'
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
'. checkmark icon label .',
'. . . description .'
'. checkmark icon label .',
'. . . description .'

gridTemplateRows: {
// min-content prevents second row from 'auto'ing to a size larger then 0 when empty
default: 'auto minmax(0, min-content)',
':has([slot=description])': 'auto auto'
Copy link
Member

Choose a reason for hiding this comment

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

pull out into constant? seems to be the common pattern we're adopting

);
}

const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef<HTMLElement>) {
Copy link
Member

Choose a reason for hiding this comment

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

What is the reason we can't use the S2 Divider which already renders the RAC Separator which is a LeafComponent?
Is it just the extra size requirement?

<Separator
{...props}
className={mergeStyles(
divider({
Copy link
Member

Choose a reason for hiding this comment

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

divider only supports 'S' | 'M' | 'L', if XL is sent in, it'll treat it like M

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it should be fine? Since we are passing the size as 'M', it should always use the 'M' styles regardless of ComboBox or Picker

setPressed(false);
}, {once: true, capture: true});
};
let layout = new ListLayout({estimatedRowHeight: 32, estimatedHeadingHeight: 50, padding: 8});
Copy link
Member

Choose a reason for hiding this comment

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

Can we memo the ListLayout? looks like it doesn't have any dependencies

setPressed(false);
}, {once: true, capture: true});
};
let layout = new ListLayout({estimatedRowHeight: 32, estimatedHeadingHeight: 50, padding: 8});
Copy link
Member

Choose a reason for hiding this comment

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

hmm this also goes against our common pattern for Virtualizer now
Could we instead move the Virtualizer up to wrap the PopoverBase?

@@ -194,6 +194,9 @@ export class S2TableLayout<T> extends TableLayout<T> {

protected buildCollection(): LayoutNode[] {
let [header, body] = super.buildCollection();
if (!header) {
Copy link
Member

Choose a reason for hiding this comment

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

@LFDanLu can you explain when this happens?

Copy link
Member

Choose a reason for hiding this comment

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

If I remember correctly, now that we are rendering the contents of the virtualized content even when we haven't fully processed the collection (aka the changes in Virtualizer and rect size check changes) there is a moment where we haven't processed the collection and thus can't perform build the header/body yet

@@ -148,6 +149,114 @@ const iconStyles = style({
}
});

export let listbox = style<{size: 'S' | 'M' | 'L' | 'XL'}>({
Copy link
Member

Choose a reason for hiding this comment

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

What are the differences between the styles here and the ones in menu? A bit unfortunate to duplicate them

Copy link
Member Author

Choose a reason for hiding this comment

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

Mainly that menu has some additional styles that Combobox and Picker didn't need but it wasn't actually affecting anything. I could probably revert the change and just use menu but I'd need to double check

);
}

const Separator = /*#__PURE__*/ createLeafComponent('separator', function Separator(props: SeparatorProps & {size?: 'S' | 'M' | 'L' | 'XL'}, ref: ForwardedRef<HTMLElement>) {
Copy link
Member

Choose a reason for hiding this comment

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

what was the reason for the extra div? where was the extra padding coming from?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants