Add ScrollArea and Scrollbar components for enhanced scroll functionality

This commit is contained in:
SameerJS6
2025-09-26 08:59:15 +05:30
parent a4232532bd
commit c4cfc1017f
2 changed files with 185 additions and 0 deletions
@@ -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}