diff --git a/web/packages/shared/components/Controls/MultiselectMenu.story.tsx b/web/packages/shared/components/Controls/MultiselectMenu.story.tsx
new file mode 100644
index 0000000000000..17a960d121d00
--- /dev/null
+++ b/web/packages/shared/components/Controls/MultiselectMenu.story.tsx
@@ -0,0 +1,148 @@
+/**
+ * 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 .
+ */
+
+import React, { useState } from 'react';
+import { Flex } from 'design';
+
+import { MultiselectMenu } from './MultiselectMenu';
+
+import type { Meta, StoryFn, StoryObj } from '@storybook/react';
+
+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'] } },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+type OptionValue = 'option1' | 'option2' | 'option3' | 'option4';
+
+const options: {
+ value: OptionValue;
+ label: string | React.ReactNode;
+ disabled?: boolean;
+ disabledTooltip?: string;
+}[] = [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2' },
+ { value: 'option3', label: 'Option 3' },
+ { value: 'option4', label: 'Option 4' },
+];
+
+const Template: StoryFn = args => {
+ const [selected, setSelected] = useState([]);
+ return (
+
+
+
+ );
+};
+
+export const Default: Story = {
+ args: { options },
+ render: Template,
+};
+
+const customOptions: typeof options = [
+ {
+ value: 'option1',
+ label: Bold Option 1,
+ },
+ {
+ value: 'option3',
+ label: Italic Option 3,
+ },
+];
+
+export const WithCustomLabels: Story = {
+ args: { options: customOptions },
+ render: Template,
+};
+
+export const WithDisabledOption: Story = {
+ args: {
+ options: [
+ ...options,
+ {
+ value: 'option5',
+ label: 'Option 5',
+ disabled: true,
+ disabledTooltip: 'Option is disabled',
+ },
+ ],
+ },
+ render: Template,
+};
diff --git a/web/packages/shared/components/Controls/MultiselectMenu.tsx b/web/packages/shared/components/Controls/MultiselectMenu.tsx
index ac57a1de1d3b7..f252cf7aa21be 100644
--- a/web/packages/shared/components/Controls/MultiselectMenu.tsx
+++ b/web/packages/shared/components/Controls/MultiselectMenu.tsx
@@ -164,8 +164,7 @@ export const MultiselectMenu = ({
<>
{
handleSelect(opt.value);
diff --git a/web/packages/shared/components/Controls/SortMenu.story.tsx b/web/packages/shared/components/Controls/SortMenu.story.tsx
new file mode 100644
index 0000000000000..9a2edd7b6ad50
--- /dev/null
+++ b/web/packages/shared/components/Controls/SortMenu.story.tsx
@@ -0,0 +1,88 @@
+/**
+ * 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 .
+ */
+
+import React, { useState } from 'react';
+import { Flex } from 'design';
+
+import { SortMenu } from './SortMenu';
+
+import type { Meta, StoryFn, StoryObj } from '@storybook/react';
+
+export default {
+ title: 'Shared/Controls/SortMenu',
+ component: SortMenu,
+ argTypes: {
+ current: {
+ control: false,
+ description: 'Current sort',
+ table: {
+ type: {
+ summary:
+ "Array<{ fieldName: Exclude; dir: 'ASC' | 'DESC'>",
+ },
+ },
+ },
+ fields: {
+ control: false,
+ description: 'Fields to sort by',
+ table: {
+ type: {
+ summary:
+ '{ value: Exclude; label: string }[]',
+ },
+ },
+ },
+ onChange: {
+ control: false,
+ description: 'Callback when fieldName or dir is changed',
+ table: {
+ type: {
+ summary:
+ "(value: { fieldName: Exclude; dir: 'ASC' | 'DESC' }) => void",
+ },
+ },
+ },
+ },
+ args: {
+ current: { fieldName: 'name', dir: 'ASC' },
+ fields: [
+ { value: 'name', label: 'Name' },
+ { value: 'created', label: 'Created' },
+ { value: 'updated', label: 'Updated' },
+ ],
+ },
+ parameters: { controls: { expanded: true, exclude: ['userContext'] } },
+} satisfies Meta>;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (({ current, fields }) => {
+ const [sort, setSort] = useState(current);
+ return (
+
+
+
+ );
+ }) satisfies StoryFn,
+};
diff --git a/web/packages/shared/components/Controls/ViewModeSwitch.story.tsx b/web/packages/shared/components/Controls/ViewModeSwitch.story.tsx
new file mode 100644
index 0000000000000..9ed4743222614
--- /dev/null
+++ b/web/packages/shared/components/Controls/ViewModeSwitch.story.tsx
@@ -0,0 +1,66 @@
+/**
+ * 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 .
+ */
+
+import React, { useState } from 'react';
+import { Flex } from 'design';
+
+import { ViewMode } from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb';
+
+import { ViewModeSwitch } from './ViewModeSwitch';
+
+import type { Meta, StoryFn, StoryObj } from '@storybook/react';
+
+export default {
+ title: 'Shared/Controls/ViewModeSwitch',
+ component: ViewModeSwitch,
+ argTypes: {
+ currentViewMode: {
+ control: { type: 'radio', options: [ViewMode.CARD, ViewMode.LIST] },
+ description: 'Current view mode',
+ table: { defaultValue: { summary: ViewMode.CARD.toString() } },
+ },
+ setCurrentViewMode: {
+ control: false,
+ description: 'Callback to set current view mode',
+ table: { type: { summary: '(newViewMode: ViewMode) => void' } },
+ },
+ },
+ args: { currentViewMode: ViewMode.CARD },
+ parameters: { controls: { expanded: true, exclude: ['userContext'] } },
+} satisfies Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (({ currentViewMode }) => {
+ const [viewMode, setViewMode] = useState(currentViewMode);
+ return (
+
+
+
+ );
+ }) satisfies StoryFn,
+};
diff --git a/web/packages/shared/components/Controls/ViewModeSwitch.tsx b/web/packages/shared/components/Controls/ViewModeSwitch.tsx
index 09aa577342ec3..6a471a51d294e 100644
--- a/web/packages/shared/components/Controls/ViewModeSwitch.tsx
+++ b/web/packages/shared/components/Controls/ViewModeSwitch.tsx
@@ -41,15 +41,10 @@ export const ViewModeSwitch = ({
setCurrentViewMode(ViewMode.CARD)}
- css={`
- border-right: 1px solid
- ${props => props.theme.colors.spotBackground[2]};
- border-top-left-radius: 4px;
- border-bottom-left-radius: 4px;
- `}
role="radio"
aria-label="Card View"
aria-checked={currentViewMode === ViewMode.CARD}
+ first
>
@@ -58,13 +53,10 @@ export const ViewModeSwitch = ({
setCurrentViewMode(ViewMode.LIST)}
- css={`
- border-top-right-radius: 4px;
- border-bottom-right-radius: 4px;
- `}
role="radio"
aria-label="List View"
aria-checked={currentViewMode === ViewMode.LIST}
+ last
>
@@ -90,7 +82,7 @@ const ViewModeSwitchContainer = styled.div`
}
`;
-const ViewModeSwitchButton = styled.button`
+const ViewModeSwitchButton = styled.button<{ first?: boolean; last?: boolean }>`
height: 100%;
width: 100%;
overflow: hidden;
@@ -103,6 +95,20 @@ const ViewModeSwitchButton = styled.button`
outline: none;
transition: outline-width 150ms ease;
+ ${p =>
+ p.first &&
+ `
+ border-top-left-radius: ${p.theme.radii[2]}px;
+ border-bottom-left-radius: ${p.theme.radii[2]}px;
+ border-right: ${p.theme.borders[1]} ${p.theme.colors.spotBackground[2]};
+ `}
+ ${p =>
+ p.last &&
+ `
+ border-top-right-radius: ${p.theme.radii[2]}px;
+ border-bottom-right-radius: ${p.theme.radii[2]}px;
+ `}
+
&:focus-visible {
outline: ${p => p.theme.borders[1]}
${p => p.theme.colors.text.slightlyMuted};