diff --git a/.changeset/new-coats-join.md b/.changeset/new-coats-join.md new file mode 100644 index 0000000..e2f0eb9 --- /dev/null +++ b/.changeset/new-coats-join.md @@ -0,0 +1,6 @@ +--- +"@kunai-consulting/qwik-components": patch +"qwik-design-system-docs": patch +--- + +Add the scroll-area component diff --git a/apps/docs/src/routes/index.mdx b/apps/docs/src/routes/index.mdx index 6e62983..f2bad95 100644 --- a/apps/docs/src/routes/index.mdx +++ b/apps/docs/src/routes/index.mdx @@ -10,3 +10,4 @@ Here is some MDX - [Checkbox](checkbox) - [Pagination](pagination) - [OTP](otp) +- [Scroll Area](scroll-area) diff --git a/apps/docs/src/routes/menu.md b/apps/docs/src/routes/menu.md index 37a47b7..56c8d20 100644 --- a/apps/docs/src/routes/menu.md +++ b/apps/docs/src/routes/menu.md @@ -5,6 +5,7 @@ - [Checkbox](/checkbox) - [Pagination](/pagination) - [OTP](/otp) +- [Scroll Area](/scroll-area) ## Styled diff --git a/apps/docs/src/routes/scroll-area/examples/both.tsx b/apps/docs/src/routes/scroll-area/examples/both.tsx new file mode 100644 index 0000000..07e3f3e --- /dev/null +++ b/apps/docs/src/routes/scroll-area/examples/both.tsx @@ -0,0 +1,33 @@ +import { component$, useStyles$ } from "@builder.io/qwik"; +import { ScrollArea } from "@kunai-consulting/qwik-components"; +import styles from './scroll-area.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( + + +
+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dignissimos + impedit rem, repellat deserunt ducimus quasi nisi voluptatem cumque + aliquid esse ea deleniti eveniet incidunt! Deserunt minus laborum + accusamus iusto dolorum. Lorem ipsum dolor sit, amet consectetur + adipisicing elit. Blanditiisofficiiserrorminimaeos fugit voluptate + excepturi eveniet dolore et, ratione impedit consequuntur dolorem hic + quae corrupti autem? Dolorem, sit voluptatum. +

+
+
+ + + + + + + + +
+ ); +}); + diff --git a/apps/docs/src/routes/scroll-area/examples/horizontal-test.tsx b/apps/docs/src/routes/scroll-area/examples/horizontal-test.tsx new file mode 100644 index 0000000..edabd6b --- /dev/null +++ b/apps/docs/src/routes/scroll-area/examples/horizontal-test.tsx @@ -0,0 +1,29 @@ +import { component$, useStyles$ } from "@builder.io/qwik"; +import { ScrollArea } from "@kunai-consulting/qwik-components"; +import styles from './scroll-area.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( + + +
+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dignissimos + impedit rem, repellat deserunt ducimus quasi nisi voluptatem cumque + aliquid esse ea deleniti eveniet incidunt! Deserunt minus laborum + accusamus iusto dolorum. Lorem ipsum dolor sit, amet consectetur + adipisicing elit. Blanditiisofficiiserror minima eos fugit voluptate + excepturi eveniet dolore et, ratione impedit consequuntur dolorem hic + quae corrupti autem? Dolorem, sit voluptatum. +

+
+
+ + + + +
+ ); +}); + diff --git a/apps/docs/src/routes/scroll-area/examples/horizontal.tsx b/apps/docs/src/routes/scroll-area/examples/horizontal.tsx new file mode 100644 index 0000000..3510df6 --- /dev/null +++ b/apps/docs/src/routes/scroll-area/examples/horizontal.tsx @@ -0,0 +1,29 @@ +import { component$, useStyles$ } from "@builder.io/qwik"; +import { ScrollArea } from "@kunai-consulting/qwik-components"; +import styles from './scroll-area.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( + + +
+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dignissimos + impedit rem, repellat deserunt ducimus quasi nisi voluptatem cumque + aliquid esse ea deleniti eveniet incidunt! Deserunt minus laborum + accusamus iusto dolorum. Lorem ipsum dolor sit, amet consectetur + adipisicing elit. Blanditiis officiis error minima eos fugit voluptate + excepturi eveniet dolore et, ratione impedit consequuntur dolorem hic + quae corrupti autem? Dolorem, sit voluptatum. +

