2026-01-30 17:40:11 +03:00
|
|
|
<!--
|
|
|
|
|
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';
|
2026-02-06 15:56:48 +03:00
|
|
|
import { Footnote } from '..';
|
2026-01-30 17:40:11 +03:00
|
|
|
|
|
|
|
|
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
2026-02-10 21:17:50 +03:00
|
|
|
/**
|
|
|
|
|
* ID of the section
|
|
|
|
|
*/
|
|
|
|
|
id?: string;
|
2026-01-30 17:40:11 +03:00
|
|
|
/**
|
|
|
|
|
* 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 }]>;
|
2026-02-04 10:47:04 +03:00
|
|
|
/**
|
|
|
|
|
* Snippet for a title description
|
|
|
|
|
*/
|
|
|
|
|
description?: Snippet<[{ className?: string }]>;
|
2026-01-30 17:40:11 +03:00
|
|
|
/**
|
|
|
|
|
* Index of the section
|
|
|
|
|
*/
|
|
|
|
|
index?: number;
|
2026-02-02 12:11:48 +03:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-02-10 21:17:50 +03:00
|
|
|
* @param id - ID of the section
|
2026-02-02 12:11:48 +03:00
|
|
|
* @returns Cleanup callback
|
|
|
|
|
*/
|
2026-02-10 21:17:50 +03:00
|
|
|
onTitleStatusChange?: (
|
|
|
|
|
index: number,
|
|
|
|
|
isPast: boolean,
|
|
|
|
|
title?: Snippet<[{ className?: string }]>,
|
|
|
|
|
id?: string,
|
|
|
|
|
) => () => void;
|
2026-01-30 17:40:11 +03:00
|
|
|
/**
|
|
|
|
|
* Snippet for the section content
|
|
|
|
|
*/
|
2026-02-18 17:36:38 +03:00
|
|
|
content?: Snippet<[{ className?: string }]>;
|
|
|
|
|
/**
|
|
|
|
|
* When true, the title stays fixed in view while
|
|
|
|
|
* scrolling through the section content.
|
|
|
|
|
*/
|
|
|
|
|
stickyTitle?: boolean;
|
|
|
|
|
/**
|
|
|
|
|
* Top offset for sticky title (e.g. header height).
|
|
|
|
|
* @default '0px'
|
|
|
|
|
*/
|
|
|
|
|
stickyOffset?: string;
|
2026-01-30 17:40:11 +03:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 17:36:38 +03:00
|
|
|
const {
|
|
|
|
|
class: className,
|
|
|
|
|
title,
|
|
|
|
|
icon,
|
|
|
|
|
description,
|
|
|
|
|
index = 0,
|
|
|
|
|
onTitleStatusChange,
|
|
|
|
|
id,
|
|
|
|
|
content,
|
|
|
|
|
stickyTitle = false,
|
|
|
|
|
stickyOffset = '0px',
|
|
|
|
|
}: Props = $props();
|
2026-01-30 17:40:11 +03:00
|
|
|
|
2026-02-02 12:11:48 +03:00
|
|
|
let titleContainer = $state<HTMLElement>();
|
2026-02-18 17:36:38 +03:00
|
|
|
const flyParams: FlyParams = {
|
|
|
|
|
y: 0,
|
|
|
|
|
x: -50,
|
|
|
|
|
duration: 300,
|
|
|
|
|
easing: cubicOut,
|
|
|
|
|
opacity: 0.2,
|
|
|
|
|
};
|
2026-02-02 12:11:48 +03:00
|
|
|
|
|
|
|
|
// Track if the user has actually scrolled away from view
|
|
|
|
|
let isScrolledPast = $state(false);
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (!titleContainer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let cleanup: ((index: number) => void) | undefined;
|
2026-02-18 17:36:38 +03:00
|
|
|
const observer = new IntersectionObserver(
|
|
|
|
|
entries => {
|
|
|
|
|
const entry = entries[0];
|
|
|
|
|
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
|
2026-02-02 12:11:48 +03:00
|
|
|
|
2026-02-18 17:36:38 +03:00
|
|
|
if (isPast !== isScrolledPast) {
|
|
|
|
|
isScrolledPast = isPast;
|
|
|
|
|
cleanup = onTitleStatusChange?.(index, isPast, title, id);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
// Set threshold to 0 to trigger exactly when the last pixel leaves
|
|
|
|
|
threshold: 0,
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-02-02 12:11:48 +03:00
|
|
|
|
|
|
|
|
observer.observe(titleContainer);
|
|
|
|
|
return () => {
|
|
|
|
|
observer.disconnect();
|
|
|
|
|
cleanup?.(index);
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-01-30 17:40:11 +03:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<section
|
2026-02-18 17:36:38 +03:00
|
|
|
{id}
|
2026-01-30 17:40:11 +03:00
|
|
|
class={cn(
|
2026-02-18 17:36:38 +03:00
|
|
|
'col-span-2 grid grid-cols-subgrid',
|
|
|
|
|
stickyTitle ? 'gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12' : 'grid-rows-[max-content_1fr]',
|
2026-01-30 17:40:11 +03:00
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
in:fly={flyParams}
|
|
|
|
|
out:fly={flyParams}
|
|
|
|
|
>
|
2026-02-18 17:36:38 +03:00
|
|
|
<div
|
|
|
|
|
bind:this={titleContainer}
|
|
|
|
|
class={cn(
|
|
|
|
|
'flex flex-col gap-2 sm:gap-3',
|
|
|
|
|
stickyTitle && 'self-start',
|
|
|
|
|
)}
|
|
|
|
|
style:position={stickyTitle ? 'sticky' : undefined}
|
|
|
|
|
style:top={stickyTitle ? stickyOffset : undefined}
|
|
|
|
|
>
|
2026-02-06 15:56:48 +03:00
|
|
|
<div class="flex items-center gap-2 sm:gap-3">
|
2026-01-30 17:40:11 +03:00
|
|
|
{#if icon}
|
2026-02-18 17:36:38 +03:00
|
|
|
{@render icon({
|
|
|
|
|
className: 'size-3 sm:size-4 stroke-foreground stroke-1 opacity-60',
|
|
|
|
|
})}
|
2026-02-10 23:19:27 +03:00
|
|
|
<div class="w-px h-2.5 sm:h-3 bg-border-subtle"></div>
|
2026-01-30 17:40:11 +03:00
|
|
|
{/if}
|
2026-02-18 17:36:38 +03:00
|
|
|
|
2026-02-04 10:47:04 +03:00
|
|
|
{#if description}
|
2026-02-06 15:56:48 +03:00
|
|
|
<Footnote>
|
|
|
|
|
{#snippet render({ class: className })}
|
|
|
|
|
{@render description({ className })}
|
|
|
|
|
{/snippet}
|
|
|
|
|
</Footnote>
|
2026-02-04 10:47:04 +03:00
|
|
|
{:else if typeof index === 'number'}
|
2026-02-06 15:56:48 +03:00
|
|
|
<Footnote>
|
2026-01-30 17:40:11 +03:00
|
|
|
Component_{String(index).padStart(3, '0')}
|
2026-02-06 15:56:48 +03:00
|
|
|
</Footnote>
|
2026-01-30 17:40:11 +03:00
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if title}
|
2026-02-06 14:48:44 +03:00
|
|
|
{@render title({
|
|
|
|
|
className:
|
2026-02-10 23:19:27 +03:00
|
|
|
'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-foreground leading-[0.9]',
|
2026-02-06 14:48:44 +03:00
|
|
|
})}
|
2026-01-30 17:40:11 +03:00
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-18 17:36:38 +03:00
|
|
|
{@render content?.({
|
|
|
|
|
className: stickyTitle
|
|
|
|
|
? 'row-start-2 col-start-2'
|
|
|
|
|
: 'row-start-2 col-start-2',
|
|
|
|
|
})}
|
2026-01-30 17:40:11 +03:00
|
|
|
</section>
|