mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-19 16:56:12 +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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue