feature/ux-improvements #26

Merged
ilia merged 73 commits from feature/ux-improvements into main 2026-02-18 14:43:05 +00:00
Showing only changes of commit e3924d43d8 - Show all commits

View File

@@ -56,13 +56,40 @@ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
/** /**
* Snippet for the section content * Snippet for the section content
*/ */
children?: Snippet; 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;
} }
const { class: className, title, icon, description, index = 0, onTitleStatusChange, id, children }: Props = $props(); const {
class: className,
title,
icon,
description,
index = 0,
onTitleStatusChange,
id,
content,
stickyTitle = false,
stickyOffset = '0px',
}: Props = $props();
let titleContainer = $state<HTMLElement>(); let titleContainer = $state<HTMLElement>();
const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }; const flyParams: FlyParams = {
y: 0,
x: -50,
duration: 300,
easing: cubicOut,
opacity: 0.2,
};
// Track if the user has actually scrolled away from view // Track if the user has actually scrolled away from view
let isScrolledPast = $state(false); let isScrolledPast = $state(false);
@@ -72,7 +99,8 @@ $effect(() => {
return; return;
} }
let cleanup: ((index: number) => void) | undefined; let cleanup: ((index: number) => void) | undefined;
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(
entries => {
const entry = entries[0]; const entry = entries[0];
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0; const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
@@ -80,10 +108,12 @@ $effect(() => {
isScrolledPast = isPast; isScrolledPast = isPast;
cleanup = onTitleStatusChange?.(index, isPast, title, id); cleanup = onTitleStatusChange?.(index, isPast, title, id);
} }
}, { },
{
// Set threshold to 0 to trigger exactly when the last pixel leaves // Set threshold to 0 to trigger exactly when the last pixel leaves
threshold: 0, threshold: 0,
}); },
);
observer.observe(titleContainer); observer.observe(titleContainer);
return () => { return () => {
@@ -94,20 +124,32 @@ $effect(() => {
</script> </script>
<section <section
id={id} {id}
class={cn( class={cn(
'flex flex-col', '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]',
className, className,
)} )}
in:fly={flyParams} in:fly={flyParams}
out:fly={flyParams} out:fly={flyParams}
> >
<div class="flex flex-col gap-2 sm:gap-3" bind:this={titleContainer}> <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}
>
<div class="flex items-center gap-2 sm:gap-3"> <div class="flex items-center gap-2 sm:gap-3">
{#if icon} {#if icon}
{@render icon({ className: 'size-3 sm:size-4 stroke-foreground stroke-1 opacity-60' })} {@render icon({
className: 'size-3 sm:size-4 stroke-foreground stroke-1 opacity-60',
})}
<div class="w-px h-2.5 sm:h-3 bg-border-subtle"></div> <div class="w-px h-2.5 sm:h-3 bg-border-subtle"></div>
{/if} {/if}
{#if description} {#if description}
<Footnote> <Footnote>
{#snippet render({ class: className })} {#snippet render({ class: className })}
@@ -129,5 +171,9 @@ $effect(() => {
{/if} {/if}
</div> </div>
{@render children?.()} {@render content?.({
className: stickyTitle
? 'row-start-2 col-start-2'
: 'row-start-2 col-start-2',
})}
</section> </section>