WIP: Responsive Segmented Controls

This commit is contained in:
Dustin Healy 2025-07-07 10:22:35 -07:00
parent f4d97e1672
commit fcfb0f47f9
7 changed files with 325 additions and 5 deletions

View 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;

View file

@ -9,6 +9,7 @@ import {
DynamicSwitch, DynamicSwitch,
DynamicInput, DynamicInput,
DynamicTags, DynamicTags,
DynamicSegment,
} from './'; } from './';
export const componentMapping: Record< export const componentMapping: Record<
@ -23,4 +24,5 @@ export const componentMapping: Record<
[ComponentTypes.Checkbox]: DynamicCheckbox, [ComponentTypes.Checkbox]: DynamicCheckbox,
[ComponentTypes.Tags]: DynamicTags, [ComponentTypes.Tags]: DynamicTags,
[ComponentTypes.Combobox]: DynamicCombobox, [ComponentTypes.Combobox]: DynamicCombobox,
[ComponentTypes.Segment]: DynamicSegment,
}; };

View file

@ -6,4 +6,5 @@ export { default as DynamicSlider } from './DynamicSlider';
export { default as DynamicSwitch } from './DynamicSwitch'; export { default as DynamicSwitch } from './DynamicSwitch';
export { default as DynamicInput } from './DynamicInput'; export { default as DynamicInput } from './DynamicInput';
export { default as DynamicTags } from './DynamicTags'; export { default as DynamicTags } from './DynamicTags';
export { default as DynamicSegment } from './DynamicSegment';
export { default as OptionHoverAlt } from './OptionHover'; export { default as OptionHoverAlt } from './OptionHover';

View 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';

View file

@ -46,3 +46,4 @@ export { default as InputWithDropdown } from './InputWithDropDown';
export { default as SelectDropDownPop } from './SelectDropDownPop'; export { default as SelectDropDownPop } from './SelectDropDownPop';
export { default as AnimatedSearchInput } from './AnimatedSearchInput'; export { default as AnimatedSearchInput } from './AnimatedSearchInput';
export { default as MultiSelectDropDown } from './MultiSelectDropDown'; export { default as MultiSelectDropDown } from './MultiSelectDropDown';
export { SegmentedControl } from './SegmentedControl';

View file

@ -14,7 +14,8 @@ export type ComponentType =
| 'switch' | 'switch'
| 'dropdown' | 'dropdown'
| 'combobox' | 'combobox'
| 'tags'; | 'tags'
| 'segment';
export type OptionType = 'conversation' | 'model' | 'custom'; export type OptionType = 'conversation' | 'model' | 'custom';
@ -34,6 +35,7 @@ export enum ComponentTypes {
Dropdown = 'dropdown', Dropdown = 'dropdown',
Combobox = 'combobox', Combobox = 'combobox',
Tags = 'tags', Tags = 'tags',
Segment = 'segment',
} }
export enum SettingTypes { export enum SettingTypes {
@ -206,6 +208,7 @@ const maxColumns = 4;
const minSliderOptions = 2; const minSliderOptions = 2;
const minDropdownOptions = 2; const minDropdownOptions = 2;
const minComboboxOptions = 2; const minComboboxOptions = 2;
const minSegmentOptions = 2;
/** /**
* Validates the provided setting using the constraints unique to each component type. * 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 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.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) {
if (setting.default === undefined && setting.range) { if (setting.default === undefined && setting.range) {
// Set default to the middle of the range if unspecified // Set default to the middle of the range if unspecified

View file

@ -70,7 +70,7 @@ const baseDefinitions: Record<string, SettingDefinition> = {
descriptionCode: true, descriptionCode: true,
type: 'enum', type: 'enum',
default: ImageDetail.auto, default: ImageDetail.auto,
component: 'slider', component: 'segment',
options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high], options: [ImageDetail.low, ImageDetail.auto, ImageDetail.high],
enumMappings: { enumMappings: {
[ImageDetail.low]: 'com_ui_low', [ImageDetail.low]: 'com_ui_low',
@ -218,7 +218,7 @@ const openAIParams: Record<string, SettingDefinition> = {
descriptionCode: true, descriptionCode: true,
type: 'enum', type: 'enum',
default: ReasoningEffort.none, default: ReasoningEffort.none,
component: 'slider', component: 'segment',
options: [ options: [
ReasoningEffort.none, ReasoningEffort.none,
ReasoningEffort.low, ReasoningEffort.low,
@ -268,7 +268,7 @@ const openAIParams: Record<string, SettingDefinition> = {
descriptionCode: true, descriptionCode: true,
type: 'enum', type: 'enum',
default: ReasoningSummary.none, default: ReasoningSummary.none,
component: 'slider', component: 'segment',
options: [ options: [
ReasoningSummary.none, ReasoningSummary.none,
ReasoningSummary.auto, ReasoningSummary.auto,
@ -276,7 +276,7 @@ const openAIParams: Record<string, SettingDefinition> = {
ReasoningSummary.detailed, ReasoningSummary.detailed,
], ],
enumMappings: { enumMappings: {
[ReasoningSummary.none]: 'com_ui_none', [ReasoningSummary.none]: 'com_ui_none_',
[ReasoningSummary.auto]: 'com_ui_auto', [ReasoningSummary.auto]: 'com_ui_auto',
[ReasoningSummary.concise]: 'com_ui_concise', [ReasoningSummary.concise]: 'com_ui_concise',
[ReasoningSummary.detailed]: 'com_ui_detailed', [ReasoningSummary.detailed]: 'com_ui_detailed',