feat(ExtendableWrapper): create reusable extendable wrapper with animations
This commit is contained in:
188
src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
Normal file
188
src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
Normal 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>
|
||||||
@@ -8,6 +8,7 @@ import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
|||||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||||
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
|
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
|
||||||
import ContentEditable from './ContentEditable/ContentEditable.svelte';
|
import ContentEditable from './ContentEditable/ContentEditable.svelte';
|
||||||
|
import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte';
|
||||||
import SearchBar from './SearchBar/SearchBar.svelte';
|
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export {
|
|||||||
ComboControl,
|
ComboControl,
|
||||||
ComboControlV2,
|
ComboControlV2,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
|
ExpandableWrapper,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user