Merge pull request #777 from SameerJS6/add-lina

feat: add Lina (Improved ScrollArea)
This commit is contained in:
Pablo Hdez
2025-09-26 16:06:12 +01:00
committed by GitHub
11 changed files with 354 additions and 35 deletions
+1 -1
View File
@@ -69,7 +69,7 @@
"@sveltejs/vite-plugin-svelte": "6.1.3", "@sveltejs/vite-plugin-svelte": "6.1.3",
"@tailwindcss/vite": "4.1.12", "@tailwindcss/vite": "4.1.12",
"@types/node": "24.3.0", "@types/node": "24.3.0",
"bits-ui": "2.9.4", "bits-ui": "2.11.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"eslint": "9.33.0", "eslint": "9.33.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
+30 -20
View File
@@ -79,8 +79,8 @@ importers:
specifier: 24.3.0 specifier: 24.3.0
version: 24.3.0 version: 24.3.0
bits-ui: bits-ui:
specifier: 2.9.4 specifier: 2.11.3
version: 2.9.4(@internationalized/date@3.8.2)(svelte@5.38.2) version: 2.11.3(@internationalized/date@3.8.2)(svelte@5.38.2)
clsx: clsx:
specifier: 2.1.1 specifier: 2.1.1
version: 2.1.1 version: 2.1.1
@@ -1127,8 +1127,8 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
bits-ui@2.9.4: bits-ui@2.11.3:
resolution: {integrity: sha512-Cqn685P6DDuEyBZT/CWMyS5+8JAnYbctvoEVPcmiut+HUpG3SozVgjoDaUib5VG4ZYUKEi1FPwHxiXo9c6J0PA==} resolution: {integrity: sha512-XhTuOgcgFatw2qxd+hIlu3EqfYu9R2FFMeJg8srZo3UVKBdOVWHbT0L3GBH46XsaESkTz3aEjU+BiGVoI/2rnQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
peerDependencies: peerDependencies:
'@internationalized/date': ^3.8.1 '@internationalized/date': ^3.8.1
@@ -2661,6 +2661,11 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.7.0 svelte: ^5.7.0
runed@0.31.1:
resolution: {integrity: sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==}
peerDependencies:
svelte: ^5.7.0
sade@1.8.1: sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2863,18 +2868,18 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
svelte-toolbelt@0.10.5:
resolution: {integrity: sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==}
engines: {node: '>=18', pnpm: '>=8.7.0'}
peerDependencies:
svelte: ^5.30.2
svelte-toolbelt@0.7.1: svelte-toolbelt@0.7.1:
resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==} resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==}
engines: {node: '>=18', pnpm: '>=8.7.0'} engines: {node: '>=18', pnpm: '>=8.7.0'}
peerDependencies: peerDependencies:
svelte: ^5.0.0 svelte: ^5.0.0
svelte-toolbelt@0.9.3:
resolution: {integrity: sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==}
engines: {node: '>=18', pnpm: '>=8.7.0'}
peerDependencies:
svelte: ^5.30.2
svelte@5.38.2: svelte@5.38.2:
resolution: {integrity: sha512-iAcp/oFAWauVSGILdD67n7DiwgLHXZzWZIdzl7araRxu72jUr7PFAo2Iie7gXt0IbnlYvhxCb9GT3ZJUquO3PA==} resolution: {integrity: sha512-iAcp/oFAWauVSGILdD67n7DiwgLHXZzWZIdzl7araRxu72jUr7PFAo2Iie7gXt0IbnlYvhxCb9GT3ZJUquO3PA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4173,15 +4178,15 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
bits-ui@2.9.4(@internationalized/date@3.8.2)(svelte@5.38.2): bits-ui@2.11.3(@internationalized/date@3.8.2)(svelte@5.38.2):
dependencies: dependencies:
'@floating-ui/core': 1.7.3 '@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4 '@floating-ui/dom': 1.7.4
'@internationalized/date': 3.8.2 '@internationalized/date': 3.8.2
esm-env: 1.2.2 esm-env: 1.2.2
runed: 0.29.2(svelte@5.38.2) runed: 0.31.1(svelte@5.38.2)
svelte: 5.38.2 svelte: 5.38.2
svelte-toolbelt: 0.9.3(svelte@5.38.2) svelte-toolbelt: 0.10.5(svelte@5.38.2)
tabbable: 6.2.0 tabbable: 6.2.0
body-parser@2.2.0: body-parser@2.2.0:
@@ -5775,6 +5780,11 @@ snapshots:
esm-env: 1.2.2 esm-env: 1.2.2
svelte: 5.38.2 svelte: 5.38.2
runed@0.31.1(svelte@5.38.2):
dependencies:
esm-env: 1.2.2
svelte: 5.38.2
sade@1.8.1: sade@1.8.1:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
@@ -6023,6 +6033,13 @@ snapshots:
runed: 0.28.0(svelte@5.38.2) runed: 0.28.0(svelte@5.38.2)
svelte: 5.38.2 svelte: 5.38.2
svelte-toolbelt@0.10.5(svelte@5.38.2):
dependencies:
clsx: 2.1.1
runed: 0.29.2(svelte@5.38.2)
style-to-object: 1.0.9
svelte: 5.38.2
svelte-toolbelt@0.7.1(svelte@5.38.2): svelte-toolbelt@0.7.1(svelte@5.38.2):
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
@@ -6030,13 +6047,6 @@ snapshots:
style-to-object: 1.0.9 style-to-object: 1.0.9
svelte: 5.38.2 svelte: 5.38.2
svelte-toolbelt@0.9.3(svelte@5.38.2):
dependencies:
clsx: 2.1.1
runed: 0.29.2(svelte@5.38.2)
style-to-object: 1.0.9
svelte: 5.38.2
svelte@5.38.2: svelte@5.38.2:
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
+15 -5
View File
@@ -4,6 +4,8 @@
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import ShowCategories from "@/components/layout/showCategories.svelte"; import ShowCategories from "@/components/layout/showCategories.svelte";
import ShowSidebarLinks from "@/components/layout/showSidebarLinks.svelte"; import ShowSidebarLinks from "@/components/layout/showSidebarLinks.svelte";
import ScrollArea from "@/components/ui/scroll-area/scroll-area.svelte";
import ScrollAreaScrollbar from "@/components/ui/scroll-area/scroll-area-scrollbar.svelte";
</script> </script>
<section> <section>
@@ -11,17 +13,25 @@
class={cn( class={cn(
"md:fixed md:left-1 md:h-[calc(100vh-4.5rem)]", "md:fixed md:left-1 md:h-[calc(100vh-4.5rem)]",
"overflow-x-hidden", "overflow-x-hidden",
"w-54 pr-2 pl-2", "w-54",
"hidden flex-col space-y-3 md:flex", "hidden flex-col space-y-3 md:flex",
"bg-neutral-100 dark:bg-neutral-950", "bg-neutral-100 dark:bg-neutral-950",
)} )}
> >
<nav class="flex flex-col space-y-0.5"> <nav class="flex flex-col space-y-0.5 px-2">
<ShowSidebarLinks /> <ShowSidebarLinks />
</nav> </nav>
<Separator orientation="horizontal" /> <div class="px-2">
<nav class="relative flex h-auto flex-col space-y-0.5 overflow-y-auto"> <Separator orientation="horizontal" />
<ShowCategories /> </div>
<nav class="relative flex h-auto flex-col space-y-0.5 overflow-hidden">
<ScrollArea
maskClassName="before:from-neutral-100 after:from-neutral-100 dark:before:from-neutral-950 dark:after:from-neutral-950"
class="flex size-full flex-col gap-y-0.5 overflow-hidden px-2"
>
<ShowCategories />
<ScrollAreaScrollbar orientation="vertical" />
</ScrollArea>
</nav> </nav>
</aside> </aside>
<main class={cn("px-2 md:mr-4 md:ml-56 md:px-0", "overflow-hidden")}> <main class={cn("px-2 md:mr-4 md:ml-56 md:px-0", "overflow-hidden")}>
@@ -10,6 +10,7 @@
import ShowCategories from "@/components/layout/showCategories.svelte"; import ShowCategories from "@/components/layout/showCategories.svelte";
import ShowSidebarLinks from "@/components/layout/showSidebarLinks.svelte"; import ShowSidebarLinks from "@/components/layout/showSidebarLinks.svelte";
import ScrollArea from "@/components/ui/scroll-area/scroll-area.svelte";
interface Props { interface Props {
className?: string; className?: string;
@@ -33,10 +34,14 @@
<h2 class="text-xl font-medium tracking-tight">svgl</h2> <h2 class="text-xl font-medium tracking-tight">svgl</h2>
</Sheet.Title> </Sheet.Title>
</Sheet.Header> </Sheet.Header>
<nav class="flex flex-col space-y-0.5 overflow-y-auto px-3 pb-3"> <ScrollArea
class="flex size-full flex-col"
viewportClassName="pb-3 px-3 space-y-0.5"
maskClassName="before:from-white after:from-white dark:before:from-neutral-900 dark:after:from-neutral-900"
>
<ShowSidebarLinks /> <ShowSidebarLinks />
<Separator orientation="horizontal" class="my-3" /> <Separator orientation="horizontal" class="my-3" />
<ShowCategories /> <ShowCategories />
</nav> </ScrollArea>
</Sheet.Content> </Sheet.Content>
</Sheet.Root> </Sheet.Root>
+8 -4
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import ScrollArea from "./ui/scroll-area/scroll-area.svelte";
import { ScrollAreaScrollbar } from "./ui/scroll-area";
interface PageCardProps { interface PageCardProps {
children: Snippet; children: Snippet;
@@ -21,14 +23,16 @@
containerClass, containerClass,
)} )}
> >
<div <ScrollArea
maskHeight={50}
maskClassName="before:from-transparent after:from-white dark:before:from-[#0f0f0f] dark:after:from-[#0f0f0f]"
class={cn( class={cn(
"max-h-[calc(100vh-4.5rem)] min-h-[calc(100vh-4.5rem)]", "flex size-full max-h-[calc(100vh-4.5rem)] min-h-[calc(100vh-4.5rem)] flex-col",
"overflow-hidden overflow-y-auto",
contentCardClass, contentCardClass,
)} )}
> >
<ScrollAreaScrollbar orientation="vertical" class="relative z-99999" />
{@render children?.()} {@render children?.()}
</div> </ScrollArea>
</div> </div>
</div> </div>
+13
View File
@@ -0,0 +1,13 @@
import Scrollbar from "./scroll-area-scrollbar.svelte";
import Root from "./scroll-area.svelte";
import Mask from "./scroll-area-mask.svelte";
export {
Root,
Scrollbar,
Mask,
//
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar,
Mask as ScrollAreaMask,
};
@@ -0,0 +1,50 @@
<script lang="ts">
import { cn } from "@/utils/cn";
type Mask = {
top: boolean;
bottom: boolean;
left: boolean;
right: boolean;
};
let {
showMask,
maskHeight,
class: className = "",
}: {
showMask: Mask;
maskHeight: number;
class?: string;
} = $props();
</script>
<div
aria-hidden="true"
style={`--top-fade-height: ${showMask.top ? `${maskHeight}px` : "0px"}; --bottom-fade-height: ${showMask.bottom ? `${maskHeight}px` : "0px"};`}
class={cn(
"pointer-events-none absolute inset-0 z-10",
"before:absolute before:inset-x-0 before:top-0 before:transition-[height,opacity] before:duration-300 before:content-['']",
"after:absolute after:inset-x-0 after:bottom-0 after:transition-[height,opacity] after:duration-300 after:content-['']",
"before:h-(--top-fade-height) after:h-(--bottom-fade-height)",
showMask.top ? "before:opacity-100" : "before:opacity-0",
showMask.bottom ? "after:opacity-100" : "after:opacity-0",
"before:from-background before:bg-gradient-to-b before:to-transparent",
"after:from-background after:bg-gradient-to-t after:to-transparent",
className,
)}
></div>
<div
aria-hidden="true"
style={`--left-fade-width: ${showMask.left ? `${maskHeight}px` : "0px"}; --right-fade-width: ${showMask.right ? `${maskHeight}px` : "0px"};`}
class={cn(
"pointer-events-none absolute inset-0 z-10",
"before:absolute before:inset-y-0 before:left-0 before:transition-[width,opacity] before:duration-300 before:content-['']",
"after:absolute after:inset-y-0 after:right-0 after:transition-[width,opacity] after:duration-300 after:content-['']",
"before:w-(--left-fade-width) after:w-(--right-fade-width)",
showMask.left ? "before:opacity-100" : "before:opacity-0",
showMask.right ? "after:opacity-100" : "after:opacity-0",
"before:from-background before:bg-gradient-to-r before:to-transparent",
"after:from-background after:bg-gradient-to-l after:to-transparent",
className,
)}
></div>
@@ -0,0 +1,44 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "@/utils/cn";
import { useHasPrimaryTouch } from "@/hooks/use-has-primary-touch";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
children,
thumbClassName,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> & {
thumbClassName?: string;
} = $props();
const hasPrimaryTouch = useHasPrimaryTouch();
</script>
{#if !$hasPrimaryTouch}
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
{orientation}
class={cn(
"flex touch-none p-px transition-[colors] duration-150 select-none hover:bg-neutral-200 data-[state=hidden]:animate-out data-[state=hidden]:fade-out-0 data-[state=visible]:animate-in data-[state=visible]:fade-in-0 dark:hover:bg-neutral-900",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent px-1 pr-1.25",
className,
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class={cn(
"relative my-0.5 flex-1 rounded-full bg-neutral-300 transition-colors ease-out hover:bg-neutral-500/50 active:bg-neutral-500/75 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600",
thumbClassName,
)}
/>
</ScrollAreaPrimitive.Scrollbar>
{/if}
@@ -0,0 +1,141 @@
<script lang="ts">
import { cn, type WithoutChild } from "@/utils/cn";
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
import { Scrollbar } from "./index.js";
import ScrollAreaMask from "./scroll-area-mask.svelte";
import { useHasPrimaryTouch } from "@/hooks/use-has-primary-touch";
type Mask = {
top: boolean;
bottom: boolean;
left: boolean;
right: boolean;
};
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
scrollbarXClasses = "",
scrollbarYClasses = "",
children,
scrollHideDelay = 0,
maskHeight = 30,
maskClassName = "",
viewportClassName = "",
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: "vertical" | "horizontal" | "both" | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
maskHeight?: number;
maskClassName?: string;
viewportClassName?: string;
} = $props();
let viewportRef: HTMLDivElement | null = $state(null);
const showMask = $state<Mask>({
top: true,
bottom: true,
left: true,
right: true,
});
const hasPrimaryTouchStore = useHasPrimaryTouch();
const checkScrollability = () => {
const element = viewportRef;
if (!element) {
return;
}
const {
scrollTop,
scrollLeft,
scrollWidth,
clientWidth,
scrollHeight,
clientHeight,
} = element;
showMask.top = scrollTop > 0;
showMask.bottom = scrollTop + clientHeight < scrollHeight - 1;
showMask.left = scrollLeft > 0;
showMask.right = scrollLeft + clientWidth < scrollWidth - 1;
};
$effect(() => {
const element = viewportRef;
if (!element) {
return;
}
const controller = new AbortController();
const { signal } = controller;
const resizeObserver = new ResizeObserver(checkScrollability);
resizeObserver.observe(element);
element.addEventListener("scroll", checkScrollability, { signal });
window.addEventListener("resize", checkScrollability, { signal });
checkScrollability();
return () => {
controller.abort();
resizeObserver.disconnect();
};
});
</script>
{#if $hasPrimaryTouchStore}
<div
bind:this={ref}
role="group"
data-slot="scroll-area"
aria-roledescription="scroll area"
class={cn("relative overflow-hidden", className)}
{...restProps}
>
<div
bind:this={viewportRef}
data-slot="scroll-area-viewport"
class={cn("size-full overflow-auto rounded-[inherit]", viewportClassName)}
tabIndex={0}
>
{@render children?.()}
</div>
{#if maskHeight > 0}
<ScrollAreaMask {showMask} class={maskClassName} {maskHeight} />{/if}
</div>
{:else}
<ScrollAreaPrimitive.Root
bind:ref
{scrollHideDelay}
data-slot="scroll-area"
class={cn("relative", className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class={cn("focus-ring size-full rounded-[inherit]", viewportClassName)}
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if maskHeight > 0}
<ScrollAreaMask {maskHeight} class={maskClassName} {showMask} />
{/if}
{#if orientation === "vertical" || orientation === "both"}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === "horizontal" || orientation === "both"}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
{/if}
+31
View File
@@ -0,0 +1,31 @@
import { browser } from "$app/environment";
import { writable } from "svelte/store";
export function useHasPrimaryTouch() {
const { subscribe, set } = writable(false);
if (!browser) {
return {
subscribe,
destroy: () => {},
};
}
const controller = new AbortController();
const { signal } = controller;
const handleTouch = () => {
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const prefersTouch = window.matchMedia("(pointer: coarse)").matches;
set(hasTouch && prefersTouch);
};
const mq = window.matchMedia("(pointer: coarse)");
mq.addEventListener("change", handleTouch, { signal });
window.addEventListener("pointerdown", handleTouch, { signal });
handleTouch();
return {
subscribe,
destroy: () => controller.abort(),
};
}
+14 -3
View File
@@ -1,6 +1,17 @@
import { clsx, type ClassValue } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};