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

feat: MultiSelect component #1572

Merged
merged 16 commits into from
Jan 15, 2025
5 changes: 5 additions & 0 deletions .changeset/brave-doors-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

feat: MultiSelect component
1 change: 0 additions & 1 deletion easy-ui-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"@types/react-transition-group": "^4.4.12",
"@vitejs/plugin-react": "^4.3.4",
"glob": "^10.2.5",
"jsdom": "^26.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sass": "^1.83.4",
Expand Down
6 changes: 2 additions & 4 deletions easy-ui-react/src/Menu/MenuOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,11 @@ import {
DEFAULT_MAX_ITEMS_UNTIL_SCROLL,
DEFAULT_PLACEMENT,
DEFAULT_WIDTH,
ITEM_HEIGHT,
OVERLAY_OFFSET,
OVERLAY_PADDING_FROM_CONTAINER,
SELECT_ALL_KEY,
Y_PADDING_INSIDE_OVERLAY,
filterSelectedKeys,
getMenuPopoverMaxHeight,
getUnmergedPopoverStyles,
isSelectAllSelected,
useSelectionCapture,
Expand Down Expand Up @@ -125,8 +124,7 @@ function MenuOverlayContent<T extends object>(props: MenuOverlayProps<T>) {
const { popoverProps, underlayProps } = usePopover(
{
containerPadding: OVERLAY_PADDING_FROM_CONTAINER,
maxHeight:
ITEM_HEIGHT * maxItemsUntilScroll + Y_PADDING_INSIDE_OVERLAY * 2 + 2,
maxHeight: getMenuPopoverMaxHeight({ maxItemsUntilScroll }),
offset: OVERLAY_OFFSET,
placement,
popoverRef,
Expand Down
6 changes: 5 additions & 1 deletion easy-ui-react/src/Menu/_mixins.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@use "../styles/common" as *;

@mixin root {
@mixin tokens {
@include component-token(
"menu",
"border_radius",
Expand Down Expand Up @@ -33,6 +33,10 @@
"color.border",
theme-token("color.neutral.300")
);
}

@mixin root {
@include tokens;

background: component-token("menu", "color.background");
border: design-token("shape.border_width.1") solid
Expand Down
8 changes: 8 additions & 0 deletions easy-ui-react/src/Menu/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export const OVERLAY_OFFSET = 8;
export const OVERLAY_PADDING_FROM_CONTAINER = 12;
export const SELECT_ALL_KEY = "all";

export function getMenuPopoverMaxHeight({
maxItemsUntilScroll,
}: {
maxItemsUntilScroll: number;
}) {
return ITEM_HEIGHT * maxItemsUntilScroll + Y_PADDING_INSIDE_OVERLAY * 2 + 2;
}

export function getUnmergedPopoverStyles(
width: MenuOverlayWidth,
triggerWidth: number | null,
Expand Down
59 changes: 59 additions & 0 deletions easy-ui-react/src/MultiSelect/MultiSelect.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Canvas, Meta, ArgTypes, Controls } from "@storybook/blocks";
import { MultiSelect } from "./MultiSelect";
import * as MultiSelectStories from "./MultiSelect.stories";

<Meta of={MultiSelectStories} />

# MultiSelect

The `<MultiSelect />` component is an input with a dropdown that allows users to select multiple options from a list. It's customizable with various props such as `placeholder`, `maxItemsUntilScroll`, and `onSelectionChange`.

<Canvas of={MultiSelectStories.StandardDropdown} />

## Async Dropdown

The `<MultiSelect />` component can also support asynchronous loading of dropdown items. This is useful for fetching items from a server or a large dataset.

<Canvas of={MultiSelectStories.AsyncDropdown} />

## With Icons

This variant of the `<MultiSelect />` includes icons next to each option. It uses the `renderPill` prop to customize how selected items are displayed, and it can render custom content in the dropdown items.

<Canvas of={MultiSelectStories.WithIcons} />

## Disabled Keys

You can disable specific items from being selected by using the `disabledKeys` prop. This is useful when some options should be unselectable.

<Canvas of={MultiSelectStories.DisabledKeys} />

## Max Items Until Scroll

When there are too many items to fit in the dropdown, you can set a maximum number of items to display before the dropdown becomes scrollable. The `maxItemsUntilScroll` prop controls this behavior.

<Canvas of={MultiSelectStories.MaxItemsUntilScroll} />

## Properties

### MultiSelect

<ArgTypes of={MultiSelect} />

### MultiSelect.Pill

The `MultiSelect.Pill` component represents a selected item in the multi-select dropdown. It's used to display each selected item as a "pill" or tag.

<ArgTypes of={MultiSelect.Pill} />

### MultiSelect.Option

The `MultiSelect.Option` component represents a single option in the dropdown.

<ArgTypes of={MultiSelect.Option} />

### MultiSelect.OptionText

The `MultiSelect.OptionText` component represents the default text inside a dropdown option.

<ArgTypes of={MultiSelect.OptionText} />
78 changes: 78 additions & 0 deletions easy-ui-react/src/MultiSelect/MultiSelect.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@use "../styles/common" as *;
@use "../InputField/mixins" as InputField;
@use "../Menu/mixins" as Menu;
@use "../styles/unstyled";

.MultiSelect {
@include InputField.root;
@include component-token("multi-select", "input-min-width", 120px);

position: relative;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: design-token("space.2");

padding: calc(#{design-token("space.1")} - 1px) design-token("space.2");
background-color: component-token("inputfield", "color.background");
border: design-token("shape.border_width.1") solid
component-token("inputfield", "color.border.resting");
border-radius: component-token("inputfield", "border_radius");
min-height: design-token("space.6");
width: 100%;

&:has(.input[data-focused="true"]) {
box-shadow: component-token("inputfield", "box_shadow");
border-color: component-token("inputfield", "color.border.engaged");
}
}

.comboBoxContainer {
display: flex;
flex: 1 1 0%;
}

.comboBox {
display: flex;
flex: 1 1 0%;
}

.inputContainer {
display: inline-flex;
padding-left: 0;
padding-right: 0;
flex-wrap: wrap;
flex: 1 1 0%;
align-items: center;
}

.input {
@include font-style("body1");
flex: 1 1 0%;
width: 100%;
min-width: component-token("multi-select", "input-min-width");
padding: design-token("space.1") 0;
margin: calc(#{design-token("space.1")} * -1) 0;
outline: none;
background-color: transparent;
border: 0;

&:focus,
&:active {
color: component-token("inputfield", "color.text.engaged");
}

&::placeholder {
color: component-token("inputfield", "color.text.subdued");
}
}

.dropdownArrowButton {
@include unstyled.button;
display: inline-flex;
align-items: center;
justify-content: center;
width: design-token("space.3");
height: design-token("space.3");
}
Loading
Loading