Skip to content

Commit

Permalink
[v17] Tidy reused Menu components (#50523)
Browse files Browse the repository at this point in the history
* chore: Tidy reused Menu components

* Add generic MultiselectMenu, ViewModeSwitch, SortMenu components

* test: Add Stories for new control components
  • Loading branch information
kiosion authored Dec 30, 2024
1 parent defaf5c commit 8331d85
Show file tree
Hide file tree
Showing 7 changed files with 801 additions and 355 deletions.
137 changes: 137 additions & 0 deletions web/packages/shared/components/Controls/MultiselectMenu.story.tsx
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 web/packages/shared/components/Controls/MultiselectMenu.tsx
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;
`;
Loading

0 comments on commit 8331d85

Please sign in to comment.