Skip to content

Commit

Permalink
wip refactor(SelectPicker): move grouped options into group element
Browse files Browse the repository at this point in the history
  • Loading branch information
SevenOutman committed Jul 27, 2023
1 parent 78ce8a7 commit b853a71
Show file tree
Hide file tree
Showing 7 changed files with 588 additions and 38 deletions.
375 changes: 375 additions & 0 deletions src/SelectPicker/Listbox.tsx
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));

Check warning on line 292 in src/SelectPicker/Listbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
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;
Loading

0 comments on commit b853a71

Please sign in to comment.