chore(ControlsWrapper): use new reusable wrapper

This commit is contained in:
Ilia Mashkov
2026-01-24 23:57:16 +03:00
parent 8a2059ac4a
commit ed4ee8bb44

View File

@@ -3,9 +3,9 @@ import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { ComboControlV2 } from '$shared/ui'; import { ComboControlV2 } from '$shared/ui';
import { ExpandableWrapper } from '$shared/ui';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up'; import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface Props { interface Props {
wrapper?: HTMLDivElement | null; wrapper?: HTMLDivElement | null;
@@ -29,40 +29,27 @@ let {
heightControl, heightControl,
}: Props = $props(); }: Props = $props();
let panelWidth = $state(0); let panelWidth = $derived(wrapper?.clientWidth ?? 0);
const margin = 24; const margin = 24;
let side = $state<'left' | 'right'>('left'); let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper // Unified active state for the entire wrapper
let isActive = $state(false); let isActive = $state(false);
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
function handleWrapperClick() { const xSpring = new Spring(0, {
if (!isDragging) { stiffness: 0.14, // Lower is slower
isActive = true; damping: 0.5, // Settle
} });
}
function handleClickOutside(e: MouseEvent) { const rotateSpring = new Spring(0, {
if (wrapper && !wrapper.contains(e.target as Node)) { stiffness: 0.12,
isActive = false; damping: 0.55,
} });
}
function handleInputFocus() { function handleInputFocus() {
isActive = true; isActive = true;
} }
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleWrapperClick();
}
if (isActive && e.key === 'Escape') {
isActive = false;
}
}
// Movement Logic // Movement Logic
$effect(() => { $effect(() => {
if (containerWidth === 0 || panelWidth === 0) return; if (containerWidth === 0 || panelWidth === 0) return;
@@ -78,144 +65,80 @@ $effect(() => {
} }
}); });
// The "Dodge"
const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
// The "Focus"
const ySpring = new Spring(0, {
stiffness: 0.32,
damping: 0.65,
});
// The "Rise"
const scaleSpring = new Spring(1, {
stiffness: 0.32,
damping: 0.65,
});
// The "Lean"
const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
$effect(() => { $effect(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0; const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0 && !isActive) { if (containerWidth > 0 && panelWidth > 0) {
// On side change set the position and the rotation // On side change set the position and the rotation
xSpring.target = targetX; xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5; rotateSpring.target = side === 'right' ? 3.5 : -3.5;
setTimeout(() => { timeoutId = setTimeout(() => {
rotateSpring.target = 0; rotateSpring.target = 0;
}, 600); }, 600);
} }
});
// Elevation and scale on focus and mouse over return () => {
$effect(() => { if (timeoutId) {
if (isActive && !isDragging) { clearTimeout(timeoutId);
// Lift up }
ySpring.target = 8; };
// Slightly bigger
scaleSpring.target = 1.1;
rotateSpring.target = side === 'right' ? -1.1 : 1.1;
setTimeout(() => {
rotateSpring.target = 0;
scaleSpring.target = 1.05;
}, 300);
} else {
ySpring.target = 0;
scaleSpring.target = 1;
rotateSpring.target = 0;
}
});
$effect(() => {
if (isDragging) {
isActive = false;
}
});
// Click outside handler
$effect(() => {
if (typeof window === 'undefined') return;
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}); });
</script> </script>
<div <div
onclick={handleWrapperClick} class="absolute top-6 left-6 z-50 will-change-transform"
bind:this={wrapper}
bind:clientWidth={panelWidth}
class={cn(
'absolute top-6 left-6 z-50 will-change-transform transition-opacity duration-300 flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
style:pointer-events={isDragging ? 'none' : 'auto'}
style:transform=" style:transform="
translate({xSpring.current}px, {ySpring.current}px) translateX({xSpring.current}px)
scale({scaleSpring.current})
rotateZ({rotateSpring.current}deg) rotateZ({rotateSpring.current}deg)
" "
role="button"
tabindex={0}
onkeydown={handleKeyDown}
aria-label="Font controls"
> >
<div <ExpandableWrapper
bind:element={wrapper}
bind:expanded={isActive}
disabled={isDragging}
aria-label="Font controls"
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
class={cn( class={cn(
'animate-nudge relative transition-all', 'transition-opacity flex items-top gap-1.5',
side === 'left' ? 'order-2' : 'order-0', panelWidth === 0 ? 'opacity-0' : 'opacity-100',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-80 grayscale-[0.2]',
)} )}
> >
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} /> {#snippet badge()}
</div>
<div
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5',
isActive
? 'bg-white/95 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
style:backdrop-filter="blur(24px)"
>
<div class="relative px-2 py-1">
<Input
bind:value={text}
disabled={isDragging}
onfocusin={handleInputFocus}
class={cn(
isActive
? 'h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50',
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
)}
placeholder="Edit label..."
/>
</div>
{#if isActive}
<div <div
in:slide={{ duration: 250, delay: 50 }} class={cn(
out:slide={{ duration: 250 }} 'animate-nudge relative transition-all',
class="flex justify-between items-center-safe" side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
> >
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div>
{/snippet}
{#snippet visibleContent()}
<div class="relative px-2 py-1">
<Input
bind:value={text}
disabled={isDragging}
onfocusin={handleInputFocus}
class={cn(
isActive
? 'h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50',
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
)}
placeholder="The quick brown fox..."
/>
</div>
{/snippet}
{#snippet hiddenContent()}
<div class="flex justify-between items-center-safe">
<ComboControlV2 control={weightControl} /> <ComboControlV2 control={weightControl} />
<ComboControlV2 control={sizeControl} /> <ComboControlV2 control={sizeControl} />
<ComboControlV2 control={heightControl} /> <ComboControlV2 control={heightControl} />
</div> </div>
{/if} {/snippet}
</div> </ExpandableWrapper>
</div> </div>