Skip to content

Commit

Permalink
feat: Adds the DropdownContainer component (#21974)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-s-molina authored Nov 3, 2022
1 parent 4cbd70d commit 97e3e79
Show file tree
Hide file tree
Showing 6 changed files with 403 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState } from 'react';
import { isEqual } from 'lodash';
import { css } from '@superset-ui/core';
import Select from '../Select/Select';
import DropdownContainer, { DropdownContainerProps } from '.';

export default {
title: 'DropdownContainer',
component: DropdownContainer,
};

const ITEMS_COUNT = 6;
const ITEM_OPTIONS = 10;
const MIN_WIDTH = 700;
const MAX_WIDTH = 1500;

const itemsOptions = Array.from({ length: ITEM_OPTIONS }).map((_, i) => ({
label: `Option ${i}`,
value: `option-${i}`,
}));

type ItemsType = Pick<DropdownContainerProps, 'items'>['items'];

type OverflowingState = { notOverflowed: string[]; overflowed: string[] };

const generateItems = (overflowingState?: OverflowingState) =>
Array.from({ length: ITEMS_COUNT }).map((_, i) => ({
id: `el-${i}`,
element: (
<div style={{ minWidth: 200 }}>
<Select
options={itemsOptions}
header={`Option ${i}`}
headerPosition={
overflowingState?.overflowed.includes(`el-${i}`) ? 'top' : 'left'
}
/>
</div>
),
}));

export const Component = (props: DropdownContainerProps) => {
const [items, setItems] = useState<ItemsType>([]);
const [overflowingState, setOverflowingState] = useState<OverflowingState>();

useEffect(() => {
setItems(generateItems(overflowingState));
}, [overflowingState]);

return (
<div
css={css`
position: relative;
overflow: auto;
min-width: ${MIN_WIDTH}px;
width: ${MIN_WIDTH}px;
max-width: ${MAX_WIDTH}px;
height: 80vh;
border: 1px solid lightgray;
resize: horizontal;
padding: 24px;
`}
>
<DropdownContainer
{...props}
items={items}
onOverflowingStateChange={value => {
if (!isEqual(overflowingState, value)) {
setOverflowingState(value);
}
}}
/>
<span
css={css`
position: absolute;
right: 20px;
bottom: 8px;
color: gray;
`}
>
Use the drag icon to resize the container
</span>
</div>
);
};
256 changes: 256 additions & 0 deletions superset-frontend/src/components/DropdownContainer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, {
CSSProperties,
ReactElement,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { css, t, useTheme } from '@superset-ui/core';
import { useResizeDetector } from 'react-resize-detector';
import { usePrevious } from 'src/hooks/usePrevious';
import Badge from '../Badge';
import Icons from '../Icons';
import Button from '../Button';
import Popover from '../Popover';

/**
* Container item.
*/
export interface Item {
/**
* String that uniquely identifies the item.
*/
id: string;
/**
* The element to be rendered.
*/
element: ReactElement;
}

/**
* Horizontal container that displays overflowed items in a popover.
* It shows an indicator of how many items are currently overflowing.
*/
export interface DropdownContainerProps {
/**
* Array of items. The id property is used to uniquely identify
* the elements when rendering or dealing with event handlers.
*/
items: Item[];
/**
* Event handler called every time an element moves between
* main container and popover.
*/
onOverflowingStateChange?: (overflowingState: {
notOverflowed: string[];
overflowed: string[];
}) => void;
/**
* Option to customize the content of the popover.
*/
popoverContent?: (overflowedItems: Item[]) => ReactElement;
/**
* Popover additional style properties.
*/
popoverStyle?: CSSProperties;
/**
* Icon of the popover trigger.
*/
popoverTriggerIcon?: ReactElement;
/**
* Text of the popover trigger.
*/
popoverTriggerText?: string;
/**
* Main container additional style properties.
*/
style?: CSSProperties;
}

const DropdownContainer = ({
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 container = current?.children.item(0);
if (container) {
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);

if (width > previousWidth && overflowingIndex !== -1) {
// Calculates remaining space in the container
const button = current?.children.item(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,
items.length,
itemsWidth,
overflowingIndex,
previousWidth,
width,
]);

const reduceItems = (items: Item[]): [Item[], string[]] =>
items.reduce(
([items, ids], item) => {
items.push({
id: item.id,
element: React.cloneElement(item.element, { key: item.id }),
});
ids.push(item.id);
return [items, ids];
},
[[], []] as [Item[], string[]],
);

const [notOverflowedItems, notOverflowedIds] = useMemo(
() =>
reduceItems(
items.slice(
0,
overflowingIndex !== -1 ? overflowingIndex : items.length,
),
),
[items, overflowingIndex],
);

const [overflowedItems, overflowedIds] = useMemo(
() =>
overflowingIndex !== -1
? reduceItems(items.slice(overflowingIndex, items.length))
: [[], []],
[items, overflowingIndex],
);

useEffect(() => {
if (onOverflowingStateChange) {
onOverflowingStateChange({
notOverflowed: notOverflowedIds,
overflowed: overflowedIds,
});
}
}, [notOverflowedIds, onOverflowingStateChange, overflowedIds]);

const content = useMemo(
() => (
<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">
{popoverTriggerIcon}
{popoverTriggerText}
<Badge count={overflowingCount} />
<Icons.DownOutlined
iconSize="m"
iconColor={theme.colors.grayscale.base}
/>
</Button>
</Popover>
)}
</div>
);
};

export default DropdownContainer;
9 changes: 7 additions & 2 deletions superset-frontend/src/components/Select/AsyncSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
StyledContainer,
StyledError,
StyledErrorMessage,
StyledHeader,
StyledSelect,
StyledStopOutlined,
} from './styles';
Expand Down Expand Up @@ -103,6 +104,7 @@ const AsyncSelect = forwardRef(
fetchOnlyOnSearch,
filterOption = true,
header = null,
headerPosition = 'top',
helperText,
invertSelection = false,
lazyLoading = true,
Expand Down Expand Up @@ -470,8 +472,10 @@ const AsyncSelect = forwardRef(
);

return (
<StyledContainer>
{header}
<StyledContainer headerPosition={headerPosition}>
{header && (
<StyledHeader headerPosition={headerPosition}>{header}</StyledHeader>
)}
<StyledSelect
allowClear={!isLoading && allowClear}
aria-label={ariaLabel || name}
Expand All @@ -481,6 +485,7 @@ const AsyncSelect = forwardRef(
getPopupContainer={
getPopupContainer || (triggerNode => triggerNode.parentNode)
}
headerPosition={headerPosition}
labelInValue
maxTagCount={MAX_TAG_COUNT}
mode={mappedMode}
Expand Down
Loading

0 comments on commit 97e3e79

Please sign in to comment.