-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[v17] Tidy reused Menu components (#50523)
* chore: Tidy reused Menu components * Add generic MultiselectMenu, ViewModeSwitch, SortMenu components * test: Add Stories for new control components
- Loading branch information
Showing
7 changed files
with
801 additions
and
355 deletions.
There are no files selected for viewing
137 changes: 137 additions & 0 deletions
137
web/packages/shared/components/Controls/MultiselectMenu.story.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,137 @@ | ||
/** | ||
* Teleport | ||
* Copyright (C) 2024 Gravitational, Inc. | ||
* | ||
* This program is free software: you can redistribute it and/or modify | ||
* it under the terms of the GNU Affero General Public License as published by | ||
* the Free Software Foundation, either version 3 of the License, or | ||
* (at your option) any later version. | ||
* | ||
* This program is distributed in the hope that it will be useful, | ||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
* GNU Affero General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Affero General Public License | ||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
*/ | ||
|
||
import React, { useState } from 'react'; | ||
import { Flex } from 'design'; | ||
|
||
import { MultiselectMenu } from './MultiselectMenu'; | ||
|
||
import type { Meta, StoryFn, StoryObj } from '@storybook/react'; | ||
|
||
type OptionValue = `option-${number}`; | ||
|
||
const options: { | ||
value: OptionValue; | ||
label: string | React.ReactNode; | ||
disabled?: boolean; | ||
disabledTooltip?: string; | ||
}[] = [ | ||
{ value: 'option-1', label: 'Option 1' }, | ||
{ value: 'option-2', label: 'Option 2' }, | ||
{ value: 'option-3', label: 'Option 3' }, | ||
{ value: 'option-4', label: 'Option 4' }, | ||
]; | ||
|
||
const optionsWithCustomLabels: typeof options = [ | ||
{ | ||
value: 'option-1', | ||
label: <strong>Bold Option 1</strong>, | ||
}, | ||
{ | ||
value: 'option-3', | ||
label: <em>Italic Option 3</em>, | ||
}, | ||
]; | ||
|
||
export default { | ||
title: 'Shared/Controls/MultiselectMenu', | ||
component: MultiselectMenu, | ||
argTypes: { | ||
buffered: { | ||
control: { type: 'boolean' }, | ||
description: 'Buffer selections until "Apply" is clicked', | ||
table: { defaultValue: { summary: 'false' } }, | ||
}, | ||
showIndicator: { | ||
control: { type: 'boolean' }, | ||
description: 'Show indicator when there are selected options', | ||
table: { defaultValue: { summary: 'true' } }, | ||
}, | ||
showSelectControls: { | ||
control: { type: 'boolean' }, | ||
description: 'Show select controls (Select All/Select None)', | ||
table: { defaultValue: { summary: 'true' } }, | ||
}, | ||
label: { | ||
control: { type: 'text' }, | ||
description: 'Label for the multiselect', | ||
}, | ||
tooltip: { | ||
control: { type: 'text' }, | ||
description: 'Tooltip for the label', | ||
}, | ||
selected: { | ||
control: false, | ||
description: 'Currently selected options', | ||
table: { type: { summary: 'T[]' } }, | ||
}, | ||
onChange: { | ||
control: false, | ||
description: 'Callback when selection changes', | ||
table: { type: { summary: 'selected: T[]' } }, | ||
}, | ||
options: { | ||
control: false, | ||
description: 'Options to select from', | ||
table: { | ||
type: { | ||
summary: | ||
'Array<{ value: T; label: string | ReactNode; disabled?: boolean; disabledTooltip?: string; }>', | ||
}, | ||
}, | ||
}, | ||
}, | ||
args: { | ||
label: 'Select Options', | ||
tooltip: 'Choose multiple options', | ||
buffered: false, | ||
showIndicator: true, | ||
showSelectControls: true, | ||
}, | ||
parameters: { controls: { expanded: true, exclude: ['userContext'] } }, | ||
render: (args => { | ||
const [selected, setSelected] = useState<string[]>([]); | ||
return ( | ||
<Flex alignItems="center" minHeight="100px"> | ||
<MultiselectMenu {...args} selected={selected} onChange={setSelected} /> | ||
</Flex> | ||
); | ||
}) satisfies StoryFn<typeof MultiselectMenu<OptionValue>>, | ||
} satisfies Meta<typeof MultiselectMenu<OptionValue>>; | ||
|
||
type Story = StoryObj<typeof MultiselectMenu<OptionValue>>; | ||
|
||
const Default: Story = { args: { options } }; | ||
|
||
const WithCustomLabels: Story = { args: { options: optionsWithCustomLabels } }; | ||
|
||
const WithDisabledOption: Story = { | ||
args: { | ||
options: [ | ||
...options, | ||
{ | ||
value: 'option-5', | ||
label: 'Option 5', | ||
disabled: true, | ||
disabledTooltip: 'Lorum ipsum dolor sit amet', | ||
}, | ||
], | ||
}, | ||
}; | ||
|
||
export { Default, WithCustomLabels, WithDisabledOption }; |
243 changes: 243 additions & 0 deletions
243
web/packages/shared/components/Controls/MultiselectMenu.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,243 @@ | ||
/** | ||
* Teleport | ||
* Copyright (C) 2024 Gravitational, Inc. | ||
* | ||
* This program is free software: you can redistribute it and/or modify | ||
* it under the terms of the GNU Affero General Public License as published by | ||
* the Free Software Foundation, either version 3 of the License, or | ||
* (at your option) any later version. | ||
* | ||
* This program is distributed in the hope that it will be useful, | ||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
* GNU Affero General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Affero General Public License | ||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
*/ | ||
|
||
import React, { ReactNode, useState } from 'react'; | ||
import styled from 'styled-components'; | ||
import { | ||
ButtonPrimary, | ||
ButtonSecondary, | ||
Flex, | ||
Menu, | ||
MenuItem, | ||
Text, | ||
} from 'design'; | ||
import { ChevronDown } from 'design/Icon'; | ||
import { CheckboxInput } from 'design/Checkbox'; | ||
|
||
import { HoverTooltip } from 'shared/components/ToolTip'; | ||
|
||
type MultiselectMenuProps<T> = { | ||
options: { | ||
value: T; | ||
label: string | ReactNode; | ||
disabled?: boolean; | ||
disabledTooltip?: string; | ||
}[]; | ||
selected: T[]; | ||
onChange: (selected: T[]) => void; | ||
label: string | ReactNode; | ||
tooltip: string; | ||
buffered?: boolean; | ||
showIndicator?: boolean; | ||
showSelectControls?: boolean; | ||
}; | ||
|
||
export const MultiselectMenu = <T extends string>({ | ||
onChange, | ||
options, | ||
selected, | ||
label, | ||
tooltip, | ||
buffered = false, | ||
showIndicator = true, | ||
showSelectControls = true, | ||
}: MultiselectMenuProps<T>) => { | ||
// we have a separate state in the filter so we can select a few different things and then click "apply" | ||
const [intSelected, setIntSelected] = useState<T[]>([]); | ||
const [anchorEl, setAnchorEl] = useState<HTMLElement>(null); | ||
const handleOpen = ( | ||
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | ||
) => { | ||
setIntSelected(selected || []); | ||
setAnchorEl(event.currentTarget); | ||
}; | ||
|
||
const handleClose = () => { | ||
setAnchorEl(null); | ||
}; | ||
|
||
// if we cancel, we reset the options to what is already selected in the params | ||
const cancelUpdate = () => { | ||
setIntSelected(selected || []); | ||
handleClose(); | ||
}; | ||
|
||
const handleSelect = (value: T) => { | ||
let newSelected = (buffered ? intSelected : selected).slice(); | ||
|
||
if (newSelected.includes(value)) { | ||
newSelected = newSelected.filter(v => v !== value); | ||
} else { | ||
newSelected.push(value); | ||
} | ||
|
||
(buffered ? setIntSelected : onChange)(newSelected); | ||
}; | ||
|
||
const handleSelectAll = () => { | ||
(buffered ? setIntSelected : onChange)( | ||
options.filter(o => !o.disabled).map(o => o.value) | ||
); | ||
}; | ||
|
||
const handleClearAll = () => { | ||
(buffered ? setIntSelected : onChange)([]); | ||
}; | ||
|
||
const applyFilters = () => { | ||
onChange(intSelected); | ||
handleClose(); | ||
}; | ||
|
||
return ( | ||
<Flex textAlign="center" alignItems="center"> | ||
<HoverTooltip tipContent={tooltip}> | ||
<ButtonSecondary | ||
size="small" | ||
onClick={handleOpen} | ||
aria-haspopup="true" | ||
aria-expanded={!!anchorEl} | ||
> | ||
{label} {selected?.length > 0 ? `(${selected?.length})` : ''} | ||
<ChevronDown ml={2} size="small" color="text.slightlyMuted" /> | ||
{selected?.length > 0 && showIndicator && <FiltersExistIndicator />} | ||
</ButtonSecondary> | ||
</HoverTooltip> | ||
<Menu | ||
popoverCss={() => `margin-top: 36px;`} | ||
menuListCss={() => `overflow-y: auto;`} | ||
transformOrigin={{ | ||
vertical: 'top', | ||
horizontal: 'left', | ||
}} | ||
anchorOrigin={{ | ||
vertical: 'bottom', | ||
horizontal: 'left', | ||
}} | ||
anchorEl={anchorEl} | ||
open={Boolean(anchorEl)} | ||
onClose={cancelUpdate} | ||
> | ||
{showSelectControls && ( | ||
<MultiselectMenuOptionsContainer gap={2} p={2} position="top"> | ||
<ButtonSecondary | ||
size="small" | ||
onClick={handleSelectAll} | ||
textTransform="none" | ||
css={` | ||
background-color: transparent; | ||
`} | ||
px={2} | ||
> | ||
Select All | ||
</ButtonSecondary> | ||
<ButtonSecondary | ||
size="small" | ||
onClick={handleClearAll} | ||
textTransform="none" | ||
css={` | ||
background-color: transparent; | ||
`} | ||
px={2} | ||
> | ||
Clear All | ||
</ButtonSecondary> | ||
</MultiselectMenuOptionsContainer> | ||
)} | ||
{options.map(opt => { | ||
const $checkbox = ( | ||
<> | ||
<CheckboxInput | ||
type="checkbox" | ||
name={opt.value} | ||
disabled={opt.disabled} | ||
onChange={() => { | ||
handleSelect(opt.value); | ||
}} | ||
id={opt.value} | ||
checked={(buffered ? intSelected : selected)?.includes( | ||
opt.value | ||
)} | ||
/> | ||
<Text ml={2} fontWeight={300} fontSize={2}> | ||
{opt.label} | ||
</Text> | ||
</> | ||
); | ||
return ( | ||
<MenuItem | ||
disabled={opt.disabled} | ||
px={2} | ||
key={opt.value} | ||
onClick={() => (!opt.disabled ? handleSelect(opt.value) : null)} | ||
> | ||
{opt.disabled && opt.disabledTooltip ? ( | ||
<HoverTooltip tipContent={opt.disabledTooltip}> | ||
{$checkbox} | ||
</HoverTooltip> | ||
) : ( | ||
$checkbox | ||
)} | ||
</MenuItem> | ||
); | ||
})} | ||
{buffered && ( | ||
<MultiselectMenuOptionsContainer | ||
justifyContent="space-between" | ||
p={2} | ||
gap={2} | ||
position="bottom" | ||
> | ||
<ButtonPrimary size="small" onClick={applyFilters}> | ||
Apply Filters | ||
</ButtonPrimary> | ||
<ButtonSecondary | ||
size="small" | ||
css={` | ||
background-color: transparent; | ||
`} | ||
onClick={cancelUpdate} | ||
> | ||
Cancel | ||
</ButtonSecondary> | ||
</MultiselectMenuOptionsContainer> | ||
)} | ||
</Menu> | ||
</Flex> | ||
); | ||
}; | ||
|
||
const MultiselectMenuOptionsContainer = styled(Flex)<{ | ||
position: 'top' | 'bottom'; | ||
}>` | ||
position: sticky; | ||
${p => (p.position === 'top' ? 'top: 0;' : 'bottom: 0;')} | ||
background-color: ${p => p.theme.colors.levels.elevated}; | ||
z-index: 1; | ||
`; | ||
|
||
const FiltersExistIndicator = styled.div` | ||
position: absolute; | ||
top: -4px; | ||
right: -4px; | ||
height: 12px; | ||
width: 12px; | ||
background-color: ${p => p.theme.colors.brand}; | ||
border-radius: 50%; | ||
display: inline-block; | ||
`; |
Oops, something went wrong.