feat(ExtendableWrapper): create reusable extendable wrapper with animations

This commit is contained in:
Ilia Mashkov
2026-01-24 23:56:26 +03:00
parent 7ffc5d6a34
commit 8a2059ac4a
2 changed files with 190 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
<!--
Component: ExpandableWrapper
Animated wrapper for content that can be expanded and collapsed.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* Bindable property to control the expanded state of the wrapper.
* @default false
*/
expanded?: boolean;
/**
* Disabled flag
* @default false
*/
disabled?: boolean;
/**
* Bindable property to bind:this
* @default null
*/
element?: HTMLElement | null;
/**
* Content that's always visible
*/
visibleContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Content that's hidden when the wrapper is collapsed
*/
hiddenContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Optional badge to render
*/
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Rotation animation direction
* @default 'clockwise'
*/
rotation?: 'clockwise' | 'counterclockwise';
}
let {
expanded = $bindable(false),
disabled = false,
element = $bindable(null),
visibleContent,
hiddenContent,
badge,
rotation = 'clockwise',
class: className = '',
...props
}: Props = $props();
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
export const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
const ySpring = new Spring(0, {
stiffness: 0.32,
damping: 0.65,
});
const scaleSpring = new Spring(1, {
stiffness: 0.32,
damping: 0.65,
});
export const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
function handleClickOutside(e: MouseEvent) {
if (element && !element.contains(e.target as Node)) {
expanded = false;
}
}
function handleWrapperClick() {
if (!disabled) {
expanded = true;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleWrapperClick();
}
if (expanded && e.key === 'Escape') {
expanded = false;
}
}
// Elevation and scale on activation
$effect(() => {
if (expanded && !disabled) {
// Lift up
ySpring.target = 8;
// Slightly bigger
scaleSpring.target = 1.1;
rotateSpring.target = rotation === 'clockwise' ? -0.5 : 0.5;
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
scaleSpring.target = 1.05;
}, 300);
} else {
ySpring.target = 0;
scaleSpring.target = 1;
rotateSpring.target = 0;
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
// Click outside handling
$effect(() => {
if (typeof window === 'undefined') {
return;
}
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
$effect(() => {
if (disabled) {
expanded = false;
}
});
</script>
<div
bind:this={element}
onclick={handleWrapperClick}
onkeydown={handleKeyDown}
role="button"
tabindex={0}
class={cn(
'will-change-transform duration-300',
disabled ? 'pointer-events-none' : 'pointer-events-auto',
className,
)}
style:transform="
translate({xSpring.current}px, {ySpring.current}px)
scale({scaleSpring.current})
rotateZ({rotateSpring.current}deg)
"
{...props}
>
{@render badge?.({ expanded, disabled })}
<div
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
expanded
? '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)]',
disabled && 'opacity-80 grayscale-[0.2]',
)}
>
{@render visibleContent?.({ expanded, disabled })}
{#if expanded}
<div
in:slide={{ duration: 250, delay: 50 }}
out:slide={{ duration: 250 }}
>
{@render hiddenContent?.({ expanded, disabled })}
</div>
{/if}
</div>
</div>

View File

@@ -8,6 +8,7 @@ import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte';
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte';
import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte';
import SearchBar from './SearchBar/SearchBar.svelte';
import VirtualList from './VirtualList/VirtualList.svelte';
@@ -16,6 +17,7 @@ export {
ComboControl,
ComboControlV2,
ContentEditable,
ExpandableWrapper,
SearchBar,
VirtualList,
};