-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat(compactSelect): add section virtualization support #108394
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
Changes from all commits
7f549be
7f7832e
0d562ee
16d97c4
647a8ec
0ebe225
9f170dd
9fc631e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,8 @@ import { | |
| type SelectKey, | ||
| type SelectSection, | ||
| } from '@sentry/scraps/compactSelect'; | ||
| import {useVirtualizedItems} from '@sentry/scraps/compactSelect/useVirtualizedItems'; | ||
| import {Container} from '@sentry/scraps/layout'; | ||
|
|
||
| import {t} from 'sentry/locale'; | ||
|
|
||
|
|
@@ -58,11 +60,19 @@ interface GridListProps | |
| section: SelectSection<SelectKey>, | ||
| type: 'select' | 'unselect' | ||
| ) => void; | ||
| /** | ||
| * When false, hides section headers in the grid list. | ||
| */ | ||
| showSectionHeaders?: boolean; | ||
| size?: GridListOptionProps['size']; | ||
| /** | ||
| * Message to be displayed when some options are hidden due to `sizeLimit`. | ||
| */ | ||
| sizeLimitMessage?: string; | ||
| /** | ||
| * If true, virtualization will be enabled for the list. | ||
| */ | ||
| virtualized?: boolean; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -82,6 +92,8 @@ function GridList({ | |
| onSectionToggle, | ||
| sizeLimitMessage, | ||
| keyDownHandler, | ||
| virtualized, | ||
| showSectionHeaders = true, | ||
| ...props | ||
| }: GridListProps) { | ||
| const ref = useRef<HTMLUListElement>(null); | ||
|
|
@@ -114,42 +126,75 @@ function GridList({ | |
| [listState.collection, hiddenOptions] | ||
| ); | ||
|
|
||
| const virtualizer = useVirtualizedItems({ | ||
| listItems, | ||
| virtualized, | ||
| size, | ||
| hiddenOptions, | ||
| showSectionHeaders, | ||
| }); | ||
|
|
||
| const listContent = virtualizer.items.map(row => { | ||
| const item = listItems[row.index]!; | ||
| if (item.type === 'section') { | ||
| return ( | ||
| <GridListSection | ||
| {...virtualizer.itemProps(row.index)} | ||
| key={item.key} | ||
| node={item} | ||
| listState={listState} | ||
| onToggle={onSectionToggle} | ||
| size={size} | ||
| isFirst={row.index === 0} | ||
| /> | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| return ( | ||
| <GridListOption | ||
| {...virtualizer.itemProps(row.index)} | ||
| key={item.key} | ||
| node={item} | ||
| listState={listState} | ||
| size={size} | ||
| /> | ||
| ); | ||
| }); | ||
|
|
||
| const sizeLimitContent = !searchable && hiddenOptions.size > 0 && ( | ||
| <SizeLimitMessage> | ||
| {sizeLimitMessage ?? t('Use search to find more options…')} | ||
| </SizeLimitMessage> | ||
| ); | ||
|
|
||
| return ( | ||
| <Fragment> | ||
| {listItems.length !== 0 && <ListSeparator role="separator" />} | ||
| {listItems.length !== 0 && label && <ListLabel id={labelId}>{label}</ListLabel>} | ||
| {overlayIsOpen && ( | ||
| <ListWrap {...mergeProps(gridProps, props)} onKeyDown={onKeyDown} ref={ref}> | ||
| {listItems.map(item => { | ||
| if (item.type === 'section') { | ||
| return ( | ||
| <GridListSection | ||
| key={item.key} | ||
| node={item} | ||
| listState={listState} | ||
| onToggle={onSectionToggle} | ||
| size={size} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <GridListOption | ||
| key={item.key} | ||
| node={item} | ||
| listState={listState} | ||
| size={size} | ||
| /> | ||
| ); | ||
| })} | ||
|
|
||
| {!searchable && hiddenOptions.size > 0 && ( | ||
| <SizeLimitMessage> | ||
| {sizeLimitMessage ?? t('Use search to find more options…')} | ||
| </SizeLimitMessage> | ||
| )} | ||
| </ListWrap> | ||
| )} | ||
| {overlayIsOpen && | ||
| (virtualized ? ( | ||
| <Container ref={virtualizer.scrollElementRef} height="100%" overflowY="auto"> | ||
| <Container {...virtualizer.wrapperProps}> | ||
| <ListWrap | ||
| {...mergeProps(gridProps, props)} | ||
| style={{ | ||
| ...gridProps.style, | ||
| ...virtualizer.listWrapStyle, | ||
| }} | ||
| onKeyDown={onKeyDown} | ||
| ref={ref} | ||
| > | ||
| {listContent} | ||
| {sizeLimitContent} | ||
| </ListWrap> | ||
| </Container> | ||
| </Container> | ||
| ) : ( | ||
| <ListWrap {...mergeProps(gridProps, props)} onKeyDown={onKeyDown} ref={ref}> | ||
| {listContent} | ||
| {sizeLimitContent} | ||
| </ListWrap> | ||
| ))} | ||
| </Fragment> | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,12 @@ | ||
| import {Fragment, useContext, useId, useMemo} from 'react'; | ||
| import {useContext, useId, useMemo} from 'react'; | ||
| import styled from '@emotion/styled'; | ||
| import {useSeparator} from '@react-aria/separator'; | ||
| import type {ListState} from '@react-stately/list'; | ||
| import type {Node} from '@react-types/shared'; | ||
|
|
||
| import { | ||
| SectionGroup, | ||
| SectionHeader, | ||
| SectionSeparator, | ||
| SectionTitle, | ||
| SectionToggle, | ||
| SectionWrap, | ||
|
|
@@ -20,16 +20,27 @@ interface GridListSectionProps { | |
| listState: ListState<any>; | ||
| node: Node<any>; | ||
| size: GridListOptionProps['size']; | ||
| 'data-index'?: number; | ||
| isFirst?: boolean; | ||
| onToggle?: (section: SelectSection<SelectKey>, type: 'select' | 'unselect') => void; | ||
| ref?: React.Ref<HTMLLIElement>; | ||
| } | ||
|
|
||
| /** | ||
| * A <li /> element that functions as a grid list section (renders a nested <ul /> | ||
| * inside). https://react-spectrum.adobe.com/react-aria/useGridList.html | ||
| */ | ||
| export function GridListSection({node, listState, onToggle, size}: GridListSectionProps) { | ||
| export function GridListSection({ | ||
| node, | ||
| listState, | ||
| onToggle, | ||
| size, | ||
| ref, | ||
| 'data-index': dataIndex, | ||
| isFirst = false, | ||
| }: GridListSectionProps) { | ||
| const titleId = useId(); | ||
| const {separatorProps} = useSeparator({elementType: 'li'}); | ||
| const {separatorProps} = useSeparator({elementType: 'div'}); | ||
|
|
||
| const showToggleAllButton = | ||
| listState.selectionManager.selectionMode === 'multiple' && | ||
|
|
@@ -42,37 +53,42 @@ export function GridListSection({node, listState, onToggle, size}: GridListSecti | |
| ); | ||
|
|
||
| return ( | ||
| <Fragment> | ||
| <SectionSeparator {...separatorProps} /> | ||
| <SectionWrap | ||
| role="rowgroup" | ||
| {...(node['aria-label'] | ||
| ? {'aria-label': node['aria-label']} | ||
| : {'aria-labelledby': titleId})} | ||
| > | ||
| {(node.rendered || showToggleAllButton) && ( | ||
| <SectionHeader> | ||
| {node.rendered && ( | ||
| <SectionTitle id={titleId} aria-hidden> | ||
| {node.rendered} | ||
| </SectionTitle> | ||
| )} | ||
| {showToggleAllButton && ( | ||
| <SectionToggle item={node} listState={listState} onToggle={onToggle} /> | ||
| )} | ||
| </SectionHeader> | ||
| )} | ||
| <SectionGroup role="presentation"> | ||
| {childNodes.map(child => ( | ||
| <GridListOption | ||
| key={child.key} | ||
| node={child} | ||
| listState={listState} | ||
| size={size} | ||
| /> | ||
| ))} | ||
| </SectionGroup> | ||
| </SectionWrap> | ||
| </Fragment> | ||
| <SectionWrap | ||
| role="rowgroup" | ||
| data-index={dataIndex} | ||
| ref={ref} | ||
| {...(node['aria-label'] | ||
| ? {'aria-label': node['aria-label']} | ||
| : {'aria-labelledby': titleId})} | ||
| > | ||
| {!isFirst && <SectionSeparatorInner {...separatorProps} />} | ||
| {(node.rendered || showToggleAllButton) && ( | ||
| <SectionHeader> | ||
| {node.rendered && ( | ||
| <SectionTitle id={titleId} aria-hidden> | ||
| {node.rendered} | ||
| </SectionTitle> | ||
| )} | ||
| {showToggleAllButton && ( | ||
| <SectionToggle item={node} listState={listState} onToggle={onToggle} /> | ||
| )} | ||
| </SectionHeader> | ||
| )} | ||
| <SectionGroup role="presentation"> | ||
| {childNodes.map(child => ( | ||
| <GridListOption | ||
| key={child.key} | ||
| node={child} | ||
| listState={listState} | ||
| size={size} | ||
| /> | ||
| ))} | ||
| </SectionGroup> | ||
| </SectionWrap> | ||
| ); | ||
| } | ||
|
|
||
| const SectionSeparatorInner = styled('div')` | ||
| border-top: solid 1px ${p => p.theme.tokens.border.secondary}; | ||
| margin: ${p => p.theme.space.xs} ${p => p.theme.space.lg}; | ||
| `; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Width measurement may pick wrong option across sections
Medium Severity
The
maxBycall compares section header label lengths against option label/textValue lengths in a single pass. If a section header has the longest text, it "wins," and then only the longest child within that specific section is picked for width measurement. Options in other sections or at the top level that are actually wider get ignored, potentially resulting in a menu that's too narrow to display them.