Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -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%]",
Expand Down
56 changes: 9 additions & 47 deletions libs/@hashintel/ds-components/src/components/Filter/sort-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,59 +25,13 @@ import {
directionToggle,
menuContent,
placeholderLabel,
searchEmpty,
searchIcon,
searchInput,
searchRow,
triggerButton,
triggerDirectionToggle,
triggerIcon,
} from "./sort-menu.recipe";

import type { DistributedOmit } from "type-fest";

const SearchField = ({
value,
onChange,
}: {
value: string;
onChange: (next: string) => void;
}) => {
const inputRef = useRef<HTMLInputElement>(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 (
<div className={searchRow()}>
<Icon name="search" size="sm" className={searchIcon()} />
<input
ref={inputRef}
type="text"
className={searchInput()}
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
placeholder="Search…"
aria-label="Search sort options"
/>
</div>
);
};

export const SortMenu = <SortKey extends string = string>({
items = [],
value,
Expand Down Expand Up @@ -279,7 +235,13 @@ export const SortMenu = <SortKey extends string = string>({
? [
{
id: "sort-menu-search",
custom: <SearchField value={search} onChange={setSearch} />,
custom: (
<SelectableListSearch
value={search}
onChange={setSearch}
aria-label="Search sort options"
/>
),
},
...sorterItems,
...(visibleSorters.length === 0
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
},
});
Original file line number Diff line number Diff line change
@@ -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: <SelectableListSearch ... /> }`). 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<HTMLInputElement>(null);

useEffect(() => {
let cancelled = false;
const raf = requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!cancelled) {
inputRef.current?.focus();
}
});
});
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
}, []);

return (
<div className={searchRow()} data-selectable-list-search="">
<Icon name="search" size="sm" className={searchIcon()} />
<input
ref={inputRef}
type="text"
className={searchInput()}
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
placeholder={placeholder}
aria-label={ariaLabel}
/>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}}
Expand Down
Loading
Loading