-
-
Notifications
You must be signed in to change notification settings - Fork 928
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
wip refactor(SelectPicker): move grouped options into group element
- Loading branch information
1 parent
78ce8a7
commit b853a71
Showing
7 changed files
with
588 additions
and
38 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,375 @@ | ||
import React, { useRef, useState, useEffect, useCallback } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import isUndefined from 'lodash/isUndefined'; | ||
import isString from 'lodash/isString'; | ||
import isNumber from 'lodash/isNumber'; | ||
import findIndex from 'lodash/findIndex'; | ||
import getPosition from 'dom-lib/getPosition'; | ||
import scrollTop from 'dom-lib/scrollTop'; | ||
import getHeight from 'dom-lib/getHeight'; | ||
import classNames from 'classnames'; | ||
import { | ||
List, | ||
AutoSizer, | ||
ListProps, | ||
ListHandle, | ||
VariableSizeList, | ||
ListChildComponentProps | ||
} from '../Windowing'; | ||
import shallowEqual from '../utils/shallowEqual'; | ||
import { mergeRefs, useClassNames, useMount } from '../utils'; | ||
import ListboxOptionGroup from './ListboxOptionGroup'; | ||
import { | ||
CompareFn, | ||
Group, | ||
KEY_GROUP, | ||
KEY_GROUP_TITLE, | ||
groupOptions | ||
} from '../utils/getDataGroupBy'; | ||
import { StandardProps, Offset } from '../@types/common'; | ||
import _ from 'lodash'; | ||
import ListboxOption from './ListboxOption'; | ||
|
||
export interface ListboxProps<T, K> | ||
extends StandardProps, | ||
Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> { | ||
classPrefix: string; | ||
options: readonly T[]; | ||
sort?: <B extends boolean>(isGroup: B) => B extends true ? CompareFn<Group<T>> : CompareFn<T>; | ||
groupBy?: string; | ||
disabledItemValues?: any[]; | ||
activeItemValues?: any[]; | ||
focusItemValue?: any; | ||
maxHeight?: number; | ||
valueKey?: string; | ||
labelKey?: string; | ||
className?: string; | ||
style?: React.CSSProperties; | ||
dropdownMenuItemAs: React.ElementType | string; | ||
dropdownMenuItemClassPrefix?: string; | ||
rowHeight?: number; | ||
rowGroupHeight?: number; | ||
virtualized?: boolean; | ||
listProps?: Partial<ListProps>; | ||
listRef?: React.Ref<ListHandle>; | ||
|
||
/** Custom selected option */ | ||
renderMenuItem?: (itemLabel: React.ReactNode, item: any) => React.ReactNode; | ||
renderMenuGroup?: (title: React.ReactNode, item: any) => React.ReactNode; | ||
onSelect?: (value: K, item: T, event: React.MouseEvent) => void; | ||
onGroupTitleClick?: (event: React.MouseEvent) => void; | ||
} | ||
|
||
export type ListboxComponent = <T, K>( | ||
p: ListboxProps<T, K> & { ref?: React.ForwardedRef<HTMLDivElement> } | ||
) => JSX.Element; | ||
|
||
const Listbox = React.forwardRef(function Listbox<T, K = React.Key>( | ||
props: ListboxProps<T, K>, | ||
ref: React.ForwardedRef<HTMLDivElement> | ||
) { | ||
const { | ||
options = [], | ||
groupBy, | ||
sort, | ||
maxHeight = 320, | ||
activeItemValues = [], | ||
disabledItemValues = [], | ||
classPrefix = 'dropdown-menu', | ||
valueKey = 'value', | ||
labelKey = 'label', | ||
virtualized, | ||
listProps, | ||
listRef: virtualizedListRef, | ||
className, | ||
style, | ||
focusItemValue, | ||
dropdownMenuItemClassPrefix, | ||
dropdownMenuItemAs: DropdownMenuItem, | ||
rowHeight = 36, | ||
rowGroupHeight = 48, | ||
renderMenuGroup, | ||
renderMenuItem, | ||
onGroupTitleClick, | ||
onSelect, | ||
...rest | ||
} = props; | ||
|
||
const { withClassPrefix, prefix, merge } = useClassNames(classPrefix); | ||
const classes = merge(className, withClassPrefix('items', { grouped: group })); | ||
|
||
const menuBodyContainerRef = useRef<HTMLDivElement>(null); | ||
const listRef = useRef<ListHandle>(null); | ||
|
||
const [foldedGroupKeys, setFoldedGroupKeys] = useState<string[]>([]); | ||
|
||
const handleGroupTitleClick = useCallback( | ||
(key: string, event: React.MouseEvent) => { | ||
const nextGroupKeys = foldedGroupKeys.filter(item => item !== key); | ||
if (nextGroupKeys.length === foldedGroupKeys.length) { | ||
nextGroupKeys.push(key); | ||
} | ||
setFoldedGroupKeys(nextGroupKeys); | ||
onGroupTitleClick?.(event); | ||
}, | ||
[onGroupTitleClick, foldedGroupKeys] | ||
); | ||
|
||
const handleSelect = useCallback( | ||
(item: any, value: any, event: React.MouseEvent, checked?: boolean) => { | ||
onSelect?.(value, item, event, checked); | ||
}, | ||
[onSelect] | ||
); | ||
|
||
const getRowHeight = (list: any[], index) => { | ||
const item = list[index]; | ||
|
||
if (group && item[KEY_GROUP] && index !== 0) { | ||
return rowGroupHeight; | ||
} | ||
|
||
return rowHeight; | ||
}; | ||
|
||
useEffect(() => { | ||
const container = menuBodyContainerRef.current; | ||
|
||
if (!container) { | ||
return; | ||
} | ||
|
||
let activeItem = container.querySelector(`.${prefix('item-focus')}`); | ||
|
||
if (!activeItem) { | ||
activeItem = container.querySelector(`.${prefix('item-active')}`); | ||
} | ||
|
||
if (!activeItem) { | ||
return; | ||
} | ||
|
||
const position = getPosition(activeItem, container) as Offset; | ||
const sTop = scrollTop(container); | ||
const sHeight = getHeight(container); | ||
if (sTop > position.top) { | ||
scrollTop(container, Math.max(0, position.top - 20)); | ||
} else if (position.top > sTop + sHeight) { | ||
scrollTop(container, Math.max(0, position.top - sHeight + 32)); | ||
} | ||
}, [focusItemValue, menuBodyContainerRef, prefix]); | ||
|
||
const group = typeof groupBy !== 'undefined'; | ||
|
||
const renderItem = ({ | ||
index = 0, | ||
style, | ||
data, | ||
item: itemData | ||
}: Partial<ListChildComponentProps> & { item?: any }) => { | ||
const item = itemData || data[index]; | ||
const value = item[valueKey]; | ||
const label = item[labelKey]; | ||
|
||
if (isUndefined(label) && !item[KEY_GROUP]) { | ||
throw Error(`labelKey "${labelKey}" is not defined in "data" : ${index}`); | ||
} | ||
|
||
// Use `value` in keys when If `value` is string or number | ||
const itemKey = isString(value) || isNumber(value) ? value : index; | ||
|
||
/** | ||
* Render <DropdownMenuGroup> | ||
* when if `group` is enabled | ||
*/ | ||
if (group && item[KEY_GROUP]) { | ||
const groupValue = item[KEY_GROUP_TITLE]; | ||
// TODO: grouped options should be owned by group | ||
return ( | ||
<ListboxOptionGroup | ||
style={style} | ||
classPrefix={'picker-menu-group'} | ||
className={classNames({ | ||
folded: foldedGroupKeys.some(key => key === groupValue) | ||
})} | ||
key={`group-${groupValue}`} | ||
onClick={handleGroupTitleClick.bind(null, groupValue)} | ||
> | ||
{renderMenuGroup ? renderMenuGroup(groupValue, item) : groupValue} | ||
</ListboxOptionGroup> | ||
); | ||
} else if (isUndefined(value) && !isUndefined(item[KEY_GROUP])) { | ||
throw Error(`valueKey "${valueKey}" is not defined in "data" : ${index} `); | ||
} | ||
|
||
const disabled = disabledItemValues?.some(disabledValue => shallowEqual(disabledValue, value)); | ||
const active = activeItemValues?.some(v => shallowEqual(v, value)); | ||
const focus = !isUndefined(focusItemValue) && shallowEqual(focusItemValue, value); | ||
|
||
return ( | ||
<DropdownMenuItem | ||
style={style} | ||
key={itemKey} | ||
disabled={disabled} | ||
active={active} | ||
focus={focus} | ||
value={value} | ||
classPrefix={dropdownMenuItemClassPrefix} | ||
onSelect={handleSelect.bind(null, item)} | ||
> | ||
{renderMenuItem ? renderMenuItem(label, item) : label} | ||
</DropdownMenuItem> | ||
); | ||
}; | ||
|
||
const filteredItems = group | ||
? options.filter(item => { | ||
// Display group title items | ||
if (item[KEY_GROUP as keyof typeof item]) return true; | ||
|
||
// Display items under the unfolded group | ||
// FIXME-Doma | ||
// `groupBy` is bound to be string when `group` is true | ||
// because `group` is actually redundant as a prop | ||
// It could simply be derived from `groupBy` value | ||
const groupValue = | ||
_.get(item, groupBy as string, '') || | ||
// FIXME-Doma | ||
// Usage of `item.parent` is strongly discouraged | ||
// It's only here for legacy support | ||
// Remove once `item.parent` is completely removed across related components | ||
item.parent?.[KEY_GROUP_TITLE]; | ||
return !foldedGroupKeys.includes(groupValue); | ||
}) | ||
: options; | ||
const rowCount = filteredItems.length; | ||
|
||
useMount(() => { | ||
const itemIndex = findIndex(filteredItems, item => item[valueKey] === activeItemValues?.[0]); | ||
listRef.current?.scrollToItem?.(itemIndex); | ||
}); | ||
|
||
const renderOptions = useCallback( | ||
(options: readonly T[]) => { | ||
return options.map(option => { | ||
const optionKey = option[valueKey]; | ||
const label = option[labelKey]; | ||
|
||
const disabled = disabledItemValues?.some(disabledValue => | ||
shallowEqual(disabledValue, optionKey) | ||
); | ||
const active = activeItemValues?.some(v => shallowEqual(v, optionKey)); | ||
const focus = !isUndefined(focusItemValue) && shallowEqual(focusItemValue, optionKey); | ||
|
||
return ( | ||
<ListboxOption | ||
key={optionKey} | ||
disabled={disabled} | ||
active={active} | ||
focus={focus} | ||
value={optionKey} | ||
classPrefix={dropdownMenuItemClassPrefix} | ||
onSelect={handleSelect.bind(null, option)} | ||
> | ||
{renderMenuItem ? renderMenuItem(label, option) : label} | ||
</ListboxOption> | ||
); | ||
}); | ||
}, | ||
[ | ||
activeItemValues, | ||
disabledItemValues, | ||
dropdownMenuItemClassPrefix, | ||
focusItemValue, | ||
handleSelect, | ||
labelKey, | ||
renderMenuItem, | ||
valueKey | ||
] | ||
); | ||
|
||
const renderOptionGroups = useCallback(() => { | ||
const groups = groupOptions(options, groupBy!, sort?.(false), sort?.(true)); | ||
return groups.map(group => { | ||
const groupKey = group.key; | ||
const expanded = !foldedGroupKeys.includes(groupKey); | ||
|
||
return ( | ||
<ListboxOptionGroup | ||
key={groupKey} | ||
title={groupKey} | ||
classPrefix={'picker-menu-group'} | ||
expanded={expanded} | ||
className={classNames({ | ||
folded: !expanded | ||
})} | ||
onClick={e => handleGroupTitleClick(group.key, e)} | ||
> | ||
{renderOptions(group.options)} | ||
{/* {renderMenuGroup ? renderMenuGroup(groupValue, item) : groupValue} */} | ||
</ListboxOptionGroup> | ||
); | ||
}); | ||
}, [foldedGroupKeys, groupBy, handleGroupTitleClick, options, renderOptions, sort]); | ||
|
||
return ( | ||
<div | ||
role="listbox" | ||
{...rest} | ||
className={classes} | ||
ref={mergeRefs(menuBodyContainerRef, ref)} | ||
style={{ ...style, maxHeight }} | ||
> | ||
{virtualized ? ( | ||
<AutoSizer defaultHeight={maxHeight} style={{ width: 'auto', height: 'auto' }}> | ||
{({ height }) => ( | ||
<List | ||
as={VariableSizeList} | ||
ref={mergeRefs(listRef, virtualizedListRef)} | ||
height={height || maxHeight} | ||
itemCount={rowCount} | ||
itemData={filteredItems} | ||
itemSize={getRowHeight.bind(this, filteredItems)} | ||
{...listProps} | ||
> | ||
{renderItem} | ||
</List> | ||
)} | ||
</AutoSizer> | ||
) : typeof groupBy === 'undefined' ? ( | ||
renderOptions(options) | ||
) : ( | ||
renderOptionGroups() | ||
)} | ||
</div> | ||
); | ||
}) as ListboxComponent; | ||
|
||
export const dropdownMenuPropTypes = { | ||
classPrefix: PropTypes.string.isRequired, | ||
className: PropTypes.string, | ||
dropdownMenuItemAs: PropTypes.elementType.isRequired, | ||
dropdownMenuItemClassPrefix: PropTypes.string, | ||
data: PropTypes.array, | ||
group: PropTypes.bool, | ||
disabledItemValues: PropTypes.array, | ||
activeItemValues: PropTypes.array, | ||
focusItemValue: PropTypes.any, | ||
maxHeight: PropTypes.number, | ||
valueKey: PropTypes.string, | ||
labelKey: PropTypes.string, | ||
style: PropTypes.object, | ||
renderMenuItem: PropTypes.func, | ||
renderMenuGroup: PropTypes.func, | ||
onSelect: PropTypes.func, | ||
onGroupTitleClick: PropTypes.func, | ||
virtualized: PropTypes.bool, | ||
listProps: PropTypes.any, | ||
rowHeight: PropTypes.number, | ||
rowGroupHeight: PropTypes.number | ||
}; | ||
|
||
Listbox.displayName = 'DropdownMenu'; | ||
Listbox.propTypes = dropdownMenuPropTypes; | ||
|
||
export default Listbox; |
Oops, something went wrong.