feat: Add fullWidth and icon support to Radio component for enhanced flexibility

This commit is contained in:
Marco Beretta 2025-10-24 13:54:32 +02:00
parent 7e1d02bcc3
commit a7dc109856
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 69 additions and 68 deletions

View file

@ -2,7 +2,7 @@ import { useRef, useState, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery } from '@librechat/client';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
@ -24,14 +24,27 @@ export default function Artifacts() {
const [isClosing, setIsClosing] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [height, setHeight] = useState(90); // Height as percentage of viewport
const [height, setHeight] = useState(90);
const [isDragging, setIsDragging] = useState(false);
const [blurAmount, setBlurAmount] = useState(0); // Dynamic blur amount
const [blurAmount, setBlurAmount] = useState(0);
const dragStartY = useRef(0);
const dragStartHeight = useRef(90);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const tabOptions = [
{
value: 'code',
label: localize('com_ui_code'),
icon: <Code className="size-4" />,
},
{
value: 'preview',
label: localize('com_ui_preview'),
icon: <Play className="size-4" />,
},
];
useEffect(() => {
setIsMounted(true);
const delay = isMobile ? 50 : 30;
@ -42,26 +55,22 @@ export default function Artifacts() {
};
}, [isMobile]);
// Dynamic blur based on height - more blur when taking up more screen
useEffect(() => {
if (!isMobile) {
setBlurAmount(0);
return;
}
// Calculate blur amount based on how much screen is covered
// 50% height = no blur, 100% height = full blur
const minHeightForBlur = 50;
const maxHeightForBlur = 100;
if (height <= minHeightForBlur) {
setBlurAmount(0);
} else if (height >= maxHeightForBlur) {
setBlurAmount(32); // Increased from 16 to 32 for stronger blur
setBlurAmount(32);
} else {
// Linear interpolation between 0 and 32px blur
const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur);
setBlurAmount(Math.round(progress * 32)); // Changed from 16 to 32
setBlurAmount(Math.round(progress * 32));
}
}, [height, isMobile]);
@ -142,7 +151,6 @@ export default function Artifacts() {
}
};
// Calculate backdrop opacity based on blur amount
const backdropOpacity = blurAmount > 0 ? Math.min(0.3, blurAmount / 53.33) : 0;
return (
@ -156,7 +164,6 @@ export default function Artifacts() {
isVisible && !isClosing
? 'transition-all duration-300'
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
// Allow pointer events when not fully blurred so chat is scrollable
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
)}
style={{
@ -199,6 +206,7 @@ export default function Artifacts() {
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
</div>
)}
{/* Header */}
<div
className={cn(
@ -215,31 +223,12 @@ export default function Artifacts() {
: '-translate-x-2 opacity-0',
)}
>
<Tabs.List className="relative inline-flex h-9 gap-2 rounded-xl bg-surface-tertiary p-0.5">
<div
className={cn(
'duration-[350ms] absolute top-0.5 h-8 rounded-[10px] bg-surface-primary-alt shadow-sm transition-all will-change-transform',
activeTab === 'code'
? 'w-[42%] translate-x-0'
: 'w-[50%] translate-x-[calc(100%-0.250rem)]',
)}
/>
<Tabs.Trigger
value="code"
className="relative z-10 flex items-center gap-1.5 rounded-[10px] border-transparent px-3 py-1 text-xs font-medium transition-all duration-200 hover:text-text-primary active:scale-95 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Code className="size-3 transition-transform duration-200 group-active:scale-90" />
<span className="whitespace-nowrap">{localize('com_ui_code')}</span>
</Tabs.Trigger>
<Tabs.Trigger
value="preview"
disabled={isMutating}
className="relative z-10 flex items-center gap-1.5 rounded-[10px] border-transparent px-3 py-1 text-xs font-medium transition-all duration-200 hover:text-text-primary active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Play className="size-3 transition-transform duration-200 group-active:scale-90" />
<span className="whitespace-nowrap">{localize('com_ui_preview')}</span>
</Tabs.Trigger>
</Tabs.List>
<Radio
options={tabOptions}
value={activeTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
/>
</div>
)}
@ -257,7 +246,6 @@ export default function Artifacts() {
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={localize('com_ui_refresh')}
className="transition-all duration-150 active:scale-90"
>
{isRefreshing ? (
<Spinner size={16} />
@ -288,14 +276,12 @@ export default function Artifacts() {
variant="ghost"
onClick={closeArtifacts}
aria-label={localize('com_ui_close')}
className="h-8 w-8 transition-all duration-150 hover:scale-105 active:scale-90"
>
<X size={16} />
</Button>
</div>
</div>
{/* Content Area - Fixed positioning to prevent layout shifts */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
<div className="absolute inset-0 flex flex-col">
<ArtifactTabs
@ -304,34 +290,35 @@ export default function Artifacts() {
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
</div>
<div
className={cn(
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
)}
aria-hidden={!isRefreshing}
role="status"
>
<div
className={cn(
'transition-transform duration-300 ease-in-out',
isRefreshing ? 'scale-100' : 'scale-95',
)}
>
<Spinner size={24} />
</div>
</div>
</div>
{/* Mobile Tab Switcher with iOS-style animation */}
{isMobile && (
<div className="pb-safe-offset-3 flex-shrink-0 border-t border-border-light bg-surface-primary-alt px-3 pt-2">
<Tabs.List className="relative flex h-10 w-full rounded-xl bg-surface-tertiary p-1">
<div
className={cn(
'duration-[350ms] absolute left-1 top-1 h-8 w-[calc(50%-0.25rem)] rounded-[10px] bg-surface-primary-alt shadow-sm transition-transform will-change-transform',
activeTab === 'code' ? 'translate-x-0' : 'translate-x-[calc(100%+0.5rem)]',
)}
/>
<Tabs.Trigger
value="code"
className="relative z-10 flex w-1/2 items-center justify-center gap-1.5 rounded-[10px] border-transparent py-1.5 text-xs font-medium transition-all duration-150 active:scale-95 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Code className="size-3.5" />
<span>{localize('com_ui_code')}</span>
</Tabs.Trigger>
<Tabs.Trigger
value="preview"
disabled={isMutating}
className="relative z-10 flex w-1/2 items-center justify-center gap-1.5 rounded-[10px] border-transparent py-1.5 text-xs font-medium transition-all duration-150 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 data-[state=active]:text-text-primary data-[state=inactive]:text-text-secondary"
>
<Play className="size-3.5" />
<span>{localize('com_ui_preview')}</span>
</Tabs.Trigger>
</Tabs.List>
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
<Radio
fullWidth
options={tabOptions}
value={activeTab}
onChange={setActiveTab}
disabled={isMutating && activeTab !== 'code'}
/>
</div>
)}
</div>

View file

@ -4,6 +4,7 @@ import { useLocalize } from '~/hooks';
interface Option {
value: string;
label: string;
icon?: React.ReactNode;
}
interface RadioProps {
@ -11,9 +12,18 @@ interface RadioProps {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
fullWidth?: boolean;
}
const Radio = memo(function Radio({ options, value, onChange, disabled = false }: RadioProps) {
const Radio = memo(function Radio({
options,
value,
onChange,
disabled = false,
className = '',
fullWidth = false,
}: RadioProps) {
const localize = useLocalize();
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
@ -67,7 +77,10 @@ const Radio = memo(function Radio({ options, value, onChange, disabled = false }
const selectedIndex = options.findIndex((opt) => opt.value === currentValue);
return (
<div className="relative inline-flex items-center rounded-lg bg-muted p-1" role="radiogroup">
<div
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted p-1 ${className}`}
role="radiogroup"
>
{selectedIndex >= 0 && (
<div
className="pointer-events-none absolute inset-y-1 rounded-md border border-border/50 bg-background shadow-sm transition-all duration-300 ease-out"
@ -85,10 +98,11 @@ const Radio = memo(function Radio({ options, value, onChange, disabled = false }
aria-checked={currentValue === option.value}
onClick={() => handleChange(option.value)}
disabled={disabled}
className={`relative z-10 flex h-[34px] items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
className={`relative z-10 flex h-[34px] items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
currentValue === option.value ? 'text-foreground' : 'text-foreground'
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} ${fullWidth ? 'flex-1' : ''}`}
>
{option.icon && <span className="flex-shrink-0">{option.icon}</span>}
<span className="whitespace-nowrap">{option.label}</span>
</button>
))}