mirror of https://github.com/apache/superset.git
feat: Adds more customization properties to DropdownContainer (#22031)
This commit is contained in:
parent
9b6322b640
commit
b040211970
|
@ -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;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue