) : null}
@@ -172,7 +172,7 @@ export function ResourceCollectionViewport({
viewportRef={viewportRef}
viewportProps={{
id: scrollId,
- className: cn("overscroll-contain pr-3", contentClassName),
+ className: cn("overscroll-contain md:pr-3", contentClassName),
"data-resource-collection-scroll": true,
}}
>
@@ -180,7 +180,7 @@ export function ResourceCollectionViewport({
{footer ? (
{footer}
diff --git a/packages/shared-ui/src/components/ui/resource/toolbar.tsx b/packages/shared-ui/src/components/ui/resource/toolbar.tsx
index 6a9ad57420b..63e477db2bb 100644
--- a/packages/shared-ui/src/components/ui/resource/toolbar.tsx
+++ b/packages/shared-ui/src/components/ui/resource/toolbar.tsx
@@ -1,5 +1,12 @@
-import { Fragment, useState, type ReactNode } from "react";
-import { Button } from "../button";
+import {
+ Fragment,
+ forwardRef,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { Button, type ButtonProps } from "../button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -12,6 +19,7 @@ import {
} from "../dropdown-menu";
import { Icon, type IconName } from "../icon";
import { Input } from "../input";
+import { useIsCompactViewport } from "../hooks/use-compact-viewport";
import {
Tooltip,
TooltipContent,
@@ -26,36 +34,287 @@ export function ResourceToolbar({
searchLabel,
onSearchChange,
controls,
+ combinedControls,
action,
+ compact = false,
+ expandSearchOnFocus = false,
}: {
searchValue: string;
searchPlaceholder: string;
searchLabel?: string;
onSearchChange: (value: string) => void;
controls?: ReactNode;
+ combinedControls?: ReactNode;
action?: ReactNode;
+ compact?: boolean;
+ expandSearchOnFocus?: boolean;
}) {
+ const isCompactViewport = useIsCompactViewport();
+ const toolbarRef = useRef
(null);
+ const controlsRef = useRef(null);
+ const individualControlsRef = useRef(null);
+ const searchRef = useRef(null);
+ const searchInputRef = useRef(null);
+ const searchButtonRef = useRef(null);
+ const restoreSearchFocus = useRef(false);
+ const actionRef = useRef(null);
+ const [combined, setCombined] = useState(false);
+ const [searchCondensed, setSearchCondensed] = useState(false);
+ const [searchExpanded, setSearchExpanded] = useState(false);
+ const restoreControlFocus = useRef(false);
+ const combinedRef = useRef(false);
+ const hasCombinedControls = Boolean(combinedControls);
+ const showCombined = compact && hasCombinedControls && combined;
+ const showSearchButton = searchCondensed && !searchExpanded;
+
+ const collapseSearch = () => {
+ restoreSearchFocus.current = searchCondensed;
+ searchInputRef.current?.blur();
+ setSearchExpanded(false);
+ };
+
+ useLayoutEffect(() => {
+ if (searchExpanded) {
+ searchInputRef.current?.focus();
+ searchInputRef.current?.select();
+ } else if (restoreSearchFocus.current) {
+ searchButtonRef.current?.focus();
+ restoreSearchFocus.current = false;
+ }
+ }, [searchExpanded]);
+
+ useLayoutEffect(() => {
+ const toolbar = toolbarRef.current;
+ const individualControls = individualControlsRef.current;
+ const search = searchRef.current;
+ if (!toolbar || !individualControls || !search || !compact) return;
+ let previousWidth = 0;
+ const measure = () => {
+ const width = toolbar.getBoundingClientRect().width;
+ if (width === 0) return;
+ const widthChanged = previousWidth !== width;
+ previousWidth = width;
+ if (
+ !widthChanged &&
+ controlsRef.current?.querySelector('[aria-haspopup][data-state="open"]')
+ )
+ return;
+ const gap = Number.parseFloat(getComputedStyle(toolbar).columnGap) || 0;
+ const searchWidth = Number.parseFloat(getComputedStyle(search).flexBasis);
+ const actionWidth = actionRef.current?.getBoundingClientRect().width ?? 0;
+ const requiredWidth =
+ searchWidth +
+ individualControls.getBoundingClientRect().width +
+ actionWidth +
+ gap * (actionRef.current ? 2 : 1);
+ const next = hasCombinedControls && width < requiredWidth;
+ const controlsWidth = next
+ ? (controlsRef.current
+ ?.querySelector("[data-resource-combined-controls]")
+ ?.getBoundingClientRect().width ?? 0)
+ : individualControls.getBoundingClientRect().width;
+ setSearchCondensed(
+ expandSearchOnFocus &&
+ (next ||
+ width -
+ controlsWidth -
+ actionWidth -
+ gap * (actionRef.current ? 2 : 1) <
+ searchWidth),
+ );
+ if (combinedRef.current === next) return;
+ restoreControlFocus.current = Boolean(
+ controlsRef.current?.contains(document.activeElement) ||
+ controlsRef.current?.querySelector('[data-state="open"]'),
+ );
+ combinedRef.current = next;
+ setCombined(next);
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(toolbar);
+ observer.observe(individualControls);
+ if (controlsRef.current) observer.observe(controlsRef.current);
+ if (actionRef.current) observer.observe(actionRef.current);
+ let frame = 0;
+ const menuObserver = new MutationObserver(() => {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(measure);
+ });
+ if (controlsRef.current)
+ menuObserver.observe(controlsRef.current, {
+ attributes: true,
+ attributeFilter: ["data-state"],
+ subtree: true,
+ });
+ return () => {
+ observer.disconnect();
+ menuObserver.disconnect();
+ cancelAnimationFrame(frame);
+ };
+ }, [
+ compact,
+ expandSearchOnFocus,
+ hasCombinedControls,
+ showCombined,
+ ]);
+
+ useLayoutEffect(() => {
+ if (!restoreControlFocus.current) return;
+ controlsRef.current
+ ?.querySelector(
+ showCombined ? "[data-resource-combined-controls] button" : "button",
+ )
+ ?.focus();
+ restoreControlFocus.current = false;
+ }, [showCombined]);
+
return (
-
-
-
- onSearchChange(event.target.value)}
- placeholder={searchPlaceholder}
- aria-label={searchLabel ?? searchPlaceholder}
- className="h-8 pl-8"
- />
-
+
+
{controls ? (
-
{controls}
+
+
+ {showCombined ? (
+
+ {combinedControls}
+
+ ) : null}
+
) : null}
{action ? (
-
+
{action}
) : null}
@@ -117,43 +376,83 @@ const RESOURCE_MENU_TRIGGER_ENGAGED_CLASS =
const RESOURCE_MENU_TRIGGER_RESTING_CLASS = "border border-input bg-background";
-function ResourceMenuTrigger({
- label,
- icon,
- active = false,
- open = false,
- tooltip = label,
-}: {
- label: string;
- icon: IconName;
- active?: boolean;
- open?: boolean;
- tooltip?: ReactNode;
-}) {
+export const ResourceControlButton = forwardRef<
+ HTMLButtonElement,
+ ButtonProps & {
+ label: string;
+ icon?: IconName;
+ active?: boolean;
+ open?: boolean;
+ tooltip?: ReactNode;
+ text?: string;
+ count?: number;
+ trailingIcon?: IconName;
+ }
+>(function ResourceControlButton(
+ {
+ label,
+ icon,
+ active = false,
+ open = false,
+ tooltip = label,
+ text,
+ count,
+ trailingIcon,
+ className,
+ ...props
+ },
+ ref,
+) {
return (
-
-
-
+
{tooltip}
);
+});
+
+function ResourceMenuTrigger(
+ props: React.ComponentProps
,
+) {
+ return (
+
+
+
+ );
}
function nextSelectedValues(
@@ -171,6 +470,74 @@ function nextSelectedValues(
return [...next];
}
+export function ResourceMultiSelectMenuItems({
+ label,
+ selectedValues,
+ options,
+ onChange,
+ compact = false,
+ clearInFooter = false,
+ showHeading = true,
+}: {
+ label: string;
+ selectedValues: readonly string[];
+ options: readonly ResourceOption[];
+ onChange: (values: string[]) => void;
+ compact?: boolean;
+ clearInFooter?: boolean;
+ showHeading?: boolean;
+}) {
+ const selected = new Set(selectedValues);
+ function updateValue(option: ResourceOption, checked: boolean) {
+ const next = nextSelectedValues(option, checked, selectedValues);
+ if (next !== null) onChange(next);
+ }
+ return (
+ <>
+ {showHeading ? (
+
+ {label}
+
+ ) : null}
+ {options.map((option) => (
+ event.preventDefault()}
+ onCheckedChange={(checked) => updateValue(option, checked === true)}
+ >
+
+
+ ))}
+ {clearInFooter ? (
+ <>
+
+ {
+ event.preventDefault();
+ onChange([]);
+ }}
+ className={cn(
+ "text-xs text-muted-foreground",
+ compact && "md:px-1.5 md:py-1",
+ )}
+ >
+ Clear filter
+
+ >
+ ) : null}
+ >
+ );
+}
+
export function ResourceMultiSelectMenu({
label,
icon,
@@ -178,37 +545,42 @@ export function ResourceMultiSelectMenu({
options,
onChange,
compact = false,
+ clearInFooter = false,
+ showLabel = false,
+ showHeading = true,
}: {
label: string;
- icon: IconName;
+ icon?: IconName;
selectedValues: readonly string[];
options: readonly ResourceOption[];
onChange: (values: string[]) => void;
compact?: boolean;
+ clearInFooter?: boolean;
+ showLabel?: boolean;
+ showHeading?: boolean;
}) {
const [open, setOpen] = useState(false);
const selected = new Set(selectedValues);
const activeOptions = options.filter((option) => selected.has(option.id));
const activeSelectedCount = activeOptions.length;
const selectionSummary =
- activeSelectedCount === 0 ? "All" : `${activeSelectedCount} selected`;
+ activeSelectedCount === 0
+ ? "All"
+ : activeOptions.map((option) => option.label).join(", ");
const triggerLabel =
activeSelectedCount === 0
? label
: `${label}: ${activeSelectedCount} selected`;
const triggerTooltip = `${label}: ${selectionSummary}`;
- function updateValue(option: ResourceOption, checked: boolean) {
- const next = nextSelectedValues(option, checked, selectedValues);
- if (next === null) return;
- onChange(next);
- }
-
return (
0}
open={open}
tooltip={triggerTooltip}
@@ -218,26 +590,15 @@ export function ResourceMultiSelectMenu({
mobileTitle={label}
className={cn(compact ? "w-max max-w-64 md:p-0.5" : "min-w-44")}
>
-
- {label}
-
- {options.map((option) => (
- event.preventDefault()}
- onCheckedChange={(checked) => updateValue(option, checked === true)}
- >
-
-
- ))}
+
);
@@ -296,7 +657,6 @@ export function ResourceFilterMenu({
{renderedGroups.map(({ group, selected }, groupIndex) => (
{groupIndex > 0 ? : null}
- {}
void;
placeholderLabel?: string;
compact?: boolean;
+ clearInFooter?: boolean;
+ showHeading?: boolean;
}) {
- const [open, setOpen] = useState(false);
- const selectedOption = options.find((option) => option.id === value);
- const directionLabel = direction === "asc" ? "ascending" : "descending";
- const sortStateLabel =
- selectedOption === undefined
- ? `Sort: ${placeholderLabel}`
- : selectedOption.omitDirection === true
- ? `Sort: ${selectedOption.label}`
- : `Sort: ${selectedOption.label}, ${directionLabel}`;
-
return (
-
- {}
-
-
+ <>
+ {showHeading ? (
- Sort by
+ Sort
- {onClear === undefined ? null : (
+ ) : null}
+ {onClear === undefined || clearInFooter ? null : (
+ {
+ event.preventDefault();
+ onClear();
+ }}
+ className={cn(
+ "flex items-center justify-between gap-3",
+ compact && "md:gap-2 md:px-1.5 md:py-1",
+ )}
+ >
+ {placeholderLabel}
+
+
+ )}
+ {options.map((option) => {
+ const selected = option.id === value;
+ return (
{
event.preventDefault();
- onClear();
+ if (option.disabled) return;
+ onChange(option.id);
}}
className={cn(
"flex items-center justify-between gap-3",
compact && "md:gap-2 md:px-1.5 md:py-1",
)}
>
- {placeholderLabel}
+
- )}
- {options.map((option) => {
- const selected = option.id === value;
- return (
- {
- event.preventDefault();
- if (option.disabled) return;
- onChange(option.id);
- }}
- className={cn(
- "flex items-center justify-between gap-3",
- compact && "md:gap-2 md:px-1.5 md:py-1",
- )}
- >
-
-
-
- );
- })}
+ );
+ })}
+ {clearInFooter && onClear !== undefined ? (
+ <>
+
+ {
+ event.preventDefault();
+ onClear();
+ }}
+ className={cn(
+ "text-xs text-muted-foreground",
+ compact && "md:px-1.5 md:py-1",
+ )}
+ >
+ Clear sort
+
+ >
+ ) : null}
+ >
+ );
+}
+
+export function ResourceSortMenu({
+ value,
+ direction,
+ options,
+ onChange,
+ onClear,
+ placeholderLabel = "Sort",
+ compact = false,
+ clearInFooter = false,
+ showLabel = false,
+ triggerIcon = "ArrowUpDown",
+ showHeading = true,
+ tooltip,
+}: {
+ value: string | null;
+ direction: "asc" | "desc";
+ options: readonly ResourceOption[];
+ onChange: (value: string) => void;
+ onClear?: () => void;
+ placeholderLabel?: string;
+ compact?: boolean;
+ clearInFooter?: boolean;
+ showLabel?: boolean;
+ triggerIcon?: IconName;
+ showHeading?: boolean;
+ tooltip?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const selectedOption = options.find((option) => option.id === value);
+ const directionLabel = direction === "asc" ? "ascending" : "descending";
+ const sortStateLabel =
+ selectedOption === undefined
+ ? `Sort: ${placeholderLabel}`
+ : selectedOption.omitDirection === true
+ ? `Sort: ${selectedOption.label}`
+ : `Sort: ${selectedOption.label}, ${directionLabel}`;
+
+ return (
+
+
+
+
);
@@ -470,34 +900,55 @@ export function ResourceCreateButton({
templateGroups,
menuActions = [],
onCreate,
+ compactWhenNarrow = false,
}: {
label: string;
templates: readonly ResourceCreateTemplate[];
templateGroups?: readonly ResourceCreateTemplateGroup[];
menuActions?: readonly ResourceCreateMenuAction[];
onCreate: (prompt?: string) => void;
+ compactWhenNarrow?: boolean;
}) {
const groups: readonly ResourceCreateTemplateGroup[] = templateGroups ?? [
{ label: "Examples", templates },
];
+ const createButton = (
+
+ );
return (
-
+ {compactWhenNarrow ? (
+
+
+ {createButton}
+ {label}
+
+
+ ) : (
+ createButton
+ )}