Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
### 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
Showing
2 changed files
with
290 additions
and
0 deletions.
There are no files selected for viewing
118 changes: 118 additions & 0 deletions
118
js_modules/dagit/packages/ui/src/components/TagSelector.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
172
js_modules/dagit/packages/ui/src/components/TagSelector.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
`; |
2b22589
There was a problem hiding this comment.
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