Skip to content

Commit

Permalink
Tag Selector Component (#12564)
Browse files Browse the repository at this point in the history
### Summary & Motivation
Adding a Tag Selector component.

Considered using Blueprint's Multi-select component:
https://blueprintjs.com/docs/versions/3/#select/multi-select but it has
a few limitations:
1. We can't provide a collapsed view for all of the selected tags
2. We don't want to allow typing

### How I Tested These Changes

I played around with the component in Storybook. Created 2 examples, a
basic one and a more advanced one using the render props.
<img width="1161" alt="Screen Shot 2023-02-27 at 2 54 00 PM"
src="https://user-images.githubusercontent.com/2286579/221669263-83529f4c-5d57-4935-9a1a-acd2cf43d2e9.png">
<img width="1172" alt="Screen Shot 2023-02-27 at 2 53 56 PM"
src="https://user-images.githubusercontent.com/2286579/221669265-0515f4c8-c6ef-4c76-904a-29a1499a558c.png">
  • Loading branch information
salazarm committed Feb 28, 2023
1 parent 848c6e8 commit 2b22589
Show file tree
Hide file tree
Showing 2 changed files with 290 additions and 0 deletions.
118 changes: 118 additions & 0 deletions js_modules/dagit/packages/ui/src/components/TagSelector.stories.tsx
@@ -0,0 +1,118 @@
import {Meta} from '@storybook/react/types-6-0';
import * as React from 'react';
import styled from 'styled-components/macro';

import {Box} from './Box';
import {Checkbox} from './Checkbox';
import {Colors} from './Colors';
import {Icon} from './Icon';
import {Menu, MenuDivider, MenuItem} from './Menu';
import {TagSelector} from './TagSelector';

// eslint-disable-next-line import/no-default-export
export default {
title: 'TagSelector',
component: TagSelector,
} as Meta;

const allTags = [
'NY',
'NJ',
'VC',
'FL',
'AL',
'CALIFORNIA_SUPER_LONG_TAG',
'ANOTHER_REALLY_LONG_TAG',
'LONG_TAGS_ARE_GREAT_FOR_TESTING_DECEMBER_2020',
];

export const Basic = () => {
const [selectedTags, setSelectedTags] = React.useState<string[]>(['NY', 'NJ']);
return (
<TagSelector allTags={allTags} selectedTags={selectedTags} setSelectedTags={setSelectedTags} />
);
};

export const Styled = () => {
const [selectedTags, setSelectedTags] = React.useState<string[]>(allTags.slice(0, 2));
const isAllSelected = selectedTags.length === 6;
return (
<TagSelector
allTags={allTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
placeholder="Select a partition or create one"
renderDropdownItem={(tag, dropdownItemProps) => {
return (
<label>
<MenuItem
tagName="div"
text={
<Box flex={{alignItems: 'center', gap: 12}}>
<Checkbox
checked={dropdownItemProps.selected}
onChange={dropdownItemProps.toggle}
/>
<Dot color={Math.random() > 0.5 ? Colors.Green500 : Colors.Gray500} />
<span>{tag}</span>
</Box>
}
/>
</label>
);
}}
renderDropdown={(dropdown) => {
const toggleAll = () => {
if (isAllSelected) {
setSelectedTags([]);
} else {
setSelectedTags(allTags);
}
};
return (
<Menu>
<Box padding={4}>
<Box flex={{direction: 'column'}} padding={{horizontal: 8}}>
<Box flex={{direction: 'row', alignItems: 'center'}}>
<StyledIcon name="add" size={24} />
<span>Create Partition</span>
</Box>
</Box>
<MenuDivider />
<label>
<MenuItem
tagName="div"
text={
<Box flex={{alignItems: 'center', gap: 8}}>
<Checkbox checked={isAllSelected} onChange={toggleAll} />
<span>Select All ({allTags.length})</span>
</Box>
}
/>
</label>
{dropdown}
</Box>
</Menu>
);
}}
renderTagList={(tags) => {
if (tags.length > 3) {
return <span>{tags.length} partitions selected</span>;
}
return tags;
}}
/>
);
};

const StyledIcon = styled(Icon)`
font-weight: 500;
`;

const Dot = styled.div<{color: string}>`
width: 8px;
height: 8px;
border-radius: 4px;
background-color: ${({color}) => color};
display: inline-block;
`;
172 changes: 172 additions & 0 deletions js_modules/dagit/packages/ui/src/components/TagSelector.tsx
@@ -0,0 +1,172 @@
import React from 'react';
import styled from 'styled-components/macro';

import {Box} from './Box';
import {Checkbox} from './Checkbox';
import {Colors} from './Colors';
import {Icon} from './Icon';
import {MenuItem, Menu} from './Menu';
import {Popover} from './Popover';
import {Tag} from './Tag';
import {TextInputStyles} from './TextInput';

type TagProps = {
remove: (ev: React.SyntheticEvent<HTMLDivElement>) => void;
};
type DropdownItemProps = {
toggle: () => void;
selected: boolean;
};
type Props = {
placeholder?: React.ReactNode;
allTags: string[];
selectedTags: string[];
setSelectedTags: (tags: React.SetStateAction<string[]>) => void;
renderTag?: (tag: string, tagProps: TagProps) => React.ReactNode;
renderTagList?: (tags: React.ReactNode[]) => React.ReactNode;
renderDropdown?: (dropdown: React.ReactNode) => React.ReactNode;
renderDropdownItem?: (tag: string, dropdownItemProps: DropdownItemProps) => React.ReactNode;
dropdownStyles?: React.CSSProperties;
};

const defaultRenderTag = (tag: string, tagProps: TagProps) => {
return (
<Tag>
<Box flex={{direction: 'row', gap: 4, justifyContent: 'space-between', alignItems: 'center'}}>
<span>{tag}</span>
<Box style={{cursor: 'pointer'}} onClick={tagProps.remove}>
<Icon name="close" />
</Box>
</Box>
</Tag>
);
};

const defaultRenderDropdownItem = (tag: string, dropdownItemProps: DropdownItemProps) => {
return (
<label>
<MenuItem
text={
<Box flex={{alignItems: 'center', gap: 8}}>
<Checkbox checked={dropdownItemProps.selected} onChange={dropdownItemProps.toggle} />
<span>{tag}</span>
</Box>
}
tagName="div"
/>
</label>
);
};

export const TagSelector = ({
allTags,
placeholder,
selectedTags,
setSelectedTags,
renderTag,
renderDropdownItem,
renderDropdown,
dropdownStyles,
renderTagList,
}: Props) => {
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const dropdown = React.useMemo(() => {
const dropdownContent = (
<Box
style={{
maxHeight: '500px',
overflowY: 'auto',
...dropdownStyles,
}}
>
{allTags.map((tag) => {
const selected = selectedTags.includes(tag);
const toggle = () => {
setSelectedTags(
selected ? selectedTags.filter((t) => t !== tag) : [...selectedTags, tag],
);
};
if (renderDropdownItem) {
return <div key={tag}>{renderDropdownItem(tag, {toggle, selected})}</div>;
}
return defaultRenderDropdownItem(tag, {toggle, selected});
})}
</Box>
);
if (renderDropdown) {
return renderDropdown(dropdownContent);
}
return <Menu>{dropdownContent}</Menu>;
}, [allTags, dropdownStyles, renderDropdown, renderDropdownItem, selectedTags, setSelectedTags]);

const dropdownContainer = React.useRef<HTMLDivElement>(null);

const tagsContent = React.useMemo(() => {
if (selectedTags.length === 0) {
return <Placeholder>{placeholder || 'Select tags'}</Placeholder>;
}
const tags = selectedTags.map((tag) =>
(renderTag || defaultRenderTag)(tag, {
remove: (ev) => {
setSelectedTags((tags) => tags.filter((t) => t !== tag));
ev.stopPropagation();
},
}),
);
if (renderTagList) {
return renderTagList(tags);
}
return tags;
}, [placeholder, selectedTags, renderTag, renderTagList]);

return (
<Popover
placement="bottom-start"
isOpen={isDropdownOpen}
onInteraction={(nextOpenState, e) => {
const target = e?.target;
if (isDropdownOpen && target instanceof HTMLElement) {
const isClickInside = dropdownContainer.current?.contains(target);
if (!isClickInside) {
setIsDropdownOpen(nextOpenState);
}
}
}}
content={<div ref={dropdownContainer}>{dropdown}</div>}
targetTagName="div"
>
<Container
onClick={() => {
setIsDropdownOpen((isOpen) => !isOpen);
}}
>
<TagsContainer flex={{grow: 1, gap: 6}}>{tagsContent}</TagsContainer>
<div style={{cursor: 'pointer'}}>
<Icon name={isDropdownOpen ? 'expand_less' : 'expand_more'} />
</div>
</Container>
</Popover>
);
};

const Container = styled.div`
display: flex;
flex-direction: row;
align-items: center;
${TextInputStyles}
`;

const Placeholder = styled.div`
color: ${Colors.Gray400};
`;

const TagsContainer = styled(Box)`
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
`;

1 comment on commit 2b22589

@vercel
Copy link

@vercel vercel bot commented on 2b22589 Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

dagit-storybook – ./js_modules/dagit/packages/ui

dagit-storybook-elementl.vercel.app
dagit-storybook-git-master-elementl.vercel.app
dagit-storybook.vercel.app

Please sign in to comment.