Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔍 feat: Filter MultiSelect and SelectDropDown (+variants) + CSS fixes for Scrollbar #2138

Merged
merged 15 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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
4 changes: 2 additions & 2 deletions client/src/components/Nav/NewChat.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useNavigate } from 'react-router-dom';
import { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { useLocalize, useNewConvo, useLocalStorage } from '~/hooks';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import { NewChatIcon } from '~/components/svg';
import { getEndpointField } from '~/utils';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
import { useNavigate } from 'react-router-dom';

export default function NewChat({
toggleNav,
Expand Down
90 changes: 90 additions & 0 deletions client/src/components/ui/MultiSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React, { useState, useMemo, useCallback } from 'react';
import type { TPlugin } from 'librechat-data-provider';
import { Search } from 'lucide-react';
import { useLocalize } from '~/hooks';

// This is a generic that can be added to Menu and Select components

export default function MultiSearch({
value,
onChange,
placeholder,
}: {
value: string | null;
onChange: (filter: string) => void;
placeholder?: string;
}) {
const localize = useLocalize();
const onChangeHandler = useCallback((e) => onChange(e.target.value), []);

return (
<div className="sticky left-0 top-0 z-10 flex h-12 items-center gap-3 bg-white px-5 py-2.5 !pr-3 text-left transition-all dark:bg-gray-800">
<Search className="h-6 w-6 text-gray-500" />
<input
type="text"
value={value || ''}
onChange={onChangeHandler}
placeholder={placeholder || localize('com_ui_select_search_model')}
className="flex-1 px-2.5 py-1"
/>
</div>
);
}

/**
* Helper function that will take a multiSearch input
* @param node
*/
function defaultGetStringKey(node: unknown): string {
if (typeof node === 'string') {
return node.toUpperCase();
}
// This should be a noop, but it's here for redundancy
return '';
}

/**
* Hook for conditionally making a multi-element list component into a sortable component
* Returns a RenderNode for search input when search functionality is available
* @param availableOptions
* @param placeholder
* @param getTextKeyOverride
* @returns
*/
export function useMultiSearch<OptionsType extends unknown[]>(
availableOptions: OptionsType,
placeholder?: string,
getTextKeyOverride?: (node: OptionsType[0]) => string,
): [OptionsType, React.ReactNode] {
const [filterValue, setFilterValue] = useState<string | null>(null);

// We conditionally show the search when there's more than 10 elements in the menu
const shouldShowSearch = availableOptions.length > 10;

// Define the helper function used to enable search
// If this is invalidly described, we will assume developer error - tf. avoid rendering
const getTextKeyHelper = getTextKeyOverride || defaultGetStringKey;

// Iterate said options
const filteredOptions = useMemo(() => {
if (!shouldShowSearch || !filterValue || !availableOptions.length) {
// Don't render if available options aren't present, there's no filter active
return availableOptions;
}
// Filter through the values, using a simple text-based search
// nothing too fancy, but we can add a better search algo later if we need
const upperFilterValue = filterValue.toUpperCase();

return availableOptions.filter((value) =>
getTextKeyHelper(value).includes(upperFilterValue),
) as OptionsType;
}, [availableOptions, getTextKeyHelper, filterValue, shouldShowSearch]);

const onSearchChange = useCallback((nextFilterValue) => setFilterValue(nextFilterValue), []);

const searchRender = shouldShowSearch ? (
<MultiSearch value={filterValue} onChange={onSearchChange} placeholder={placeholder} />
) : null;

return [filteredOptions, searchRender];
}
15 changes: 13 additions & 2 deletions client/src/components/ui/MultiSelectDropDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Listbox, Transition } from '@headlessui/react';
import { Wrench, ArrowRight } from 'lucide-react';
import { CheckMark } from '~/components/svg';
import useOnClickOutside from '~/hooks/useOnClickOutside';
import { useMultiSearch } from './MultiSearch';
import { cn } from '~/utils/';
import type { TPlugin } from 'librechat-data-provider';

Expand Down Expand Up @@ -43,6 +44,13 @@ function MultiSelectDropDown({
setIsOpen(true);
};

// Detemine if we should to convert this component into a searchable select. If we have enough elements, a search
// input will appear near the top of the menu, allowing correct filtering of different model menu items. This will
// reset once the component is unmounted (as per a normal search)
const [filteredValues, searchRender] = useMultiSearch<TPlugin[]>(availableValues);
const hasSearchRender = Boolean(searchRender);
const options = hasSearchRender ? filteredValues : availableValues;

const transitionProps = { className: 'top-full mt-3' };
if (showAbove) {
transitionProps.className = 'bottom-full mb-3';
Expand Down Expand Up @@ -136,9 +144,12 @@ function MultiSelectDropDown({
>
<Listbox.Options
ref={menuRef}
className="absolute z-50 mt-2 max-h-60 w-full overflow-auto rounded bg-white text-base text-xs ring-1 ring-black/10 focus:outline-none dark:bg-gray-800 dark:ring-white/20 dark:last:border-0 md:w-[100%]"
className={cn(
'absolute z-50 mt-2 max-h-60 w-full overflow-auto rounded bg-white text-base text-xs ring-1 ring-black/10 focus:outline-none dark:bg-gray-800 dark:ring-white/20 dark:last:border-0 md:w-[100%]',
)}
>
{availableValues.map((option, i: number) => {
{searchRender}
{options.map((option, i: number) => {
if (!option) {
return null;
}
Expand Down
16 changes: 13 additions & 3 deletions client/src/components/ui/MultiSelectPop.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { Wrench } from 'lucide-react';
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
import type { TPlugin } from 'librechat-data-provider';
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
import { useMultiSearch } from './MultiSearch';
import { cn } from '~/utils/';

type SelectDropDownProps = {
Expand Down Expand Up @@ -35,6 +36,11 @@ function MultiSelectPop({
const title = _title;
const excludeIds = ['select-plugin', 'plugins-label', 'selected-plugins'];

// Detemine if we should to convert this component into a searchable select
const [filteredValues, searchRender] = useMultiSearch<TPlugin[]>(availableValues);
const hasSearchRender = Boolean(searchRender);
const options = hasSearchRender ? filteredValues : availableValues;

return (
<Root>
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
Expand Down Expand Up @@ -106,9 +112,13 @@ function MultiSelectPop({
<Content
side="bottom"
align="center"
className="mt-2 max-h-60 min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:text-white"
className={cn(
'mt-2 max-h-60 min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:text-white',
hasSearchRender && 'relative',
)}
>
{availableValues.map((option) => {
{searchRender}
{options.map((option) => {
if (!option) {
return null;
}
Expand Down
13 changes: 11 additions & 2 deletions client/src/components/ui/SelectDropDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Option } from '~/common';
import CheckMark from '../svg/CheckMark';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
import { useMultiSearch } from './MultiSearch';

type SelectDropDownProps = {
id?: string;
Expand Down Expand Up @@ -57,6 +58,13 @@ function SelectDropDown({
title = localize('com_ui_model');
}

// Detemine if we should to convert this component into a searchable select. If we have enough elements, a search
// input will appear near the top of the menu, allowing correct filtering of different model menu items. This will
// reset once the component is unmounted (as per a normal search)
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>(availableValues);
const hasSearchRender = Boolean(searchRender);
const options = hasSearchRender ? filteredValues : availableValues;

return (
<div className={cn('flex items-center justify-center gap-2 ', containerClassName ?? '')}>
<div className={cn('relative w-full', subContainerClassName ?? '')}>
Expand Down Expand Up @@ -122,7 +130,7 @@ function SelectDropDown({
>
<Listbox.Options
className={cn(
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-base text-xs ring-black/10 focus:outline-none dark:bg-gray-800 dark:ring-white/20 dark:border-gray-600 md:w-[100%]',
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded border bg-white text-base text-xs ring-black/10 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:ring-white/20 md:w-[100%]',
optionsListClass ?? '',
)}
>
Expand All @@ -138,7 +146,8 @@ function SelectDropDown({
{renderOption()}
</Listbox.Option>
)}
{availableValues.map((option: string | Option, i: number) => {
{searchRender}
{options.map((option: string | Option, i: number) => {
if (!option) {
return null;
}
Expand Down
16 changes: 14 additions & 2 deletions client/src/components/ui/SelectDropDownPop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
import type { Option } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
import { useMultiSearch } from './MultiSearch';

type SelectDropDownProps = {
id?: string;
Expand Down Expand Up @@ -42,6 +43,13 @@ function SelectDropDownPop({
title = localize('com_ui_model');
}

// Detemine if we should to convert this component into a searchable select. If we have enough elements, a search
// input will appear near the top of the menu, allowing correct filtering of different model menu items. This will
// reset once the component is unmounted (as per a normal search)
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>(availableValues);
const hasSearchRender = Boolean(searchRender);
const options = hasSearchRender ? filteredValues : availableValues;

return (
<Root>
<div className={'flex items-center justify-center gap-2 '}>
Expand Down Expand Up @@ -95,9 +103,13 @@ function SelectDropDownPop({
<Content
side="bottom"
align="start"
className="mt-2 max-h-[52vh] min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:text-white lg:max-h-[52vh]"
className={cn(
'mt-2 max-h-[52vh] min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:text-white lg:max-h-[52vh]',
hasSearchRender && 'relative',
)}
>
{availableValues.map((option) => {
{searchRender}
{options.map((option) => {
return (
<MenuItem
key={option}
Expand Down
1 change: 1 addition & 0 deletions client/src/localization/languages/Eng.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default {
com_ui_close: 'Close',
com_ui_model: 'Model',
com_ui_select_model: 'Select a model',
com_ui_select_search_model: 'Search model by name',
com_ui_use_prompt: 'Use prompt',
com_ui_prev: 'Prev',
com_ui_next: 'Next',
Expand Down
41 changes: 21 additions & 20 deletions client/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1083,42 +1083,43 @@ button {
padding: 0.25rem 0.5rem;
}

.bg-token-surface-secondary {
background-color: #f7f7f8;
background-color: var(--surface-secondary);
}

.from-token-surface-secondary {
--tw-gradient-from: var(--surface-secondary) var(--tw-gradient-from-position);
--tw-gradient-to: hsla(0,0%,100%,0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
}

/* Webkit scrollbar */
::-webkit-scrollbar {
height: 0.1em;
width: 0.5rem;
}

.scrollbar-trigger:hover ::-webkit-scrollbar-thumb {
visibility: hide;
}

::-webkit-scrollbar-thumb {
background-color: hsla(0,0%,100%,.1);
background-color: rgba(0, 0, 0, 0.1);
border-radius: 9999px;
}

.scrollbar-transparent::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1);
.dark ::-webkit-scrollbar-thumb {
background-color: hsla(0, 0%, 100%, 0.1);
}

.bg-token-surface-secondary {
background-color: #f7f7f8;
background-color: var(--surface-secondary);
::-webkit-scrollbar-track {
background-color: transparent;
border-radius: 9999px;
}

.from-token-surface-secondary {
--tw-gradient-from: var(--surface-secondary) var(--tw-gradient-from-position);
--tw-gradient-to: hsla(0,0%,100%,0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
.scrollbar-transparent::-webkit-scrollbar-thumb {
background-color: transparent;
}

::-webkit-scrollbar-track {
.dark .scrollbar-transparent::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: hsla(0,0%,100%,.3);

}

body,
Expand Down
7 changes: 6 additions & 1 deletion client/src/utils/cn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { twMerge } from 'tailwind-merge';
import { clsx } from 'clsx';

export default function cn(...inputs: string[]) {
/**
* Merges the tailwind clases (using twMerge). Conditionally removes false values
* @param inputs The tailwind classes to merge
* @returns className string to apply to an element or HOC
*/
export default function cn(...inputs: Array<string | boolean>) {
return twMerge(clsx(inputs));
}