mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
WIP: Responsive Segmented Controls
This commit is contained in:
parent
f4d97e1672
commit
fcfb0f47f9
7 changed files with 325 additions and 5 deletions
130
client/src/components/SidePanel/Parameters/DynamicSegment.tsx
Normal file
130
client/src/components/SidePanel/Parameters/DynamicSegment.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { OptionTypes } from 'librechat-data-provider';
|
||||
import type { DynamicSettingProps } from 'librechat-data-provider';
|
||||
import { Label, HoverCard, HoverCardTrigger, SegmentedControl } from '~/components/ui';
|
||||
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
function DynamicSegment({
|
||||
label = '',
|
||||
settingKey,
|
||||
defaultValue,
|
||||
description = '',
|
||||
columnSpan,
|
||||
setOption,
|
||||
optionType,
|
||||
options,
|
||||
enumMappings,
|
||||
readonly = false,
|
||||
showLabel = true,
|
||||
showDefault = false,
|
||||
labelCode = false,
|
||||
descriptionCode = false,
|
||||
conversation,
|
||||
}: DynamicSettingProps) {
|
||||
const localize = useLocalize();
|
||||
const { preset } = useChatContext();
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
return inputValue;
|
||||
}
|
||||
|
||||
return conversation?.[settingKey] ?? defaultValue;
|
||||
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (optionType === OptionTypes.Custom) {
|
||||
// TODO: custom logic, add to payload but not to conversation
|
||||
setInputValue(value);
|
||||
return;
|
||||
}
|
||||
setOption(settingKey)(value);
|
||||
};
|
||||
|
||||
useParameterEffects({
|
||||
preset,
|
||||
settingKey,
|
||||
defaultValue,
|
||||
conversation,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
preventDelayedUpdate: true,
|
||||
});
|
||||
|
||||
if (!options || options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert options to SegmentedControl format with proper localization
|
||||
const segmentOptions =
|
||||
options?.map((option) => {
|
||||
const optionValue = typeof option === 'string' ? option : String(option);
|
||||
const optionLabel = typeof option === 'string' ? option : String(option);
|
||||
|
||||
// Use enum mappings for localization if available
|
||||
const localizedLabel = enumMappings?.[optionValue]
|
||||
? localize(enumMappings[optionValue] as TranslationKeys) ||
|
||||
String(enumMappings[optionValue])
|
||||
: optionLabel;
|
||||
|
||||
return {
|
||||
label: String(localizedLabel),
|
||||
value: optionValue,
|
||||
disabled: false,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-start gap-6',
|
||||
columnSpan != null ? `col-span-${columnSpan}` : 'col-span-full',
|
||||
)}
|
||||
>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
{showLabel === true && (
|
||||
<div className="flex w-full justify-between">
|
||||
<Label
|
||||
htmlFor={`${settingKey}-dynamic-segment`}
|
||||
className="text-left text-sm font-medium"
|
||||
>
|
||||
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
|
||||
{showDefault && (
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default')}: {defaultValue})
|
||||
</small>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<SegmentedControl
|
||||
options={segmentOptions}
|
||||
value={selectedValue}
|
||||
onValueChange={handleChange}
|
||||
disabled={readonly}
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
{description && (
|
||||
<OptionHover
|
||||
description={
|
||||
descriptionCode
|
||||
? (localize(description as TranslationKeys) ?? description)
|
||||
: description
|
||||
}
|
||||
side={ESide.Left}
|
||||
/>
|
||||
)}
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicSegment;
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
DynamicSwitch,
|
||||
DynamicInput,
|
||||
DynamicTags,
|
||||
DynamicSegment,
|
||||
} from './';
|
||||
|
||||
export const componentMapping: Record<
|
||||
|
|
@ -23,4 +24,5 @@ export const componentMapping: Record<
|
|||
[ComponentTypes.Checkbox]: DynamicCheckbox,
|
||||
[ComponentTypes.Tags]: DynamicTags,
|
||||
[ComponentTypes.Combobox]: DynamicCombobox,
|
||||
[ComponentTypes.Segment]: DynamicSegment,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ export { default as DynamicSlider } from './DynamicSlider';
|
|||
export { default as DynamicSwitch } from './DynamicSwitch';
|
||||
export { default as DynamicInput } from './DynamicInput';
|
||||
export { default as DynamicTags } from './DynamicTags';
|
||||
export { default as DynamicSegment } from './DynamicSegment';
|
||||
export { default as OptionHoverAlt } from './OptionHover';
|
||||
|
|
|
|||
169
client/src/components/ui/SegmentedControl.tsx
Normal file
169
client/src/components/ui/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export interface SegmentedControlOption {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps {
|
||||
options: SegmentedControlOption[];
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
name?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
|
||||
({ options, value, onValueChange, name, className, disabled }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, height: 0, left: 0, top: 0 });
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [useGrid, setUseGrid] = useState(false);
|
||||
|
||||
// Ensure we always have a current value
|
||||
const currentValue = value !== undefined ? value : options[0]?.value;
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (disabled) return;
|
||||
onValueChange?.(newValue);
|
||||
};
|
||||
|
||||
const updateIndicator = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const selector = currentValue === '' ? '[data-value=""]' : `[data-value="${currentValue}"]`;
|
||||
const activeButton = containerRef.current.querySelector(selector) as HTMLButtonElement;
|
||||
|
||||
if (activeButton) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const buttonRect = activeButton.getBoundingClientRect();
|
||||
|
||||
if (useGrid) {
|
||||
// 2x2 grid layout - use full button dimensions
|
||||
setIndicatorStyle({
|
||||
width: buttonRect.width,
|
||||
height: buttonRect.height,
|
||||
left: buttonRect.left - containerRect.left,
|
||||
top: buttonRect.top - containerRect.top,
|
||||
});
|
||||
} else {
|
||||
// 1-row layout - account for flex-1 distribution
|
||||
const containerPadding = 4; // p-1 = 4px
|
||||
setIndicatorStyle({
|
||||
width: buttonRect.width,
|
||||
height: buttonRect.height,
|
||||
left: buttonRect.left - containerRect.left - containerPadding,
|
||||
top: buttonRect.top - containerRect.top - containerPadding,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if text is being truncated and switch to grid if needed
|
||||
const checkLayout = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const buttons = containerRef.current.querySelectorAll('button');
|
||||
let needsGrid = false;
|
||||
|
||||
buttons.forEach((button) => {
|
||||
if (button.scrollWidth > button.clientWidth) {
|
||||
needsGrid = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (needsGrid !== useGrid) {
|
||||
setUseGrid(needsGrid);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize and handle resize
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
checkLayout();
|
||||
updateIndicator();
|
||||
});
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
// Initial check
|
||||
setTimeout(() => {
|
||||
checkLayout();
|
||||
updateIndicator();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Update indicator when value changes
|
||||
useEffect(() => {
|
||||
updateIndicator();
|
||||
}, [currentValue, options]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative rounded-lg bg-surface-secondary p-1',
|
||||
useGrid ? 'grid grid-cols-2 gap-1' : 'flex items-center',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className,
|
||||
)}
|
||||
role="radiogroup"
|
||||
>
|
||||
{/* Sliding background indicator */}
|
||||
<div
|
||||
className={cn(
|
||||
'ring-border-light/20 absolute rounded-md bg-surface-primary shadow-sm ring-1 transition-all duration-300 ease-out',
|
||||
!isInitialized && 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: indicatorStyle.width,
|
||||
height: indicatorStyle.height,
|
||||
transform: `translate(${indicatorStyle.left}px, ${indicatorStyle.top}px)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{options.map((option) => {
|
||||
const isActive = currentValue === option.value;
|
||||
const isDisabled = disabled || option.disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
disabled={isDisabled}
|
||||
data-value={option.value}
|
||||
onClick={() => handleChange(option.value)}
|
||||
className={cn(
|
||||
'relative z-10 px-2 py-1.5 text-xs font-medium transition-colors duration-200 ease-out',
|
||||
'rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'min-w-0 truncate',
|
||||
useGrid ? 'w-full' : 'flex-1',
|
||||
isActive ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary',
|
||||
!isDisabled && 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SegmentedControl.displayName = 'SegmentedControl';
|
||||
|
|
@ -46,3 +46,4 @@ export { default as InputWithDropdown } from './InputWithDropDown';
|
|||
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
||||
export { SegmentedControl } from './SegmentedControl';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export type ComponentType =
|
|||
| 'switch'
|
||||
| 'dropdown'
|
||||
| 'combobox'
|
||||
| 'tags';
|
||||
| 'tags'
|
||||
| 'segment';
|
||||
|
||||
export type OptionType = 'conversation' | 'model' | 'custom';
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ export enum ComponentTypes {
|
|||
Dropdown = 'dropdown',
|
||||
Combobox = 'combobox',
|
||||
Tags = 'tags',
|
||||
Segment = 'segment',
|
||||
}
|
||||
|
||||
export enum SettingTypes {
|
||||
|
|
@ -206,6 +208,7 @@ const maxColumns = 4;
|
|||
const minSliderOptions = 2;
|
||||
const minDropdownOptions = 2;
|
||||
const minComboboxOptions = 2;
|
||||
const minSegmentOptions = 2;
|
||||
|
||||
/**
|
||||
* Validates the provided setting using the constraints unique to each component type.
|
||||
|
|
@ -361,6 +364,20 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
|
|||
setting.type === SettingTypes.Number ? (setting.includeInput ?? true) : false; // Default to true if type is number
|
||||
}
|
||||
|
||||
if (setting.component === ComponentTypes.Segment) {
|
||||
if (
|
||||
setting.type === SettingTypes.Enum &&
|
||||
(!setting.options || setting.options.length < minSegmentOptions)
|
||||
) {
|
||||
errors.push({
|
||||
code: ZodIssueCode.custom,
|
||||
message: `Segment component for setting ${setting.key} requires at least ${minSegmentOptions} options for enum type.`,
|
||||
path: ['options'],
|
||||
});
|
||||
// continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (setting.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) {
|
||||
if (setting.default === undefined && setting.range) {
|
||||
// Set default to the middle of the range if unspecified
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ const baseDefinitions: Record<string, SettingDefinition> = {
|
|||
descriptionCode: true,
|
||||
type: 'enum',
|
||||
default: ImageDetail.auto,
|
||||
component: 'slider',
|
||||
component: 'segment',
|
||||
options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high],
|
||||
enumMappings: {
|
||||
[ImageDetail.low]: 'com_ui_low',
|
||||
|
|
@ -218,7 +218,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
|||
descriptionCode: true,
|
||||
type: 'enum',
|
||||
default: ReasoningEffort.none,
|
||||
component: 'slider',
|
||||
component: 'segment',
|
||||
options: [
|
||||
ReasoningEffort.none,
|
||||
ReasoningEffort.low,
|
||||
|
|
@ -268,7 +268,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
|||
descriptionCode: true,
|
||||
type: 'enum',
|
||||
default: ReasoningSummary.none,
|
||||
component: 'slider',
|
||||
component: 'segment',
|
||||
options: [
|
||||
ReasoningSummary.none,
|
||||
ReasoningSummary.auto,
|
||||
|
|
@ -276,7 +276,7 @@ const openAIParams: Record<string, SettingDefinition> = {
|
|||
ReasoningSummary.detailed,
|
||||
],
|
||||
enumMappings: {
|
||||
[ReasoningSummary.none]: 'com_ui_none',
|
||||
[ReasoningSummary.none]: 'com_ui_none_',
|
||||
[ReasoningSummary.auto]: 'com_ui_auto',
|
||||
[ReasoningSummary.concise]: 'com_ui_concise',
|
||||
[ReasoningSummary.detailed]: 'com_ui_detailed',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue