Skip to content

Commit bb83eb9

Browse files
committed
BREAKING: moving to ariakit with new CustomMenu
1 parent 6870945 commit bb83eb9

File tree

15 files changed

+1142
-1073
lines changed

15 files changed

+1142
-1073
lines changed

client/src/common/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ export interface ExtendedEndpoint {
504504
models?: string[];
505505
agentNames?: Record<string, string>;
506506
assistantNames?: Record<string, string>;
507+
modelIcons?: Record<string, string | undefined>;
507508
}
508509

509510
export interface ModelItemProps {

client/src/components/Chat/Header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
22
import { useOutletContext } from 'react-router-dom';
33
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
44
import type { ContextType } from '~/common';
5-
import { EndpointMenuDropdown } from './Menus/Endpoints/EndpointMenuDropdown';
5+
import ModelSelector from './Menus/Endpoints/ModelSelector';
66
import { PresetsMenu, HeaderNewChat } from './Menus';
77
import { useGetStartupConfig } from '~/data-provider';
88
import ExportAndShareMenu from './ExportAndShareMenu';
@@ -39,7 +39,7 @@ export default function Header() {
3939
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
4040
<div className="mx-2 flex items-center gap-2">
4141
{!navVisible && <HeaderNewChat />}
42-
{<EndpointMenuDropdown interfaceConfig={interfaceConfig} modelSpecs={modelSpecs} />}
42+
{<ModelSelector interfaceConfig={interfaceConfig} modelSpecs={modelSpecs} />}
4343
{<HeaderOptions interfaceConfig={interfaceConfig} />}
4444
{interfaceConfig.presets === true && <PresetsMenu />}
4545
{hasAccessToBookmarks === true && <BookmarkMenu />}

client/src/components/Chat/Landing.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useMemo, useCallback, useState, useEffect } from 'react';
22
import { EModelEndpoint } from 'librechat-data-provider';
33
import type * as t from 'librechat-data-provider';
4-
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
54
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
5+
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
66
import { BirthdayIcon, TooltipAnchor, SplitText } from '~/components';
77
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
88
import { useLocalize, useAuthContext } from '~/hooks';
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import * as Ariakit from '@ariakit/react';
2+
import { twMerge } from 'tailwind-merge';
3+
import * as React from 'react';
4+
import { SearchIcon } from 'lucide-react';
5+
6+
export interface CustomMenuProps extends Ariakit.MenuButtonProps<'div'> {
7+
label?: React.ReactNode;
8+
values?: Record<string, any>;
9+
onValuesChange?: (values: Record<string, any>) => void;
10+
searchValue?: string;
11+
onSearch?: (value: string) => void;
12+
combobox?: Ariakit.ComboboxProps['render'];
13+
trigger?: Ariakit.MenuButtonProps['render'];
14+
defaultOpen?: boolean;
15+
}
16+
17+
export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(function CustomMenu(
18+
{
19+
label,
20+
children,
21+
values,
22+
onValuesChange,
23+
searchValue,
24+
onSearch,
25+
combobox,
26+
trigger,
27+
defaultOpen,
28+
...props
29+
},
30+
ref,
31+
) {
32+
const parent = Ariakit.useMenuContext();
33+
const searchable = searchValue != null || !!onSearch || !!combobox;
34+
35+
const menuStore = Ariakit.useMenuStore({
36+
showTimeout: 100,
37+
placement: parent ? 'right' : 'left',
38+
defaultOpen: defaultOpen,
39+
});
40+
41+
const element = (
42+
<Ariakit.MenuProvider store={menuStore} values={values} setValues={onValuesChange}>
43+
<Ariakit.MenuButton
44+
ref={ref}
45+
{...props}
46+
className={twMerge(
47+
!parent &&
48+
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white',
49+
menuStore.useState('open')
50+
? 'bg-surface-tertiary hover:bg-surface-tertiary'
51+
: 'bg-surface-secondary hover:bg-surface-tertiary',
52+
props.className,
53+
)}
54+
render={parent ? <CustomMenuItem render={trigger} /> : trigger}
55+
>
56+
<span className="flex-1">{label}</span>
57+
<Ariakit.MenuButtonArrow className="stroke-1 text-base opacity-75" />
58+
</Ariakit.MenuButton>
59+
<Ariakit.Menu
60+
open={menuStore.useState('open')}
61+
portal
62+
overlap
63+
unmountOnHide
64+
gutter={parent ? -4 : 4}
65+
className={twMerge(
66+
`${parent ? 'animate-popover-left ml-3' : 'animate-popover'} outline-none! z-50 flex max-h-[min(450px,var(--popover-available-height))] w-full`,
67+
'min-w-[220px] flex-col overflow-auto rounded-xl border border-border-light',
68+
'bg-surface-secondary px-3 py-2 text-sm text-text-primary shadow-lg',
69+
searchable && 'p-0',
70+
)}
71+
>
72+
<SearchableContext.Provider value={searchable}>
73+
{searchable ? (
74+
<>
75+
<div className="sticky top-0 z-10 bg-inherit p-1">
76+
<Ariakit.Combobox
77+
autoSelect
78+
render={combobox}
79+
className={twMerge(
80+
'h-10 w-full rounded border-none bg-transparent px-2 text-base',
81+
'sm:h-8 sm:text-sm',
82+
)}
83+
/>
84+
</div>
85+
<Ariakit.ComboboxList className="p-0.5 pt-0">{children}</Ariakit.ComboboxList>
86+
</>
87+
) : (
88+
children
89+
)}
90+
</SearchableContext.Provider>
91+
</Ariakit.Menu>
92+
</Ariakit.MenuProvider>
93+
);
94+
95+
if (searchable) {
96+
return (
97+
<Ariakit.ComboboxProvider
98+
resetValueOnHide
99+
includesBaseElement={false}
100+
value={searchValue}
101+
setValue={onSearch}
102+
>
103+
{element}
104+
</Ariakit.ComboboxProvider>
105+
);
106+
}
107+
108+
return element;
109+
});
110+
111+
export const CustomMenuSeparator = React.forwardRef<HTMLHRElement, Ariakit.MenuSeparatorProps>(
112+
function CustomMenuSeparator(props, ref) {
113+
return (
114+
<Ariakit.MenuSeparator
115+
ref={ref}
116+
{...props}
117+
className={twMerge(
118+
'my-0.5 h-0 w-full border-t border-slate-200 dark:border-slate-700',
119+
props.className,
120+
)}
121+
/>
122+
);
123+
},
124+
);
125+
126+
export interface CustomMenuGroupProps extends Ariakit.MenuGroupProps {
127+
label?: React.ReactNode;
128+
}
129+
130+
export const CustomMenuGroup = React.forwardRef<HTMLDivElement, CustomMenuGroupProps>(
131+
function CustomMenuGroup({ label, ...props }, ref) {
132+
return (
133+
<Ariakit.MenuGroup ref={ref} {...props} className={twMerge('', props.className)}>
134+
{label && (
135+
<Ariakit.MenuGroupLabel className="cursor-default p-2 text-sm font-medium opacity-60 sm:py-1 sm:text-xs">
136+
{label}
137+
</Ariakit.MenuGroupLabel>
138+
)}
139+
{props.children}
140+
</Ariakit.MenuGroup>
141+
);
142+
},
143+
);
144+
145+
const SearchableContext = React.createContext(false);
146+
147+
export interface CustomMenuItemProps extends Omit<Ariakit.ComboboxItemProps, 'store'> {
148+
name?: string;
149+
}
150+
151+
export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemProps>(
152+
function CustomMenuItem({ name, value, ...props }, ref) {
153+
const menu = Ariakit.useMenuContext();
154+
const searchable = React.useContext(SearchableContext);
155+
const defaultProps: CustomMenuItemProps = {
156+
ref,
157+
focusOnHover: true,
158+
blurOnHoverEnd: false,
159+
...props,
160+
className: twMerge(
161+
'flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black active:pt-[9px] active:pb-[7px] active:bg-black/10 dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white dark:active:bg-white/5 sm:py-1 sm:text-sm sm:active:pt-[5px] sm:active:pb-[3px]',
162+
props.className,
163+
),
164+
};
165+
166+
const checkable = Ariakit.useStoreState(menu, (state) => {
167+
if (!name) {
168+
return false;
169+
}
170+
if (value == null) {
171+
return false;
172+
}
173+
return state?.values[name] != null;
174+
});
175+
176+
const checked = Ariakit.useStoreState(menu, (state) => {
177+
if (!name) {
178+
return false;
179+
}
180+
return state?.values[name] === value;
181+
});
182+
183+
// If the item is checkable, we render a checkmark icon next to the label.
184+
if (checkable) {
185+
defaultProps.children = (
186+
<React.Fragment>
187+
<span className="flex-1">{defaultProps.children}</span>
188+
<Ariakit.MenuItemCheck checked={checked} />
189+
{searchable && (
190+
// When an item is displayed in a search menu as a role=option
191+
// element instead of a role=menuitemradio, we can't depend on the
192+
// aria-checked attribute. Although NVDA and JAWS announce it
193+
// accurately, VoiceOver doesn't. TalkBack does announce the checked
194+
// state, but misleadingly implies that a double tap will change the
195+
// state, which isn't the case. Therefore, we use a visually hidden
196+
// element to indicate whether the item is checked or not, ensuring
197+
// cross-browser/AT compatibility.
198+
<Ariakit.VisuallyHidden>{checked ? 'checked' : 'not checked'}</Ariakit.VisuallyHidden>
199+
)}
200+
</React.Fragment>
201+
);
202+
}
203+
204+
// If the item is not rendered in a search menu (listbox), we can render it
205+
// as a MenuItem/MenuItemRadio.
206+
if (!searchable) {
207+
if (name != null && value != null) {
208+
const radioProps = { ...defaultProps, name, value, hideOnClick: true };
209+
return <Ariakit.MenuItemRadio {...radioProps} />;
210+
}
211+
return <Ariakit.MenuItem {...defaultProps} />;
212+
}
213+
214+
return (
215+
<Ariakit.ComboboxItem
216+
{...defaultProps}
217+
setValueOnClick={false}
218+
value={checkable ? value : undefined}
219+
selectValueOnClick={() => {
220+
if (name == null || value == null) {
221+
return false;
222+
}
223+
// By default, clicking on a ComboboxItem will update the
224+
// selectedValue state of the combobox. However, since we're sharing
225+
// state between combobox and menu, we also need to update the menu's
226+
// values state.
227+
menu?.setValue(name, value);
228+
return true;
229+
}}
230+
hideOnClick={(event) => {
231+
// Make sure that clicking on a combobox item that opens a nested
232+
// menu/dialog does not close the menu.
233+
const expandable = event.currentTarget.hasAttribute('aria-expanded');
234+
if (expandable) {
235+
return false;
236+
}
237+
// By default, clicking on a ComboboxItem only closes its own popover.
238+
// However, since we're in a menu context, we also close all parent
239+
// menus.
240+
menu?.hideAll();
241+
return true;
242+
}}
243+
/>
244+
);
245+
},
246+
);

0 commit comments

Comments
 (0)