Files
frontend-svelte/src/shared/ui/Section/Section.svelte

123 lines
3.6 KiB
Svelte
Raw Normal View History

<!--
Component: Section
Provides a container for a page widget with snippets for a title
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import type { HTMLAttributes } from 'svelte/elements';
import {
type FlyParams,
fly,
} from 'svelte/transition';
import { Footnote } from '..';
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/**
* Additional CSS classes to apply to the section container.
*/
class?: string;
/**
* Snippet for a title itself
*/
title?: Snippet<[{ className?: string }]>;
/**
* Snippet for a title icon
*/
icon?: Snippet<[{ className?: string }]>;
/**
* Snippet for a title description
*/
description?: Snippet<[{ className?: string }]>;
/**
* Index of the section
*/
index?: number;
/**
* Callback function to notify when the title visibility status changes
*
* @param index - Index of the section
* @param isPast - Whether the section is past the current scroll position
* @param title - Snippet for a title itself
* @returns Cleanup callback
*/
onTitleStatusChange?: (index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) => () => void;
/**
* Snippet for the section content
*/
children?: Snippet;
}
const { class: className, title, icon, description, index = 0, onTitleStatusChange, children }: Props = $props();
let titleContainer = $state<HTMLElement>();
const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 };
// Track if the user has actually scrolled away from view
let isScrolledPast = $state(false);
$effect(() => {
if (!titleContainer) {
return;
}
let cleanup: ((index: number) => void) | undefined;
const observer = new IntersectionObserver(entries => {
const entry = entries[0];
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
if (isPast !== isScrolledPast) {
isScrolledPast = isPast;
cleanup = onTitleStatusChange?.(index, isPast, title);
}
}, {
// Set threshold to 0 to trigger exactly when the last pixel leaves
threshold: 0,
});
observer.observe(titleContainer);
return () => {
observer.disconnect();
cleanup?.(index);
};
});
</script>
<section
class={cn(
'flex flex-col',
className,
)}
in:fly={flyParams}
out:fly={flyParams}
>
2026-02-06 14:48:44 +03:00
<div class="flex flex-col gap-2 sm:gap-3" bind:this={titleContainer}>
<div class="flex items-center gap-2 sm:gap-3">
{#if icon}
{@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1 opacity-60' })}
<div class="w-px h-2.5 sm:h-3 bg-gray-300/60"></div>
{/if}
{#if description}
<Footnote>
{#snippet render({ class: className })}
{@render description({ className })}
{/snippet}
</Footnote>
{:else if typeof index === 'number'}
<Footnote>
Component_{String(index).padStart(3, '0')}
</Footnote>
{/if}
</div>
{#if title}
2026-02-06 14:48:44 +03:00
{@render title({
className:
'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-gray-900 leading-[0.9]',
})}
{/if}
</div>
{@render children?.()}
</section>