diff --git a/.changeset/brave-doors-hug.md b/.changeset/brave-doors-hug.md new file mode 100644 index 00000000..e7cfccda --- /dev/null +++ b/.changeset/brave-doors-hug.md @@ -0,0 +1,5 @@ +--- +"@easypost/easy-ui": minor +--- + +feat: MultiSelect component diff --git a/easy-ui-react/package.json b/easy-ui-react/package.json index a8fa3bf8..4d772de7 100644 --- a/easy-ui-react/package.json +++ b/easy-ui-react/package.json @@ -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", diff --git a/easy-ui-react/src/Menu/MenuOverlay.tsx b/easy-ui-react/src/Menu/MenuOverlay.tsx index af3ff354..242ad494 100644 --- a/easy-ui-react/src/Menu/MenuOverlay.tsx +++ b/easy-ui-react/src/Menu/MenuOverlay.tsx @@ -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, @@ -125,8 +124,7 @@ function MenuOverlayContent(props: MenuOverlayProps) { 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, diff --git a/easy-ui-react/src/Menu/_mixins.scss b/easy-ui-react/src/Menu/_mixins.scss index c48d64fb..efce9645 100644 --- a/easy-ui-react/src/Menu/_mixins.scss +++ b/easy-ui-react/src/Menu/_mixins.scss @@ -1,6 +1,6 @@ @use "../styles/common" as *; -@mixin root { +@mixin tokens { @include component-token( "menu", "border_radius", @@ -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 diff --git a/easy-ui-react/src/Menu/utilities.ts b/easy-ui-react/src/Menu/utilities.ts index 03835f61..bce8baeb 100644 --- a/easy-ui-react/src/Menu/utilities.ts +++ b/easy-ui-react/src/Menu/utilities.ts @@ -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, diff --git a/easy-ui-react/src/MultiSelect/MultiSelect.mdx b/easy-ui-react/src/MultiSelect/MultiSelect.mdx new file mode 100644 index 00000000..7d984c27 --- /dev/null +++ b/easy-ui-react/src/MultiSelect/MultiSelect.mdx @@ -0,0 +1,59 @@ +import { Canvas, Meta, ArgTypes, Controls } from "@storybook/blocks"; +import { MultiSelect } from "./MultiSelect"; +import * as MultiSelectStories from "./MultiSelect.stories"; + + + +# MultiSelect + +The `` 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`. + + + +## Async Dropdown + +The `` component can also support asynchronous loading of dropdown items. This is useful for fetching items from a server or a large dataset. + + + +## With Icons + +This variant of the `` 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. + + + +## Disabled Keys + +You can disable specific items from being selected by using the `disabledKeys` prop. This is useful when some options should be unselectable. + + + +## 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. + + + +## Properties + +### 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. + + + +### MultiSelect.Option + +The `MultiSelect.Option` component represents a single option in the dropdown. + + + +### MultiSelect.OptionText + +The `MultiSelect.OptionText` component represents the default text inside a dropdown option. + + diff --git a/easy-ui-react/src/MultiSelect/MultiSelect.module.scss b/easy-ui-react/src/MultiSelect/MultiSelect.module.scss new file mode 100644 index 00000000..af417fdb --- /dev/null +++ b/easy-ui-react/src/MultiSelect/MultiSelect.module.scss @@ -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"); +} diff --git a/easy-ui-react/src/MultiSelect/MultiSelect.stories.tsx b/easy-ui-react/src/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 00000000..3eb1fdd3 --- /dev/null +++ b/easy-ui-react/src/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,263 @@ +import { Meta, StoryObj } from "@storybook/react"; +import React, { useCallback } from "react"; +import { HorizontalStack } from "../HorizontalStack"; +import { Icon } from "../Icon"; +import { + FedExLogoImg, + InlineStoryDecorator, + UPSLogoImg, +} from "../utilities/storybook"; +import { + Item, + MultiSelect, + useAsyncList, + useFilter, + useListData, +} from "./MultiSelect"; + +const fruits = [ + { key: 1, label: "Apple" }, + { key: 2, label: "Banana" }, + { key: 3, label: "Cherry" }, + { key: 4, label: "Date" }, + { key: 5, label: "Elderberry" }, + { key: 6, label: "Fig" }, + { key: 7, label: "Grape" }, + { key: 8, label: "Honeydew" }, + { key: 9, label: "Kiwi" }, + { key: 10, label: "Lemon" }, + { key: 11, label: "Mango" }, + { key: 12, label: "Nectarine" }, + { key: 13, label: "Orange" }, + { key: 14, label: "Papaya" }, + { key: 15, label: "Quince" }, + { key: 16, label: "Raspberry" }, + { key: 17, label: "Strawberry" }, + { key: 18, label: "Tangerine" }, + { key: 19, label: "Ugli Fruit" }, + { key: 20, label: "Watermelon" }, +] as const satisfies Item[]; + +type Story = StoryObj; + +const meta: Meta = { + title: "Components/MultiSelect", + component: MultiSelect, + args: {}, + decorators: [ + (Story) => ( +
+ +
+ ), + InlineStoryDecorator, + ], +}; + +export default meta; + +export const StandardDropdown: Story = { + render: () => { + const [selectedItems, setSelectedItems] = React.useState([ + fruits[0], + ]); + const { contains } = useFilter({ sensitivity: "base" }); + const filter = useCallback( + (item: Item, filterText: string) => contains(item.label, filterText), + [contains], + ); + const list = useListData({ + initialSelectedKeys: selectedItems.map((i) => i.key), + initialItems: fruits, + filter, + }); + return ( + ( + + )} + > + {(item) => ( + + {item.label} + + )} + + ); + }, +}; + +export const AsyncDropdown: Story = { + render: () => { + const [selectedItems, setSelectedItems] = React.useState([ + { key: 1, label: "Apple", icon: FedExLogoImg }, + ]); + const { contains } = useFilter({ sensitivity: "base" }); + const list = useAsyncList({ + initialSelectedKeys: [1], + async load({ filterText }) { + await new Promise((resolve) => setTimeout(resolve, 300)); + return { + items: fruits.filter((fruit) => { + return filterText ? contains(fruit.label, filterText) : true; + }), + }; + }, + }); + return ( + } + > + {(item) => ( + + {item.label} + + )} + + ); + }, +}; + +export const WithIcons: Story = { + render: () => { + const carriers = [ + { key: 1, label: "UPS", icon: UPSLogoImg }, + { key: 2, label: "FedEx", icon: FedExLogoImg }, + ] as const satisfies Item[]; + const [selectedItems, setSelectedItems] = React.useState([ + carriers[0], + ]); + const { contains } = useFilter({ sensitivity: "base" }); + const filter = useCallback( + (item: Item, filterText: string) => contains(item.label, filterText), + [contains], + ); + const list = useListData({ + initialSelectedKeys: selectedItems.map((i) => i.key), + initialItems: carriers, + filter, + }); + return ( + ( + + )} + > + {(item) => ( + + + {item.icon && } + {item.label} + + + )} + + ); + }, +}; + +export const DisabledKeys: Story = { + render: () => { + const [selectedItems, setSelectedItems] = React.useState([ + fruits[0], + ]); + const { contains } = useFilter({ sensitivity: "base" }); + const filter = useCallback( + (item: Item, filterText: string) => contains(item.label, filterText), + [contains], + ); + const list = useListData({ + initialSelectedKeys: selectedItems.map((i) => i.key), + initialItems: fruits, + filter, + }); + return ( + item.key)} + selectedItems={selectedItems} + onSelectionChange={setSelectedItems} + placeholder="Select a fruit" + maxItemsUntilScroll={10} + renderPill={(item) => ( + + )} + > + {(item) => ( + + + {item.icon && } + {item.label} + + + )} + + ); + }, +}; + +export const MaxItemsUntilScroll: Story = { + render: () => { + const [selectedItems, setSelectedItems] = React.useState([ + fruits[0], + ]); + const { contains } = useFilter({ sensitivity: "base" }); + const filter = useCallback( + (item: Item, filterText: string) => contains(item.label, filterText), + [contains], + ); + const list = useListData({ + initialSelectedKeys: selectedItems.map((i) => i.key), + initialItems: fruits, + filter, + }); + return ( + item.key)} + selectedItems={selectedItems} + onSelectionChange={setSelectedItems} + placeholder="Select a fruit" + maxItemsUntilScroll={4} + renderPill={(item) => ( + + )} + > + {(item) => ( + + + {item.icon && } + {item.label} + + + )} + + ); + }, +}; diff --git a/easy-ui-react/src/MultiSelect/MultiSelect.test.tsx b/easy-ui-react/src/MultiSelect/MultiSelect.test.tsx new file mode 100644 index 00000000..2b8a223e --- /dev/null +++ b/easy-ui-react/src/MultiSelect/MultiSelect.test.tsx @@ -0,0 +1,137 @@ +import { getByRole, screen } from "@testing-library/react"; +import { UserEvent } from "@testing-library/user-event"; +import React, { useCallback, useState } from "react"; +import { vi } from "vitest"; +import { mockGetComputedStyle, render, userClick } from "../utilities/test"; +import { Item, MultiSelect, useFilter, useListData } from "./MultiSelect"; + +describe("", () => { + let restoreGetComputedStyle: () => void; + + beforeEach(() => { + restoreGetComputedStyle = mockGetComputedStyle(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + restoreGetComputedStyle(); + }); + + it("should render a ComboBox", async () => { + render(getMultiSelect()); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should open dropdown with arrow button", async () => { + const { user } = render(getMultiSelect()); + await userClick(user, screen.getByText("Open dropdown")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + it("should filter dropdown with input interaction", async () => { + const { user } = render(getMultiSelect()); + await user.type(screen.getByRole("combobox"), "ban"); + expect(screen.getByRole("option", { name: "Banana" })).toBeInTheDocument(); + }); + + it("should select item", async () => { + const { user } = render(getMultiSelect()); + await userClick(user, screen.getByText("Open dropdown")); + const options = screen.getAllByRole("option"); + await userClick(user, options[1]); + expect(getSelectedItem("Banana")).toBeInTheDocument(); + }); + + it("should remove selected item", async () => { + const { user } = render( + getMultiSelect({ + initialSelectedItems: [fruits[0]], + }), + ); + await userClick(user, screen.getByText("Open dropdown")); + expect(getSelectedItem("Apple")).toBeInTheDocument(); + await clearSelectedItem(user, "Apple"); + expect(screen.queryByText("No selected items")).toBeInTheDocument(); + }); +}); + +const fruits = [ + { key: 1, label: "Apple" }, + { key: 2, label: "Banana" }, + { key: 3, label: "Cherry" }, + { key: 4, label: "Date" }, + { key: 5, label: "Elderberry" }, + { key: 6, label: "Fig" }, + { key: 7, label: "Grape" }, + { key: 8, label: "Honeydew" }, + { key: 9, label: "Kiwi" }, + { key: 10, label: "Lemon" }, + { key: 11, label: "Mango" }, + { key: 12, label: "Nectarine" }, + { key: 13, label: "Orange" }, + { key: 14, label: "Papaya" }, + { key: 15, label: "Quince" }, + { key: 16, label: "Raspberry" }, + { key: 17, label: "Strawberry" }, + { key: 18, label: "Tangerine" }, + { key: 19, label: "Ugli Fruit" }, + { key: 20, label: "Watermelon" }, +] as const satisfies Item[]; + +const getMultiSelect = ({ + initialSelectedItems = [], +}: { + initialSelectedItems?: Item[]; +} = {}) => { + function MultiSelectTest() { + const [selectedItems, setSelectedItems] = + useState(initialSelectedItems); + const { contains } = useFilter({ sensitivity: "base" }); + const filter = useCallback( + (item: Item, filterText: string) => contains(item.label, filterText), + [contains], + ); + const list = useListData({ + initialSelectedKeys: selectedItems.map((i) => i.key), + initialItems: fruits, + filter, + }); + return ( + ( + + )} + > + {(item) => ( + + {item.label} + + )} + + ); + } + return ; +}; + +function getSelectedItem(name: string) { + const selectedItems = screen.getByLabelText("Selected items"); + return getByRole(selectedItems, "row", { name, hidden: true }); +} + +async function clearSelectedItem(user: UserEvent, name: string) { + await userClick( + user, + getByRole(getSelectedItem(name), "button", { + name: /remove/i, + hidden: true, + }), + ); +} diff --git a/easy-ui-react/src/MultiSelect/MultiSelect.tsx b/easy-ui-react/src/MultiSelect/MultiSelect.tsx new file mode 100644 index 00000000..5e5ee417 --- /dev/null +++ b/easy-ui-react/src/MultiSelect/MultiSelect.tsx @@ -0,0 +1,302 @@ +import KeyboardArrowDownIcon from "@easypost/easy-ui-icons/KeyboardArrowDown"; +import React, { KeyboardEvent, useRef, useState } from "react"; +import { Key, useFilter, VisuallyHidden } from "react-aria"; +import { Button, ComboBox, ComboBoxProps, Input } from "react-aria-components"; +import { AsyncListData, useAsyncList, useListData } from "react-stately"; +import { Icon } from "../Icon"; +import { MenuOverlayProps } from "../Menu/MenuOverlay"; +import { DEFAULT_MAX_ITEMS_UNTIL_SCROLL } from "../Menu/utilities"; +import { PillGroup, PillProps } from "../PillGroup"; +import { + MultiSelectDropdown, + MultiSelectDropdownOption, + MultiSelectDropdownOptionText, +} from "./MultiSelectDropdown"; +import { useElementWidth } from "./utilities"; + +import styles from "./MultiSelect.module.scss"; +import { Text } from "../Text"; + +export type Item = { key: Key } & PillProps; + +export type MultiSelectProps = { + /** + * The children to render inside the multi-select component. This can either be a direct + * React node or a function that takes an item from the provided collection and returns a React node. + */ + children: React.ReactNode | ((item: T) => React.ReactNode); + + /** + * A list of keys representing disabled items in the dropdown. These items will be + * non-selectable in the multi-select input. + */ + disabledKeys?: ComboBoxProps["disabledKeys"]; + + /** + * The list of items to display in the dropdown. These items are typically passed down + * from a ComboBox component and represent the available options for selection. + */ + dropdownItems: ComboBoxProps["items"]; + + /** + * The current input value in the multi-select input field. This is used to filter or search + * for items within the dropdown list. + */ + inputValue: ComboBoxProps["inputValue"]; + + /** + * A boolean flag that indicates whether the dropdown is in a loading state. + * When true, the dropdown will display a loading spinner or indicator. + */ + isLoading?: AsyncListData["isLoading"]; + + /** + * The maximum number of items to show before the dropdown becomes scrollable. + * This value is passed down from the MenuOverlay component and controls the + * appearance of the dropdown when there are too many items to display. + */ + maxItemsUntilScroll?: MenuOverlayProps["maxItemsUntilScroll"]; + + /** + * Handler that is triggered whenever the input value changes. It is typically used to + * filter or update the available dropdown items based on the input. + */ + onInputChange: ComboBoxProps["onInputChange"]; + + /** + * A callback handler that is invoked when the selection changes. It receives the updated + * list of selected items and is typically used to manage state or trigger other side effects. + */ + onSelectionChange: (items: T[]) => void; + + /** + * Placeholder text that appears when no items are selected in the multi-select input. + */ + placeholder?: string; + + /** + * Function that renders a "pill" or tag-like element for each selected item in the multi-select. + * This is useful for custom rendering of selected items. + */ + renderPill: (item: T) => React.ReactNode; + + /** + * The currently selected items in the collection. This is a controlled value that reflects + * the currently selected items. + */ + selectedItems: T[]; +}; + +/** + * An input and dropdown that allows users to select multiple options from a list. + * + * @privateRemarks + * Some inspiration from + * https://github.com/irsyadadl/justd/blob/2.x/components/ui/multiple-select.tsx + * Note that there are some limitations to this component until React Aria + * builds a dedicated component: + * https://github.com/adobe/react-spectrum/issues/2140 + * + * @example + * _Sync list:_ + * ```tsx + * const [selectedItems, setSelectedItems] = React.useState([]); + * const { contains } = useFilter({ sensitivity: "base" }); + * const filter = useCallback( + * (item: Item, filterText: string) => contains(item.label, filterText), + * [contains], + * ); + * const list = useListData({ + * initialSelectedKeys: [], + * initialItems: [{ key: 1, label: "Apple" }, { key: 2, label: "Banana" }], + * filter, + * }); + * + * item.key)} + * selectedItems={selectedItems} + * onSelectionChange={setSelectedItems} + * placeholder="Select a fruit" + * maxItemsUntilScroll={10} + * renderPill={(item) => ( + * + * )} + * > + * {(item) => ( + * + * {item.label} + * + * )} + * + * ``` + */ +export function MultiSelect(props: MultiSelectProps) { + const { + children, + disabledKeys, + dropdownItems = [], + inputValue, + isLoading, + maxItemsUntilScroll = DEFAULT_MAX_ITEMS_UNTIL_SCROLL, + onInputChange = () => {}, + onSelectionChange, + placeholder, + renderPill, + selectedItems, + } = props; + + const rootRef = useRef(null); + const clearComboBoxButtonRef = useRef(null); + const menuRef = useRef(null); + + // ComboBox needs a temporary selected key to handle changes properly + const [tempSelectedKey, setTempSelectedKey] = useState(null); + + const rootWidth = useElementWidth(rootRef); + + const onRemoveSelectedItem = (keys: Set) => { + const key = keys.values().next().value; + if (key) { + onSelectionChange(selectedItems.filter((k) => k.key !== key)); + onInputChange(""); + setTempSelectedKey(null); + } + }; + + const onComboBoxSelectionChange = (key: Key | null) => { + if (!key) { + return; + } + + const item = [...dropdownItems].find((i) => i.key === key); + if (!item) { + return; + } + + if (!selectedItems.map((i) => i.key).includes(key)) { + onSelectionChange([...selectedItems, item]); + setTempSelectedKey(key); + } + + onInputChange(""); + }; + + const onComboBoxInputChange = (value: string) => { + onInputChange(value); + setTempSelectedKey((prevSelectedKey) => + value === "" ? null : prevSelectedKey, + ); + }; + + const onComboBoxInputKeyDownCapture = ( + e: KeyboardEvent, + ) => { + if (e.key === "Backspace" && inputValue === "") { + if (selectedItems.length === 0) { + return; + } + + if (selectedItems.length > 0) { + const endItem = selectedItems[selectedItems.length - 1]; + onSelectionChange(selectedItems.filter((k) => k.key !== endItem.key)); + } + + onInputChange(""); + setTempSelectedKey(null); + } + }; + + const onComboBoxInputBlur = () => { + onInputChange(""); + setTempSelectedKey(null); + }; + + return ( +
+ {selectedItems.length > 0 ? ( + + {renderPill} + + ) : ( + + No selected items + + )} +
+ +
+ + +
+ + {children} + +
+ +
+
+ ); +} + +/** + * Represents a selected item as pill in a ``. + */ +MultiSelect.Pill = PillGroup.Pill; + +/** + * Represents a dropdown option in a ``. + */ +MultiSelect.Option = MultiSelectDropdownOption; + +/** + * Represents the default text in a dropdown option in a ``. + */ +MultiSelect.OptionText = MultiSelectDropdownOptionText; + +export { useAsyncList, useFilter, useListData }; +export type { Key }; diff --git a/easy-ui-react/src/MultiSelect/MultiSelectDropdown.module.scss b/easy-ui-react/src/MultiSelect/MultiSelectDropdown.module.scss new file mode 100644 index 00000000..ef935ebe --- /dev/null +++ b/easy-ui-react/src/MultiSelect/MultiSelectDropdown.module.scss @@ -0,0 +1,66 @@ +@use "../styles/common" as *; +@use "../Menu/mixins" as Menu; + +.popover { + @include Menu.tokens; + + display: flex; + flex-direction: column; + max-width: none; + + background: component-token("menu", "color.background"); + border: design-token("shape.border_width.1") solid + component-token("menu", "color.border"); + border-radius: component-token("menu", "border_radius"); + box-shadow: component-token("menu", "shadow"); +} + +.menu { + overflow: auto; + padding: component-token("menu", "padding.y") 0; +} + +.option, +.listEmptyState { + display: flex; + flex-direction: column; + justify-content: center; + height: component-token("menu", "item_height"); + padding: 0 component-token("menu", "padding.x"); +} + +.option { + position: relative; + cursor: pointer; + + &[data-hovered], + &[data-focused] { + background: component-token("menu", "color.background.hovered"); + } + + &[data-disabled] { + opacity: 0.5; + cursor: default; + } +} + +.listEmptyState { + align-items: center; +} + +.spinnerContainer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: component-token("menu", "padding.y") 0; +} + +.spinner { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: component-token("menu", "item_height"); + padding: 0 component-token("menu", "padding.x"); +} diff --git a/easy-ui-react/src/MultiSelect/MultiSelectDropdown.tsx b/easy-ui-react/src/MultiSelect/MultiSelectDropdown.tsx new file mode 100644 index 00000000..dfa904d0 --- /dev/null +++ b/easy-ui-react/src/MultiSelect/MultiSelectDropdown.tsx @@ -0,0 +1,118 @@ +import React, { ReactNode, RefObject } from "react"; +import { + ListBox, + ListBoxItem, + ListBoxItemProps, + Popover, +} from "react-aria-components"; +import { AsyncListData } from "react-stately"; +import { MenuOverlayProps } from "../Menu/MenuOverlay"; +import { + DEFAULT_MAX_ITEMS_UNTIL_SCROLL, + getMenuPopoverMaxHeight, + ITEM_HEIGHT, + Y_PADDING_INSIDE_OVERLAY, +} from "../Menu/utilities"; +import { Spinner } from "../Spinner"; +import { Text, TextProps } from "../Text"; +import { getComponentToken, pxToRem } from "../utilities/css"; +import { useScrollbar } from "../utilities/useScrollbar"; +import { Item } from "./MultiSelect"; + +import styles from "./MultiSelectDropdown.module.scss"; + +type MultiSelectDropdownProps = { + children: React.ReactNode | ((item: T) => React.ReactNode); + isLoading?: AsyncListData["isLoading"]; + maxItemsUntilScroll?: MenuOverlayProps["maxItemsUntilScroll"]; + menuRef: RefObject; + triggerRef: RefObject; + width: number; +}; + +type MultiSelectDropdownOptionProps = { + children: React.ReactNode; +} & ListBoxItemProps; + +export function MultiSelectDropdown( + props: MultiSelectDropdownProps, +) { + const { + children, + maxItemsUntilScroll = DEFAULT_MAX_ITEMS_UNTIL_SCROLL, + isLoading, + triggerRef, + menuRef, + width, + } = props; + + const style = { + width, + ...getComponentToken("menu", "item_height", `${pxToRem(ITEM_HEIGHT)}rem`), + ...getComponentToken( + "menu", + "padding.y", + `${pxToRem(Y_PADDING_INSIDE_OVERLAY)}rem`, + ), + }; + + return ( + + {isLoading ? ( +
+
+ +
+
+ ) : ( + + ( +
+ + No results found + +
+ )} + selectionMode="multiple" + > + {children} +
+
+ )} +
+ ); +} + +export function MultiSelectDropdownOption( + props: MultiSelectDropdownOptionProps, +) { + return ; +} + +export function MultiSelectDropdownOptionText(props: TextProps) { + return ; +} + +function ScrollContainer({ + scrollRef, + children, +}: { + scrollRef: RefObject; + children: ReactNode; +}) { + useScrollbar(scrollRef, "ezui-os-theme-overlay"); + return ( +
+ {children} +
+ ); +} diff --git a/easy-ui-react/src/MultiSelect/index.ts b/easy-ui-react/src/MultiSelect/index.ts new file mode 100644 index 00000000..22ee6bcf --- /dev/null +++ b/easy-ui-react/src/MultiSelect/index.ts @@ -0,0 +1 @@ +export * from "./MultiSelect"; diff --git a/easy-ui-react/src/MultiSelect/utilities.ts b/easy-ui-react/src/MultiSelect/utilities.ts new file mode 100644 index 00000000..752d3868 --- /dev/null +++ b/easy-ui-react/src/MultiSelect/utilities.ts @@ -0,0 +1,18 @@ +import { useResizeObserver } from "@react-aria/utils"; +import { RefObject, useState } from "react"; + +export function useElementWidth(elementRef: RefObject) { + const [width, setWidth] = useState(0); + + useResizeObserver({ + ref: elementRef, + onResize() { + if (elementRef.current) { + const { width } = elementRef.current.getBoundingClientRect(); + setWidth(width); + } + }, + }); + + return width; +} diff --git a/easy-ui-react/src/utilities/storybook.tsx b/easy-ui-react/src/utilities/storybook.tsx index 38b1245b..9cd31c95 100644 --- a/easy-ui-react/src/utilities/storybook.tsx +++ b/easy-ui-react/src/utilities/storybook.tsx @@ -334,6 +334,15 @@ export function StripeLogo(props: SVGProps) { ); } +export function UPSLogoImg(props: ComponentProps<"img">) { + return ( + + ); +} + export function FedExLogoImg(props: ComponentProps<"img">) { return (