feat: Adds more customization properties to DropdownContainer (#22031)

This commit is contained in:
Michael S. Molina 2022-11-04 10:20:49 -04:00 committed by GitHub
parent 9b6322b640
commit b040211970
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 223 additions and 180 deletions

View File

@ -20,7 +20,8 @@ import React, { useEffect, useState } from 'react';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { css } from '@superset-ui/core'; import { css } from '@superset-ui/core';
import Select from '../Select/Select'; import Select from '../Select/Select';
import DropdownContainer, { DropdownContainerProps } from '.'; import Button from '../Button';
import DropdownContainer, { DropdownContainerProps, Ref } from '.';
export default { export default {
title: 'DropdownContainer', title: 'DropdownContainer',
@ -31,6 +32,7 @@ const ITEMS_COUNT = 6;
const ITEM_OPTIONS = 10; const ITEM_OPTIONS = 10;
const MIN_WIDTH = 700; const MIN_WIDTH = 700;
const MAX_WIDTH = 1500; const MAX_WIDTH = 1500;
const HEIGHT = 400;
const itemsOptions = Array.from({ length: ITEM_OPTIONS }).map((_, i) => ({ const itemsOptions = Array.from({ length: ITEM_OPTIONS }).map((_, i) => ({
label: `Option ${i}`, label: `Option ${i}`,
@ -60,39 +62,42 @@ const generateItems = (overflowingState?: OverflowingState) =>
export const Component = (props: DropdownContainerProps) => { export const Component = (props: DropdownContainerProps) => {
const [items, setItems] = useState<ItemsType>([]); const [items, setItems] = useState<ItemsType>([]);
const [overflowingState, setOverflowingState] = useState<OverflowingState>(); const [overflowingState, setOverflowingState] = useState<OverflowingState>();
const containerRef = React.useRef<Ref>(null);
useEffect(() => { useEffect(() => {
setItems(generateItems(overflowingState)); setItems(generateItems(overflowingState));
}, [overflowingState]); }, [overflowingState]);
return ( return (
<div <div>
css={css` <div
position: relative; css={css`
overflow: auto; overflow: auto;
min-width: ${MIN_WIDTH}px; min-width: ${MIN_WIDTH}px;
width: ${MIN_WIDTH}px; width: ${MIN_WIDTH}px;
max-width: ${MAX_WIDTH}px; max-width: ${MAX_WIDTH}px;
height: 80vh; height: ${HEIGHT}px;
border: 1px solid lightgray; border: 1px solid lightgray;
resize: horizontal; resize: horizontal;
padding: 24px; padding: 24px;
`} margin-bottom: 12px;
> `}
<DropdownContainer >
{...props} <DropdownContainer
items={items} {...props}
onOverflowingStateChange={value => { items={items}
if (!isEqual(overflowingState, value)) { onOverflowingStateChange={value => {
setOverflowingState(value); if (!isEqual(overflowingState, value)) {
} setOverflowingState(value);
}} }
/> }}
ref={containerRef}
/>
</div>
<Button onClick={() => containerRef.current?.open()}>Open</Button>
<span <span
css={css` css={css`
position: absolute; margin-left: ${MIN_WIDTH - 340}px;
right: 20px;
bottom: 8px;
color: gray; color: gray;
`} `}
> >

View File

@ -18,8 +18,11 @@
*/ */
import React, { import React, {
CSSProperties, CSSProperties,
forwardRef,
ReactElement, ReactElement,
RefObject,
useEffect, useEffect,
useImperativeHandle,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useState, useState,
@ -68,10 +71,18 @@ export interface DropdownContainerProps {
* Option to customize the content of the popover. * Option to customize the content of the popover.
*/ */
popoverContent?: (overflowedItems: Item[]) => ReactElement; popoverContent?: (overflowedItems: Item[]) => ReactElement;
/**
* Popover ref.
*/
popoverRef?: RefObject<HTMLDivElement>;
/** /**
* Popover additional style properties. * Popover additional style properties.
*/ */
popoverStyle?: CSSProperties; popoverStyle?: CSSProperties;
/**
* Displayed count in the popover trigger.
*/
popoverTriggerCount?: number;
/** /**
* Icon of the popover trigger. * Icon of the popover trigger.
*/ */
@ -86,171 +97,198 @@ export interface DropdownContainerProps {
style?: CSSProperties; style?: CSSProperties;
} }
const DropdownContainer = ({ export type Ref = HTMLDivElement & { open: () => void };
items,
onOverflowingStateChange,
popoverContent,
popoverStyle = {},
popoverTriggerIcon,
popoverTriggerText = t('More'),
style,
}: DropdownContainerProps) => {
const theme = useTheme();
const { ref, width = 0 } = useResizeDetector<HTMLDivElement>();
const previousWidth = usePrevious(width) || 0;
const { current } = ref;
const [overflowingIndex, setOverflowingIndex] = useState<number>(-1);
const [itemsWidth, setItemsWidth] = useState<number[]>([]);
useLayoutEffect(() => { const DropdownContainer = forwardRef(
const container = current?.children.item(0); (
if (container) { {
const { children } = container; items,
const childrenArray = Array.from(children); onOverflowingStateChange,
popoverContent,
popoverRef,
popoverStyle = {},
popoverTriggerCount,
popoverTriggerIcon,
popoverTriggerText = t('More'),
style,
}: DropdownContainerProps,
outerRef: RefObject<Ref>,
) => {
const theme = useTheme();
const { ref, width = 0 } = useResizeDetector<HTMLDivElement>();
const previousWidth = usePrevious(width) || 0;
const { current } = ref;
const [overflowingIndex, setOverflowingIndex] = useState<number>(-1);
const [itemsWidth, setItemsWidth] = useState<number[]>([]);
const [popoverVisible, setPopoverVisible] = useState(false);
// Stores items width once useLayoutEffect(() => {
if (itemsWidth.length === 0) { const container = current?.children.item(0);
setItemsWidth( if (container) {
childrenArray.map(child => child.getBoundingClientRect().width), const { children } = container;
const childrenArray = Array.from(children);
// Stores items width once
if (itemsWidth.length === 0) {
setItemsWidth(
childrenArray.map(child => child.getBoundingClientRect().width),
);
}
// Calculates the index of the first overflowed element
const index = childrenArray.findIndex(
child =>
child.getBoundingClientRect().right >
container.getBoundingClientRect().right,
); );
} setOverflowingIndex(index === -1 ? children.length : index);
// Calculates the index of the first overflowed element if (width > previousWidth && overflowingIndex !== -1) {
const index = childrenArray.findIndex( // Calculates remaining space in the container
child => const button = current?.children.item(1);
child.getBoundingClientRect().right > const buttonRight = button?.getBoundingClientRect().right || 0;
container.getBoundingClientRect().right, const containerRight = current?.getBoundingClientRect().right || 0;
); const remainingSpace = containerRight - buttonRight;
setOverflowingIndex(index === -1 ? children.length : index); // Checks if the first element in the popover fits in the remaining space
const fitsInRemainingSpace = remainingSpace >= itemsWidth[0];
if (width > previousWidth && overflowingIndex !== -1) { if (fitsInRemainingSpace && overflowingIndex < items.length) {
// Calculates remaining space in the container // Moves element from popover to container
const button = current?.children.item(1); setOverflowingIndex(overflowingIndex + 1);
const buttonRight = button?.getBoundingClientRect().right || 0; }
const containerRight = current?.getBoundingClientRect().right || 0;
const remainingSpace = containerRight - buttonRight;
// Checks if the first element in the popover fits in the remaining space
const fitsInRemainingSpace = remainingSpace >= itemsWidth[0];
if (fitsInRemainingSpace && overflowingIndex < items.length) {
// Moves element from popover to container
setOverflowingIndex(overflowingIndex + 1);
} }
} }
} }, [
}, [ current,
current, items.length,
items.length, itemsWidth,
itemsWidth, overflowingIndex,
overflowingIndex, previousWidth,
previousWidth, width,
width, ]);
]);
const reduceItems = (items: Item[]): [Item[], string[]] => const reduceItems = (items: Item[]): [Item[], string[]] =>
items.reduce( items.reduce(
([items, ids], item) => { ([items, ids], item) => {
items.push({ items.push({
id: item.id, id: item.id,
element: React.cloneElement(item.element, { key: item.id }), element: React.cloneElement(item.element, { key: item.id }),
}); });
ids.push(item.id); ids.push(item.id);
return [items, ids]; return [items, ids];
}, },
[[], []] as [Item[], string[]], [[], []] as [Item[], string[]],
);
const [notOverflowedItems, notOverflowedIds] = useMemo(
() =>
reduceItems(
items.slice(
0,
overflowingIndex !== -1 ? overflowingIndex : items.length,
),
),
[items, overflowingIndex],
); );
const [notOverflowedItems, notOverflowedIds] = useMemo( const [overflowedItems, overflowedIds] = useMemo(
() => () =>
reduceItems( overflowingIndex !== -1
items.slice( ? reduceItems(items.slice(overflowingIndex, items.length))
0, : [[], []],
overflowingIndex !== -1 ? overflowingIndex : items.length, [items, overflowingIndex],
), );
),
[items, overflowingIndex],
);
const [overflowedItems, overflowedIds] = useMemo( useEffect(() => {
() => if (onOverflowingStateChange) {
overflowingIndex !== -1 onOverflowingStateChange({
? reduceItems(items.slice(overflowingIndex, items.length)) notOverflowed: notOverflowedIds,
: [[], []], overflowed: overflowedIds,
[items, overflowingIndex], });
); }
}, [notOverflowedIds, onOverflowingStateChange, overflowedIds]);
useEffect(() => { const content = useMemo(
if (onOverflowingStateChange) { () => (
onOverflowingStateChange({ <div
notOverflowed: notOverflowedIds, css={css`
overflowed: overflowedIds, display: flex;
}); flex-direction: column;
} gap: ${theme.gridUnit * 3}px;
}, [notOverflowedIds, onOverflowingStateChange, overflowedIds]); `}
style={popoverStyle}
const content = useMemo( ref={popoverRef}
() => (
<div
css={css`
display: flex;
flex-direction: column;
gap: ${theme.gridUnit * 3}px;
width: 200px;
`}
style={popoverStyle}
>
{popoverContent
? popoverContent(overflowedItems)
: overflowedItems.map(item => item.element)}
</div>
),
[overflowedItems, popoverContent, popoverStyle, theme.gridUnit],
);
const overflowingCount =
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
return (
<div
ref={ref}
css={css`
display: flex;
align-items: flex-end;
`}
>
<div
css={css`
display: flex;
align-items: center;
gap: ${theme.gridUnit * 3}px;
margin-right: ${theme.gridUnit * 3}px;
min-width: 100px;
`}
style={style}
>
{notOverflowedItems.map(item => item.element)}
</div>
{overflowingCount > 0 && (
<Popover
content={content}
trigger="click"
overlayInnerStyle={{
overflow: 'auto',
maxHeight: 500,
}}
> >
<Button buttonStyle="secondary"> {popoverContent
{popoverTriggerIcon} ? popoverContent(overflowedItems)
{popoverTriggerText} : overflowedItems.map(item => item.element)}
<Badge count={overflowingCount} /> </div>
<Icons.DownOutlined ),
iconSize="m" [
iconColor={theme.colors.grayscale.base} overflowedItems,
/> popoverContent,
</Button> popoverRef,
</Popover> popoverStyle,
)} theme.gridUnit,
</div> ],
); );
};
useImperativeHandle(
outerRef,
() => ({
...(ref.current as HTMLDivElement),
open: () => setPopoverVisible(true),
}),
[ref],
);
const overflowingCount =
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
return (
<div
ref={ref}
css={css`
display: flex;
align-items: flex-end;
`}
>
<div
css={css`
display: flex;
align-items: center;
gap: ${theme.gridUnit * 3}px;
margin-right: ${theme.gridUnit * 3}px;
min-width: 100px;
`}
style={style}
>
{notOverflowedItems.map(item => item.element)}
</div>
{overflowingCount > 0 && (
<Popover
content={content}
trigger="click"
visible={popoverVisible}
onVisibleChange={visible => setPopoverVisible(visible)}
overlayInnerStyle={{
maxHeight: 500,
overflowY: 'auto',
}}
>
<Button buttonStyle="secondary">
{popoverTriggerIcon}
{popoverTriggerText}
<Badge count={popoverTriggerCount || overflowingCount} />
<Icons.DownOutlined
iconSize="m"
iconColor={theme.colors.grayscale.base}
/>
</Button>
</Popover>
)}
</div>
);
},
);
export default DropdownContainer; export default DropdownContainer;