diff --git a/libs/@hashintel/ds-components/src/components/Filter/sort-menu.recipe.ts b/libs/@hashintel/ds-components/src/components/Filter/sort-menu.recipe.ts index 9566dbf7f15..1fe825fb9b4 100644 --- a/libs/@hashintel/ds-components/src/components/Filter/sort-menu.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Filter/sort-menu.recipe.ts @@ -55,54 +55,6 @@ export const menuContent = cva({ }, }); -export const searchRow = cva({ - base: { - display: "flex", - alignItems: "center", - gap: "1.5", - marginTop: - "[calc(-1 * (var(--spacing-1) + var(--selectable-list-padding-y)))]", - marginInline: - "[calc(-1 * (var(--selectable-list-padding-x) + var(--spacing-1)))]", - marginBottom: "0.5", - paddingInline: "[var(--selectable-list-padding-x)]", - paddingTop: "1.5", - paddingBottom: "1", - background: "neutral.s20", - borderBottom: "1px solid {colors.neutral.s50}", - }, -}); - -export const searchIcon = cva({ - base: { - color: "fg.muted", - flexShrink: "0", - }, -}); - -export const searchInput = cva({ - base: { - flex: "1", - minWidth: "0", - appearance: "none", - border: "none", - background: "[transparent]", - outline: "none", - padding: "0", - font: "[inherit]", - color: "[inherit]", - _placeholder: { color: "neutral.s80" }, - }, -}); - -export const searchEmpty = cva({ - base: { - display: "block", - color: "neutral.s90", - paddingBlock: "0.5", - }, -}); - export const triggerButton = cva({ base: { maxWidth: "[100%]", diff --git a/libs/@hashintel/ds-components/src/components/Filter/sort-menu.tsx b/libs/@hashintel/ds-components/src/components/Filter/sort-menu.tsx index 433d7eb04cf..e2be31ec672 100644 --- a/libs/@hashintel/ds-components/src/components/Filter/sort-menu.tsx +++ b/libs/@hashintel/ds-components/src/components/Filter/sort-menu.tsx @@ -9,6 +9,8 @@ import { } from "../Button/button"; import { Icon } from "../Icon/icon"; import { Menu, type MenuItem } from "../Menu/menu"; +import { SelectableListSearch } from "../Menu/SelectableList/selectable-list-search"; +import { searchEmpty } from "../Menu/SelectableList/selectable-list-search.recipe"; import { readSavedSort, type SortDirection, @@ -23,10 +25,6 @@ import { directionToggle, menuContent, placeholderLabel, - searchEmpty, - searchIcon, - searchInput, - searchRow, triggerButton, triggerDirectionToggle, triggerIcon, @@ -34,48 +32,6 @@ import { import type { DistributedOmit } from "type-fest"; -const SearchField = ({ - value, - onChange, -}: { - value: string; - onChange: (next: string) => void; -}) => { - const inputRef = useRef(null); - - // Focus when the (lazily mounted) dropdown opens. Double rAF so the focus - // lands after ark moves focus to the menu content (mirrors Filter). - useEffect(() => { - let cancelled = false; - const raf = requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (!cancelled) { - inputRef.current?.focus(); - } - }); - }); - return () => { - cancelled = true; - cancelAnimationFrame(raf); - }; - }, []); - - return ( -
- - onChange(event.currentTarget.value)} - placeholder="Search…" - aria-label="Search sort options" - /> -
- ); -}; - export const SortMenu = ({ items = [], value, @@ -279,7 +235,13 @@ export const SortMenu = ({ ? [ { id: "sort-menu-search", - custom: , + custom: ( + + ), }, ...sorterItems, ...(visibleSorters.length === 0 diff --git a/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list-search.recipe.ts b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list-search.recipe.ts new file mode 100644 index 00000000000..811c64f1a2b --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list-search.recipe.ts @@ -0,0 +1,62 @@ +import { cva } from "@hashintel/ds-helpers/css"; + +export const searchRow = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "1.5", + marginTop: + "[calc(-1 * (var(--spacing-1) + var(--selectable-list-padding-y)))]", + marginInline: + "[calc(-1 * (var(--selectable-list-padding-x) + var(--spacing-1)))]", + marginBottom: "0.5", + paddingInline: "[var(--selectable-list-padding-x)]", + paddingTop: "1.5", + paddingBottom: "1", + background: "neutral.s10", + borderBottom: "1px solid {colors.neutral.s35}", + // When the dropdown flips above the trigger, move the search to the + // bottom edge so it stays adjacent to the trigger (the list content is a + // flex column, so `order` relocates it without changing DOM order) + "[data-placement^='top'] &": { + order: "[1]", + marginTop: "0.5", + marginBottom: + "[calc(-1 * (var(--spacing-1) + var(--selectable-list-padding-y)))]", + paddingTop: "1", + paddingBottom: "1.5", + borderBottom: "none", + borderTop: "1px solid {colors.neutral.s35}", + }, + }, +}); + +export const searchIcon = cva({ + base: { + color: "fg.muted", + flexShrink: "0", + }, +}); + +export const searchInput = cva({ + base: { + flex: "1", + minWidth: "0", + appearance: "none", + border: "none", + background: "[transparent]", + outline: "none", + padding: "0", + font: "[inherit]", + color: "[inherit]", + _placeholder: { color: "neutral.s80" }, + }, +}); + +export const searchEmpty = cva({ + base: { + display: "block", + color: "neutral.s90", + paddingBlock: "0.5", + }, +}); diff --git a/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list-search.tsx b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list-search.tsx new file mode 100644 index 00000000000..b2d029c5a4a --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list-search.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from "react"; + +import { Icon } from "../../Icon/icon"; +import { + searchIcon, + searchInput, + searchRow, +} from "./selectable-list-search.recipe"; + +/** + * A search field to embed as a custom row at the top of a SelectableList + * (`{ custom: }`). It focuses itself when + * mounted — pair with a lazily mounted dropdown so focus lands when it opens + * (the double rAF lets ark move focus to the list content first). + */ +export const SelectableListSearch = ({ + value, + onChange, + placeholder = "Search…", + "aria-label": ariaLabel, +}: { + value: string; + onChange: (next: string) => void; + placeholder?: string; + "aria-label": string; +}) => { + const inputRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const raf = requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!cancelled) { + inputRef.current?.focus(); + } + }); + }); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + }; + }, []); + + return ( +
+ + onChange(event.currentTarget.value)} + placeholder={placeholder} + aria-label={ariaLabel} + /> +
+ ); +}; diff --git a/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.recipe.ts b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.recipe.ts index 17f528941cb..daaf6410783 100644 --- a/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.recipe.ts +++ b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.recipe.ts @@ -6,6 +6,15 @@ export const styles = sva({ slots: ["content", "group", "groupLabel", "emptyContainer", "customItem"], base: { content: { + // A flex column so rows can re-order themselves by placement (the + // search row moves to the bottom edge when the dropdown flips upward). + // Children must not shrink, or long lists would compress rows to fit + // maxHeight instead of scrolling. + display: "flex", + flexDirection: "column", + "& > *": { + flexShrink: "0", + }, backgroundColor: "white", border: "1px solid {colors.bd.subtle}", borderRadius: "lg", @@ -55,6 +64,12 @@ export const styles = sva({ width: "full", paddingX: "[var(--selectable-list-padding-x)]", paddingY: "[var(--selectable-list-padding-y)]", + // A search row moves to the bottom edge when the dropdown flips upward + "[data-placement^='top'] &": { + "&:has([data-selectable-list-search])": { + order: "[1]", + }, + }, }, }, variants: { diff --git a/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.tsx b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.tsx index 955f9961684..8e0b344bf74 100644 --- a/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.tsx +++ b/libs/@hashintel/ds-components/src/components/Menu/SelectableList/selectable-list.tsx @@ -119,7 +119,19 @@ const CustomRow = ({ item, ctx }: { item: CustomItem; ctx: RenderCtx }) => { className={classes.customItem} data-selectable-list-custom={getItemId(item)} onKeyDown={(event) => { - if (event.key !== "Tab" && event.key !== "Escape") { + // In a Select, arrows and Enter fall through to zag's content handler + // so the list highlight/selection can be driven from inside the row + // (zag already ignores typing from editable elements, but Space and + // Home/End would act on both the caret and the list, so they stay + // stopped along with everything else). + const passThrough = + event.key === "Tab" || + event.key === "Escape" || + (ctx.as === "Select" && + (event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Enter")); + if (!passThrough) { event.stopPropagation(); } }} diff --git a/libs/@hashintel/ds-components/src/components/Select/multi-select.stories.tsx b/libs/@hashintel/ds-components/src/components/Select/multi-select.stories.tsx new file mode 100644 index 00000000000..ac55527476b --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Select/multi-select.stories.tsx @@ -0,0 +1,313 @@ +import { useState } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; + +import { Select } from "./select"; + +import type { ItemOrGroup } from "../Menu/SelectableList/selectable-list"; +import type { MultiSelectItem, SelectItem } from "./select"; +import type { Story, StoryDefault } from "@ladle/react"; + +export default { + title: "Components/Select", +} satisfies StoryDefault; + +const sampleItems: Array> = [ + { value: "apple", text: "Apple" }, + { value: "banana", text: "Banana" }, + { value: "cherry", text: "Cherry" }, + { value: "date", text: "Date" }, +]; + +const noop = () => {}; + +const findItemText = ( + items: ReadonlyArray>, + value: string, +): string => { + for (const entry of items) { + if ("items" in entry) { + const found = entry.items.find((it) => it.value === value); + if (found) { + return found.text; + } + } else if (entry.value === value) { + return entry.text; + } + } + return value; +}; + +const sectionStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[32px]", + background: "neutral.s10", +}); + +const groupStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[12px]", +}); + +const subheadingStyle: React.CSSProperties = { + fontSize: 12, + fontWeight: 500, + color: "#666", +}; + +// Declared `as const` so a `Select` using these items can narrow `value` / +// `onChange` to the literal union of these values. +const colorItems = [ + { value: "red", text: "Red" }, + { value: "green", text: "Green" }, + { value: "blue", text: "Blue" }, + { value: "orange", text: "Orange" }, +] as const; + +type ColorValue = (typeof colorItems)[number]["value"]; + +const ColorSwatch = ({ value }: { value: string }) => ( +