+
+
+ + + + +
+ ); +}); + diff --git a/apps/docs/src/routes/scroll-area/examples/scroll-area.css b/apps/docs/src/routes/scroll-area/examples/scroll-area.css new file mode 100644 index 0000000..261ee68 --- /dev/null +++ b/apps/docs/src/routes/scroll-area/examples/scroll-area.css @@ -0,0 +1,64 @@ +.scroll-area-root { + background: #0a4d70; + position: relative; + overflow: hidden; + width: 100%; + height: 100%; + --thumb-size: 12px; +} + +.scroll-area-viewport { + width: 100%; + height: 100%; + border-radius: inherit; + overflow: scroll; + position: relative; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +/* Hide default scrollbars */ +.scroll-area-viewport::-webkit-scrollbar { + display: none; +} + +.scroll-area-scrollbar { + display: flex; + touch-action: none; + user-select: none; + padding: 2px; + background: rgba(0, 0, 0, 0.05); + transition: background 160ms ease-out; + position: absolute; +} + +.scroll-area-scrollbar[data-orientation='vertical'] { + width: 16px; + height: 100%; + right: 0; + top: 0; +} + +.scroll-area-scrollbar[data-orientation='horizontal'] { + height: 16px; + width: calc(100% - var(--thumb-size)); + bottom: 0; + left: 0; +} + +.scroll-area-thumb { + position: relative; + height: var(--thumb-size); + width: var(--thumb-size); + background: rgba(0, 0, 0, 0.3); + border-radius: 9999px; + transition: background 160ms ease-out; +} + +.scroll-area-thumb:hover { + background: rgba(0, 0, 0, 0.5); +} + +.scroll-area-thumb[data-dragging] { + background: rgba(0, 0, 0, 0.7); +} diff --git a/apps/docs/src/routes/scroll-area/examples/vertical-test.tsx b/apps/docs/src/routes/scroll-area/examples/vertical-test.tsx new file mode 100644 index 0000000..4fc6e59 --- /dev/null +++ b/apps/docs/src/routes/scroll-area/examples/vertical-test.tsx @@ -0,0 +1,28 @@ +import { component$, useStyles$ } from "@builder.io/qwik"; +import { ScrollArea } from "@kunai-consulting/qwik-components"; +import styles from "./scroll-area.css?inline"; + +export default component$(() => { + useStyles$(styles); + return ( + + + {/* Content with fixed height to ensure scrolling */} +
+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dignissimos impedit + rem, repellat deserunt ducimus quasi nisi voluptatem cumque aliquid esse ea + deleniti eveniet incidunt! Deserunt minus laborum accusamus iusto dolorum. + Lorem ipsum dolor sit, amet consectetur adipisicing elit. Blanditiis officiis + error minima eos fugit voluptate excepturi eveniet dolore et, ratione impedit + consequuntur dolorem hic quae corrupti autem? Dolorem, sit voluptatum. +

+
+
+ + + + +
+ ); +}); diff --git a/apps/docs/src/routes/scroll-area/examples/vertical.tsx b/apps/docs/src/routes/scroll-area/examples/vertical.tsx new file mode 100644 index 0000000..7b7bac9 --- /dev/null +++ b/apps/docs/src/routes/scroll-area/examples/vertical.tsx @@ -0,0 +1,30 @@ +import { component$, useStyles$ } from "@builder.io/qwik"; +import { ScrollArea } from "@kunai-consulting/qwik-components"; +import styles from './scroll-area.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( + + +
+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Dignissimos + impedit rem, repellat deserunt ducimus quasi nisi voluptatem cumque + aliquid esse ea deleniti eveniet incidunt! Deserunt minus laborum + accusamus iusto dolorum. Lorem ipsum dolor sit, amet consectetur + adipisicing elit. Blanditiis officiis error minima eos fugit voluptate + excepturi eveniet dolore et, ratione impedit consequuntur dolorem hic + quae corrupti autem? Dolorem, sit voluptatum. +

