File size: 4,698 Bytes
3201ca6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | import React from "react";
import {
UseComboboxGetMenuPropsOptions,
UseComboboxGetItemPropsOptions,
} from "downshift";
import { cn } from "#/utils/utils";
import { dropdownMenuListClassName } from "#/utils/dropdown-classes";
export interface GenericDropdownMenuProps<T> {
isOpen: boolean;
filteredItems: T[];
inputValue: string;
highlightedIndex: number;
selectedItem: T | null;
getMenuProps: <Options>(
options?: UseComboboxGetMenuPropsOptions & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
getItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<T> & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
onScroll?: (event: React.UIEvent<HTMLUListElement>) => void;
menuRef?: React.RefObject<HTMLUListElement | null>;
renderItem: (
item: T,
index: number,
highlightedIndex: number,
selectedItem: T | null,
getItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<T> & Options,
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
) => React.ReactNode;
/**
* Optional presentational node rendered immediately BEFORE an item (e.g. a
* group header). It is a sibling of the item, not part of downshift's `items`
* array, so it consumes no item index — mirroring the `numberOfRecentItems`
* divider. The consumer owns when a prefix appears (e.g. at group boundaries).
*/
renderItemPrefix?: (item: T, index: number) => React.ReactNode;
renderEmptyState: (inputValue: string) => React.ReactNode;
stickyTopItem?: React.ReactNode;
stickyFooterItem?: React.ReactNode;
testId?: string;
numberOfRecentItems?: number;
itemKey: (item: T) => string | number;
}
export function GenericDropdownMenu<T>({
isOpen,
filteredItems,
inputValue,
highlightedIndex,
selectedItem,
getMenuProps,
getItemProps,
onScroll,
menuRef,
renderItem,
renderItemPrefix,
renderEmptyState,
stickyTopItem,
stickyFooterItem,
testId,
numberOfRecentItems = 0,
itemKey,
}: GenericDropdownMenuProps<T>) {
const hasItems = filteredItems.length > 0;
const showEmptyState = !hasItems && !stickyTopItem && !stickyFooterItem;
// Always render the menu container (even when closed) so getMenuProps is always called
// This prevents the downshift warning about forgetting to call getMenuProps
if (!isOpen) {
return (
<div className="relative">
<ul
{...getMenuProps({
ref: menuRef,
className: "hidden",
"data-testid": testId,
})}
/>
</div>
);
}
return (
<div className="relative">
<div
className={cn(
"absolute z-10 w-full bg-tertiary border border-[var(--oh-border-input)] rounded-lg shadow-none",
"focus:outline-none mt-1 z-[9999]",
stickyTopItem || stickyFooterItem ? "max-h-60" : "max-h-60",
)}
>
<ul
{...getMenuProps({
ref: menuRef,
className: cn(
"w-full overflow-auto p-1 custom-scrollbar-always",
dropdownMenuListClassName,
stickyTopItem || stickyFooterItem
? "max-h-[calc(15rem-3rem)]"
: "max-h-60", // Reserve space for sticky items
),
onScroll,
"data-testid": testId,
})}
>
{showEmptyState ? (
renderEmptyState(inputValue)
) : (
<>
{stickyTopItem}
{filteredItems.map((item, index) => {
const key = itemKey(item);
return (
<React.Fragment key={key}>
{renderItemPrefix?.(item, index)}
{renderItem(
item,
index,
highlightedIndex,
selectedItem,
getItemProps,
)}
{numberOfRecentItems > 0 &&
index === numberOfRecentItems - 1 && (
<li
role="presentation"
aria-hidden="true"
className="border-b border-[var(--oh-border-input)] bg-tertiary pb-1 mb-1 h-[1px]"
/>
)}
</React.Fragment>
);
})}
</>
)}
</ul>
{stickyFooterItem && (
<div className="border-t border-[var(--oh-border-input)] bg-tertiary p-1 rounded-b-lg">
{stickyFooterItem}
</div>
)}
</div>
</div>
);
}
|