Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

multi select component #1554

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions frontend/app/element/multiselect.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

.multi-select {
border-radius: 7px;
background: rgb(from var(--block-bg-color) r g b / 70%);
color: var(--main-text-color);
padding: 4px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
align-self: stretch;
border: 1px solid rgb(from var(--main-text-color) r g b / 15%);
width: 100%;

.option {
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 12px;
border-radius: 4px;
align-self: stretch;
border: 1px solid transparent;

&:hover {
background-color: rgb(from var(--main-bg-color) r g b / 60%);
}

&.selected {
border: 1px solid var(--success-color);
background: rgb(from var(--success-color) r g b / 15%);

i {
color: var(--success-color);
}
}
}

i {
font-size: 1rem;
}
}
55 changes: 55 additions & 0 deletions frontend/app/element/multiselect.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import type { Meta, StoryObj } from "@storybook/react";
import { MultiSelect } from "./multiselect";

const meta: Meta<typeof MultiSelect> = {
title: "Components/MultiSelect",
component: MultiSelect,
args: {
options: [
{ label: "macOS", value: "macos" },
{ label: "Windows", value: "windows" },
{ label: "Linux", value: "linux" },
],
},
argTypes: {
options: {
description: "List of selectable options.",
},
selectedValues: {
description: "Array of selected option values.",
},
onChange: {
description: "Callback triggered when selected options change.",
action: "changed",
},
},
};

export default meta;

type Story = StoryObj<typeof MultiSelect>;

export const WithPreselectedValues: Story = {
render: (args) => (
<div style={{ width: "500px", padding: "20px", border: "2px solid #ccc", background: "#111" }}>
<MultiSelect {...args} />
</div>
),
args: {
selectedValues: ["macos", "windows"],
},
};

export const WithNoSelection: Story = {
render: (args) => (
<div style={{ width: "500px", padding: "20px", border: "2px solid #ccc", background: "#111" }}>
<MultiSelect {...args} />
</div>
),
args: {
selectedValues: [],
},
};
63 changes: 63 additions & 0 deletions frontend/app/element/multiselect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import React, { useState } from "react";
import "./multiselect.scss";

type Option = {
label: string;
value: string;
};

type MultiSelectProps = {
options: Option[];
selectedValues?: string[];
onChange: (values: string[]) => void;
};

const MultiSelect = ({ options, selectedValues = [], onChange }: MultiSelectProps) => {
const [selected, setSelected] = useState<string[]>(selectedValues);

const handleToggle = (value: string) => {
setSelected((prevSelected) => {
const newSelected = prevSelected.includes(value)
? prevSelected.filter((v) => v !== value) // Remove if already selected
: [...prevSelected, value]; // Add if not selected

onChange(newSelected);
return newSelected;
});
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, value: string) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleToggle(value);
}
};

return (
<div className="multi-select" role="listbox" aria-multiselectable="true" aria-label="Multi-select list">
{options.map((option) => {
const isSelected = selected.includes(option.value);

return (
<div
key={option.value}
role="option"
aria-selected={isSelected}
className={`option ${isSelected ? "selected" : ""}`}
tabIndex={0}
onClick={() => handleToggle(option.value)}
onKeyDown={(e) => handleKeyDown(e, option.value)}
>
{option.label}
{isSelected && <i className="fa fa-solid fa-check" aria-hidden="true" />}
</div>
);
})}
</div>
);
};
Comment on lines +39 to +61
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enhance accessibility and performance for large lists.

The component is accessible but could be improved:

  1. Add loading state
  2. Consider virtualization for large lists
  3. Enhance ARIA labels

Consider these improvements:

 return (
     <div className="multi-select" role="listbox" aria-multiselectable="true" aria-label="Multi-select list">
+        {options.length === 0 && (
+            <div className="empty-state" role="alert">
+                No options available
+            </div>
+        )}
         {options.map((option) => {
             const isSelected = selected.includes(option.value);
             return (
                 <div
                     key={option.value}
                     role="option"
                     aria-selected={isSelected}
+                    aria-label={`${option.label}${isSelected ? ' selected' : ''}`}
                     className={`option ${isSelected ? "selected" : ""}`}
                     tabIndex={0}
                     onClick={() => handleToggle(option.value)}
                     onKeyDown={(e) => handleKeyDown(e, option.value)}
                 >
                     {option.label}
                     {isSelected && <i className="fa fa-solid fa-check" aria-hidden="true" />}
                 </div>
             );
         })}
     </div>
 );

For large lists, consider using a virtualization library:

import { FixedSizeList } from 'react-window';


export { MultiSelect };
Empty file.
Empty file.
Loading