+
+
+ + + + + +
+ ); +}); + diff --git a/apps/docs/src/routes/scroll-area/index.mdx b/apps/docs/src/routes/scroll-area/index.mdx new file mode 100644 index 0000000..465e5b1 --- /dev/null +++ b/apps/docs/src/routes/scroll-area/index.mdx @@ -0,0 +1,60 @@ +--- +title: Qwik Design System | Scroll Area +--- + +# Scroll Area + +A scrollable container that provides a custom-styled scrollbar with support for both vertical and horizontal scrolling. + + + +## Basic Usage + +The Scroll Area component provides a customizable scrolling container that replaces the browser's default scrollbars with a more aesthetically pleasing design. + +## Anatomy + +The Scroll Area component is composed of several parts: + +```tsx + + + {/* Your content */} + + + + + + + + +``` + +## Vertical Scrolling +By default, Scroll Area supports vertical scrolling when content exceeds the container height. + + + +## Horizontal Scrolling +To enable horizontal scrolling, add the horizontal scrollbar component. + + +## Both Directions +Scroll Area supports both vertical and horizontal scrolling simultaneously. + + +## Custom Styling +The Scroll Area component can be styled using CSS classes. + +## Sizing +The Scroll Area component adapts to its container size. You can control its dimensions using standard CSS properties. + +## Hidden Scrollbars +The Scroll Area component automatically hides the native browser scrollbars while maintaining scroll functionality. + +## Scroll Thumb Behavior +The scroll thumb responds to: + +* Direct dragging +* Click and drag interactions +* Content scrolling diff --git a/libs/components/src/checklist/checklist-item.tsx b/libs/components/src/checklist/checklist-item.tsx index eaf5ece..bcf3313 100644 --- a/libs/components/src/checklist/checklist-item.tsx +++ b/libs/components/src/checklist/checklist-item.tsx @@ -8,12 +8,12 @@ import { useSignal, useTask$, useVisibleTask$, - $, -} from '@builder.io/qwik'; -import { CheckboxRoot } from '../checkbox/checkbox-root'; -import { ChecklistContext, type ChecklistState } from './checklist-context'; + $ +} from "@builder.io/qwik"; +import { CheckboxRoot } from "../checkbox/checkbox-root"; +import { ChecklistContext, type ChecklistState } from "./checklist-context"; -interface ChecklistItemProps extends PropsOf<'div'> { +interface ChecklistItemProps extends PropsOf<"div"> { _index?: number; } @@ -21,7 +21,7 @@ export const ChecklistItem = component$((props: ChecklistItemProps) => { const { _index, ...rest } = props; if (_index === undefined) { - throw new Error('Checklist Item must have an index.'); + throw new Error("Checklist Item must have an index."); } const context = useContext(ChecklistContext); diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index e77af7d..377f253 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -2,3 +2,4 @@ export * as Otp from './otp'; export * as Checkbox from './checkbox'; export * as Checklist from './checklist'; export * as Pagination from "./pagination"; +export * as ScrollArea from './scroll-area'; diff --git a/libs/components/src/scroll-area/index.ts b/libs/components/src/scroll-area/index.ts new file mode 100644 index 0000000..1121992 --- /dev/null +++ b/libs/components/src/scroll-area/index.ts @@ -0,0 +1,4 @@ +export { ScrollAreaRoot as Root } from './scroll-area-root'; +export { ScrollAreaViewPort as ViewPort } from './scroll-area-view-port'; +export { ScrollAreaScrollbar as Scrollbar } from './scroll-area-scrollbar'; +export { ScrollAreaThumb as Thumb } from './scroll-area-thumb'; \ No newline at end of file diff --git a/libs/components/src/scroll-area/research.mdx b/libs/components/src/scroll-area/research.mdx new file mode 100644 index 0000000..38288c7 --- /dev/null +++ b/libs/components/src/scroll-area/research.mdx @@ -0,0 +1,18 @@ +# Headless ScrollArea Component + +## API + +``` + + + {/* data */} + + {/* horizontal */} + + + {/* vertical */} + + + + +``` \ No newline at end of file diff --git a/libs/components/src/scroll-area/scroll-area-context.ts b/libs/components/src/scroll-area/scroll-area-context.ts new file mode 100644 index 0000000..df64c68 --- /dev/null +++ b/libs/components/src/scroll-area/scroll-area-context.ts @@ -0,0 +1,10 @@ +import { createContextId, type Signal } from "@builder.io/qwik"; + +export const scrollAreaContextId = + createContextId("scroll-area-context"); + +export interface ScrollAreaContext { + scrollbarRef: Signal; + thumbRef: Signal; + viewportRef: Signal; +} diff --git a/libs/components/src/scroll-area/scroll-area-root.tsx b/libs/components/src/scroll-area/scroll-area-root.tsx new file mode 100644 index 0000000..8c1d7b4 --- /dev/null +++ b/libs/components/src/scroll-area/scroll-area-root.tsx @@ -0,0 +1,30 @@ +import { + component$, + type PropsOf, + Slot, + useContextProvider, + useSignal +} from "@builder.io/qwik"; +import { scrollAreaContextId } from "./scroll-area-context"; + +type RootProps = PropsOf<"div">; + +export const ScrollAreaRoot = component$((props) => { + const viewportRef = useSignal(); + const scrollbarRef = useSignal(); + const thumbRef = useSignal(); + + const context = { + viewportRef, + scrollbarRef, + thumbRef + }; + + useContextProvider(scrollAreaContextId, context); + + return ( +
+ +
+ ); +}); diff --git a/libs/components/src/scroll-area/scroll-area-scrollbar.tsx b/libs/components/src/scroll-area/scroll-area-scrollbar.tsx new file mode 100644 index 0000000..bb293cd --- /dev/null +++ b/libs/components/src/scroll-area/scroll-area-scrollbar.tsx @@ -0,0 +1,63 @@ +import { + $, + component$, + type PropsOf, + type QRL, + Slot, + useContext, + useSignal +} from "@builder.io/qwik"; +import { scrollAreaContextId } from "./scroll-area-context"; + +type ScrollBarType = PropsOf<"div"> & { + orientation?: "vertical" | "horizontal"; + onScroll$?: QRL<(e: Event) => void>; +}; + +export const ScrollAreaScrollbar = component$((props) => { + const context = useContext(scrollAreaContextId); + const { orientation = "vertical" } = props; + + const onTrackClick$ = $((e: MouseEvent) => { + const scrollbar = context.scrollbarRef.value; + if (!scrollbar) return; + + const viewport = context.viewportRef.value; + const thumb = context.thumbRef.value; + + if (!thumb || e.target === thumb) return; + if (!viewport) return; + + const rect = scrollbar.getBoundingClientRect(); + + if (orientation === "vertical") { + const clickPos = e.clientY - rect.top; + // Calculate click position as a ratio of the scrollbar height + const scrollRatio = clickPos / rect.height; + // Calculate the maximum scroll position + const maxScroll = viewport.scrollHeight - viewport.clientHeight; + // Set scroll position directly based on ratio + viewport.scrollTop = scrollRatio * maxScroll; + } else { + const clickPos = e.clientX - rect.left; + // Calculate click position as a ratio of the scrollbar width + const scrollRatio = clickPos / rect.width; + // Calculate the maximum scroll position + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + // Set scroll position directly based on ratio + viewport.scrollLeft = scrollRatio * maxScroll; + } + }); + + return ( +
+ +
+ ); +}); diff --git a/libs/components/src/scroll-area/scroll-area-thumb.tsx b/libs/components/src/scroll-area/scroll-area-thumb.tsx new file mode 100644 index 0000000..83593c6 --- /dev/null +++ b/libs/components/src/scroll-area/scroll-area-thumb.tsx @@ -0,0 +1,98 @@ +import { + component$, + $, + useSignal, + useOnDocument, + type Signal, + type PropsOf, + useContext +} from "@builder.io/qwik"; +import type { PropFunction } from "@builder.io/qwik"; +import { scrollAreaContextId } from "./scroll-area-context"; + +type ScrollAreaThumb = { + ref?: Signal; + onDragStart$?: PropFunction<(e: MouseEvent) => void>; + onDragMove$?: PropFunction<(e: MouseEvent) => void>; + onDragEnd$?: PropFunction<() => void>; +} & PropsOf<"div">; + +export const ScrollAreaThumb = component$((props) => { + const context = useContext(scrollAreaContextId); + const isDragging = useSignal(false); + const dragData = useSignal({ + startClientY: 0, + startClientX: 0, + startScrollTop: 0, + startScrollLeft: 0 + }); + + const onDragStart$ = $((e: MouseEvent) => { + const thumb = context.thumbRef.value; + const scrollbar = context.scrollbarRef.value; + const viewport = context.viewportRef.value; + + if (!scrollbar) return; + if (!viewport) return; + if (!thumb) return; + + e.preventDefault(); + isDragging.value = true; + + dragData.value = { + startClientY: e.clientY, + startClientX: e.clientX, + startScrollTop: viewport.scrollTop, + startScrollLeft: viewport.scrollLeft + }; + }); + + const onDragMove$ = $((e: MouseEvent) => { + if (!isDragging.value) return; + + const thumb = context.thumbRef.value; + const scrollbar = context.scrollbarRef.value; + const viewport = context.viewportRef.value; + + if (!scrollbar) return; + if (!viewport) return; + if (!thumb) return; + + const isVertical = scrollbar.getAttribute("data-orientation") === "vertical"; + + e.preventDefault(); + + if (isVertical) { + const deltaY = e.clientY - dragData.value.startClientY; + const scrollbarHeight = scrollbar.clientHeight; + const thumbHeight = thumb.clientHeight; + const scrollRatio = + (viewport.scrollHeight - viewport.clientHeight) / (scrollbarHeight - thumbHeight); + viewport.scrollTop = dragData.value.startScrollTop + deltaY * scrollRatio; + } else { + const deltaX = e.clientX - dragData.value.startClientX; + const scrollbarWidth = scrollbar.clientWidth; + const thumbWidth = thumb.clientWidth; + const scrollRatio = + (viewport.scrollWidth - viewport.clientWidth) / (scrollbarWidth - thumbWidth); + viewport.scrollLeft = dragData.value.startScrollLeft + deltaX * scrollRatio; + } + }); + + const onDragEnd$ = $(() => { + isDragging.value = false; + }); + + useOnDocument("mousemove", onDragMove$); + useOnDocument("mouseup", onDragEnd$); + + return ( +
+ ); +}); diff --git a/libs/components/src/scroll-area/scroll-area-view-port.tsx b/libs/components/src/scroll-area/scroll-area-view-port.tsx new file mode 100644 index 0000000..5751b06 --- /dev/null +++ b/libs/components/src/scroll-area/scroll-area-view-port.tsx @@ -0,0 +1,56 @@ +import { + $, + component$, + type PropFunction, + type PropsOf, + Slot, + useContext +} from "@builder.io/qwik"; +import { scrollAreaContextId } from "./scroll-area-context"; + +type ViewPortProps = PropsOf<"div"> & { + onScroll$?: PropFunction<(e: Event) => void>; +}; + +export const ScrollAreaViewPort = component$((props) => { + const context = useContext(scrollAreaContextId); + const onScroll$ = $((e: Event) => { + const viewport = e.target as HTMLElement; + const scrollbars = Array.from( + viewport.parentElement?.querySelectorAll("[data-scroll-area-scrollbar]") || [] + ); + + for (const scrollbar of scrollbars) { + const thumb = scrollbar.querySelector("[data-scroll-area-thumb]") as HTMLElement; + if (!thumb) return; + + const isVertical = scrollbar.getAttribute("data-orientation") === "vertical"; + + if (isVertical) { + const scrollRatio = + viewport.scrollTop / (viewport.scrollHeight - viewport.clientHeight); + const maxTop = scrollbar.clientHeight - thumb.clientHeight; + thumb.style.transform = `translateY(${scrollRatio * maxTop}px)`; + } else { + const scrollRatio = + viewport.scrollLeft / (viewport.scrollWidth - viewport.clientWidth); + const maxLeft = scrollbar.clientWidth - thumb.clientWidth; + thumb.style.transform = `translateX(${scrollRatio * maxLeft}px)`; + } + } + + // Call the provided onScroll$ handler if it exists + props.onScroll$?.(e); + }); + + return ( +
+ +
+ ); +}); diff --git a/libs/components/src/scroll-area/scroll-area.driver.ts b/libs/components/src/scroll-area/scroll-area.driver.ts new file mode 100644 index 0000000..9ad111d --- /dev/null +++ b/libs/components/src/scroll-area/scroll-area.driver.ts @@ -0,0 +1,39 @@ +import type { Locator, Page } from "@playwright/test"; +export type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator.locator("[data-scroll-area-root]"); + }; + + const getViewport = () => { + return rootLocator.locator("[data-scroll-area-viewport]"); + }; + + const getVerticalScrollbar = () => { + return rootLocator.locator('[data-scroll-area-scrollbar][data-orientation="vertical"]'); + }; + + const getHorizontalScrollbar = () => { + return rootLocator.locator('[data-scroll-area-scrollbar][data-orientation="horizontal"]'); + }; + + const getVerticalThumb = () => { + return getVerticalScrollbar().locator("[data-scroll-area-thumb]"); + }; + + const getHorizontalThumb = () => { + return getHorizontalScrollbar().locator("[data-scroll-area-thumb]"); + }; + + return { + ...rootLocator, + locator: rootLocator, + getRoot, + getViewport, + getVerticalScrollbar, + getHorizontalScrollbar, + getVerticalThumb, + getHorizontalThumb, + }; +} diff --git a/libs/components/src/scroll-area/scroll-area.test.ts b/libs/components/src/scroll-area/scroll-area.test.ts new file mode 100644 index 0000000..68fbbda --- /dev/null +++ b/libs/components/src/scroll-area/scroll-area.test.ts @@ -0,0 +1,218 @@ +import { expect, test, type Page } from "@playwright/test"; +import { createTestDriver } from "./scroll-area.driver"; + +async function setup(page: Page, exampleName: string) { + await page.goto(`http://localhost:6174/scroll-area/${exampleName}`); + + const driver = createTestDriver(page); + + return driver; +} + +test.describe("critical functionality", () => { + test(`GIVEN a scroll area + WHEN content exceeds viewport height + THEN vertical scrollbar should be visible`, async ({ page }) => { + const d = await setup(page, "vertical-test"); + await expect(d.getVerticalScrollbar()).toBeVisible(); + }); + + test(`GIVEN a scroll area + WHEN content exceeds viewport width + THEN horizontal scrollbar should be visible`, async ({ page }) => { + const d = await setup(page, "horizontal-test"); + await expect(d.getHorizontalScrollbar()).toBeVisible(); + }); + + test(`GIVEN a scroll area with vertical content + WHEN scrolling to bottom + THEN thumb should move to bottom position`, async ({ page }) => { + const d = await setup(page, "vertical-test"); + const viewport = d.getViewport(); + + // Scroll to bottom + await viewport.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + + // Wait for scroll to complete and thumb to update position + await page.waitForTimeout(100); + + // Verify thumb position + const thumb = d.getVerticalThumb(); + await expect(thumb).toBeVisible(); + + // Check if thumb is at bottom position + const scrollbar = d.getVerticalScrollbar(); + const thumbBox = await thumb.boundingBox(); + const scrollbarBox = await scrollbar.boundingBox(); + + if (!thumbBox) return; + if (!scrollbarBox) return; + + // Calculate expected bottom position considering padding + const expectedBottom = scrollbarBox.y + scrollbarBox.height; + const actualBottom = thumbBox.y + thumbBox.height - 2; // 2px for padding + + expect(actualBottom).toBeCloseTo(expectedBottom, 1); + }); +}); + +test.describe("drag functionality", () => { + test(`GIVEN a scroll area + WHEN dragging the vertical thumb + THEN content should scroll accordingly`, async ({ page }) => { + const d = await setup(page, "vertical-test"); + const thumb = d.getVerticalThumb(); + const viewport = d.getViewport(); + + // Get initial scroll position + const initialScrollTop = await viewport.evaluate((el) => el.scrollTop); + + // Get thumb position + const thumbBox = await thumb.boundingBox(); + if (!thumbBox) throw new Error("Could not get thumb position"); + + // Simulate drag operation + // Start at the center of the thumb + await page.mouse.move( + thumbBox.x + thumbBox.width / 2, + thumbBox.y + thumbBox.height / 2 + ); + await page.mouse.down(); + + // Move mouse down by 100px + await page.mouse.move( + thumbBox.x + thumbBox.width / 2, + thumbBox.y + thumbBox.height / 2 + 100, + { steps: 10 } // Make movement smoother + ); + + await page.mouse.up(); + + // Wait for scrolling to complete + await page.waitForTimeout(100); + + // Verify new scroll position + const newScrollTop = await viewport.evaluate((el) => el.scrollTop); + expect(newScrollTop).toBeGreaterThan(initialScrollTop); + }); + + test(`GIVEN a scroll area + WHEN dragging the horizontal thumb + THEN content should scroll accordingly`, async ({ page }) => { + const d = await setup(page, "horizontal-test"); + const thumb = d.getHorizontalThumb(); + const viewport = d.getViewport(); + + // Get initial scroll position + const initialScrollLeft = await viewport.evaluate((el) => el.scrollLeft); + + // Get thumb position + const thumbBox = await thumb.boundingBox(); + if (!thumbBox) throw new Error("Could not get thumb position"); + + // Simulate drag operation + // Start at the center of the thumb + await page.mouse.move( + thumbBox.x + thumbBox.width / 2, + thumbBox.y + thumbBox.height / 2 + ); + await page.mouse.down(); + + // Move mouse right by 100px + await page.mouse.move( + thumbBox.x + thumbBox.width / 2 + 100, + thumbBox.y + thumbBox.height / 2, + { steps: 10 } // Make movement smoother + ); + + await page.mouse.up(); + + // Wait for scrolling to complete + await page.waitForTimeout(100); + + // Verify new scroll position + const newScrollLeft = await viewport.evaluate((el) => el.scrollLeft); + expect(newScrollLeft).toBeGreaterThan(initialScrollLeft); + }); +}); + +test.describe("thumb behavior", () => { + test(`GIVEN a scroll area + WHEN thumb is being dragged + THEN it should have dragging state`, async ({ page }) => { + const d = await setup(page, "vertical-test"); + const thumb = d.getVerticalThumb(); + + // Get thumb position + const thumbBox = await thumb.boundingBox(); + if (!thumbBox) throw new Error("Could not get thumb position"); + + // Move to center of thumb and click + await page.mouse.move( + thumbBox.x + thumbBox.width / 2, + thumbBox.y + thumbBox.height / 2 + ); + await page.mouse.down(); + + // Wait for drag state to update + await page.waitForTimeout(50); + + await expect(thumb).toHaveAttribute("data-dragging", ""); + + // Cleanup + await page.mouse.up(); + }); + + test(`GIVEN a scroll area + WHEN clicking on scrollbar track + THEN viewport should scroll to that position`, async ({ page }) => { + const d = await setup(page, "vertical-test"); + const scrollbar = d.getVerticalScrollbar(); + const viewport = d.getViewport(); + + // Get initial scroll position + const initialScrollTop = await viewport.evaluate((el) => el.scrollTop); + + // Get scrollbar position + const box = await scrollbar.boundingBox(); + if (!box) throw new Error("Could not get scrollbar position"); + + // Click in the middle of the scrollbar, but account for padding + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + // Wait for scroll to complete + await page.waitForTimeout(50); + + // Verify new scroll position + const newScrollTop = await viewport.evaluate((el) => el.scrollTop); + expect(newScrollTop).toBeGreaterThan(initialScrollTop); + }); +}); + +test.describe("a11y", () => { + test(`GIVEN a scroll area + WHEN scrolling + THEN native keyboard shortcuts should work`, async ({ page }) => { + const d = await setup(page, "vertical-test"); + const viewport = d.getViewport(); + + // Focus the viewport + await viewport.focus(); + + // Test various keyboard shortcuts + const shortcuts = ["PageDown", "PageUp", "Home", "End"]; + for (const key of shortcuts) { + const initialScrollTop = await viewport.evaluate((el) => el.scrollTop); + await page.keyboard.press(key); + const newScrollTop = await viewport.evaluate((el) => el.scrollTop); + + if (key === "PageDown" || key === "End") { + expect(newScrollTop).toBeGreaterThanOrEqual(initialScrollTop); + } else { + expect(newScrollTop).toBeLessThanOrEqual(initialScrollTop); + } + } + }); +});