Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions e2e/helpers/mount-smart-scroller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { CunninghamProvider } from "../../src/components/Provider/Provider";
import { SmartScroller } from "../../src/components/smart-scroller/SmartScroller";

// Playwright CT bridges function props one-way, so tests declare scenarios via
// plain data and this helper (which runs in the browser) builds the actual
// children locally. The outer frame carries `data-testid="frame"` so resize
// tests can change its width at runtime to trigger the component's
// ResizeObserver.
interface TestSmartScrollerProps {
/** Number of fixed-width items rendered inside the scroller. */
itemCount: number;
/** Forwarded to SmartScroller; defaults to its own default (0.5). */
scrollRatio?: number;
/** Width of the bounding frame the scroller must overflow against. */
frameWidth?: number;
/** Width of each item; `itemCount * itemWidth` decides whether it overflows. */
itemWidth?: number;
}

export const TestSmartScroller = ({
itemCount,
scrollRatio,
frameWidth = 480,
itemWidth = 120,
}: TestSmartScrollerProps) => {
return (
<CunninghamProvider currentLocale="en-US">
<div
data-testid="frame"
style={{
width: frameWidth,
border: "1px solid var(--c--contextuals--border--surface--primary)",
borderRadius: 8,
padding: 8,
}}
>
<SmartScroller scrollRatio={scrollRatio}>
{Array.from({ length: itemCount }, (_, i) => (
<div
key={i}
data-testid={`item-${i}`}
style={{
flex: "0 0 auto",
width: itemWidth,
height: 40,
marginInline: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
whiteSpace: "nowrap",
borderRadius: 20,
background:
"var(--c--contextuals--background--surface--secondary)",
}}
>
Item {i + 1}
</div>
))}
</SmartScroller>
</div>
</CunninghamProvider>
);
};
159 changes: 159 additions & 0 deletions e2e/smart-scroller/smart-scroller.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { test, expect } from "@playwright/experimental-ct-react";
import type { Locator, Page } from "@playwright/test";
import { TestSmartScroller } from "../helpers/mount-smart-scroller";

const viewportOf = (page: Page) =>
page.locator(".c__smart-scroller__viewport");

// The arrows are a pointer-only affordance (aria-hidden), so they are located
// by class rather than by role/name.
const rightArrowOf = (page: Page) =>
page.locator(".c__smart-scroller__arrow--right");
const leftArrowOf = (page: Page) =>
page.locator(".c__smart-scroller__arrow--left");

// Read a numeric geometry property off the scrollable viewport.
const geom = (
viewport: Locator,
prop: "scrollLeft" | "clientWidth" | "scrollWidth",
) => viewport.evaluate((el, p) => el[p as keyof Element] as number, prop);

// Poll until the smooth scroll has settled, then return the resting scrollLeft.
const settledScrollLeft = async (viewport: Locator) => {
let last = -1;
await expect
.poll(async () => {
const current = await geom(viewport, "scrollLeft");
const stable = current === last;
last = current;
return stable ? "stable" : current;
})
.toBe("stable");
return last;
};

test.describe("SmartScroller", () => {
test("renders no arrows when content fits", async ({ mount, page }) => {
await mount(<TestSmartScroller itemCount={2} />);
await expect(viewportOf(page)).toBeVisible();

await expect(rightArrowOf(page)).toHaveCount(0);
await expect(leftArrowOf(page)).toHaveCount(0);
});

test("shows only the right arrow when content overflows at the start", async ({
mount,
page,
}) => {
await mount(<TestSmartScroller itemCount={15} />);

await expect(rightArrowOf(page)).toBeVisible();
await expect(leftArrowOf(page)).toHaveCount(0);
expect(await geom(viewportOf(page), "scrollLeft")).toBe(0);
});

test("hides the arrows from assistive technology", async ({
mount,
page,
}) => {
await mount(<TestSmartScroller itemCount={15} />);

// The visible arrow is aria-hidden, so it is absent from the a11y tree...
await expect(rightArrowOf(page)).toBeVisible();
await expect(rightArrowOf(page)).toHaveAttribute("aria-hidden", "true");
await expect(
page.getByRole("button", { name: "Scroll right" }),
).toHaveCount(0);

// ...and removed from the tab order.
await expect(rightArrowOf(page)).toHaveAttribute("tabindex", "-1");
});

test("clicking the right arrow scrolls ~50% of the viewport width", async ({
mount,
page,
}) => {
await mount(<TestSmartScroller itemCount={15} />);
const viewport = viewportOf(page);

const clientWidth = await geom(viewport, "clientWidth");
await rightArrowOf(page).click();

const scrollLeft = await settledScrollLeft(viewport);
expect(Math.abs(scrollLeft - clientWidth * 0.5)).toBeLessThanOrEqual(2);
});

test("scrolling right reveals the left arrow", async ({ mount, page }) => {
await mount(<TestSmartScroller itemCount={15} />);

await rightArrowOf(page).click();

await expect(leftArrowOf(page)).toBeVisible();
});

test("clicking the left arrow scrolls back toward the start", async ({
mount,
page,
}) => {
await mount(<TestSmartScroller itemCount={15} />);
const viewport = viewportOf(page);

await rightArrowOf(page).click();
const afterRight = await settledScrollLeft(viewport);
expect(afterRight).toBeGreaterThan(0);

await leftArrowOf(page).click();
const afterLeft = await settledScrollLeft(viewport);
expect(afterLeft).toBeLessThan(afterRight);
});

test("reaching the end hides the right arrow and keeps the left arrow", async ({
mount,
page,
}) => {
await mount(<TestSmartScroller itemCount={15} />);
const viewport = viewportOf(page);

await viewport.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});

await expect(rightArrowOf(page)).toHaveCount(0);
await expect(leftArrowOf(page)).toBeVisible();
});

test("scrollRatio of 1 scrolls a full viewport width", async ({
mount,
page,
}) => {
await mount(<TestSmartScroller itemCount={15} scrollRatio={1} />);
const viewport = viewportOf(page);

const clientWidth = await geom(viewport, "clientWidth");
await rightArrowOf(page).click();

const scrollLeft = await settledScrollLeft(viewport);
expect(Math.abs(scrollLeft - clientWidth)).toBeLessThanOrEqual(2);
});

test("recomputes arrows when the container is resized", async ({
mount,
page,
}) => {
// 3 items (~360px) fit the 480px frame: no arrows.
await mount(<TestSmartScroller itemCount={3} />);
await expect(rightArrowOf(page)).toHaveCount(0);

// Shrinking the frame makes the same content overflow -> right arrow shows.
await page.getByTestId("frame").evaluate((el) => {
(el as HTMLElement).style.width = "200px";
});
await expect(rightArrowOf(page)).toBeVisible();

// Growing it back past the content width hides the arrow again.
await page.getByTestId("frame").evaluate((el) => {
(el as HTMLElement).style.width = "480px";
});
await expect(rightArrowOf(page)).toHaveCount(0);
});
});
50 changes: 50 additions & 0 deletions src/components/smart-scroller/SmartScroller.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Meta, Canvas, ArgTypes } from "@storybook/blocks";
import * as SmartScrollerStories from "./smart-scroller.stories";

<Meta of={SmartScrollerStories} name="Docs" />

# SmartScroller

`SmartScroller` is a container that makes its content horizontally scrollable and overlays an arrow button on each edge where content is hidden by overflow. Clicking an arrow scrolls the viewport by half of its visible width (configurable). It is well suited to horizontal toolbars, tab strips, chip rows, or any single row of items that may not fit the available width.

The component handles everything itself: it measures overflow, shows or hides each arrow as needed, and hides the native scrollbar so the arrows are the primary affordance. Content still scrolls with the wheel/trackpad.

## Usage

```tsx
import { SmartScroller } from "@gouvfr-lasuite/ui-kit";

<SmartScroller>
<Chip>Item 1</Chip>
<Chip>Item 2</Chip>
<Chip>Item 3</Chip>
{/* … */}
</SmartScroller>;
```

The styles are bundled in `@gouvfr-lasuite/ui-kit/style`, so make sure it is imported once in your app.

Children are laid out in a single row sized to their content, so the container overflows horizontally once the items exceed the available width. Give the `SmartScroller` a bounded width (or place it in a width-constrained parent) so there is an edge to overflow against.

## Behaviour

- An arrow is rendered **only** on the side(s) where there is hidden content: the right arrow appears when there is more content to the right, the left arrow once you have scrolled away from the start. Each arrow disappears as soon as its edge is fully reached.
- A subtle gradient fade is drawn on every scrollable edge to hint at the partially-cut content underneath. The fade never intercepts clicks.
- Overflow is recomputed on scroll and whenever the container **or** its content is resized (e.g. items added or removed, the viewport shrinking), so the arrows always reflect the current state.
- Scrolling on click is smooth, and respects the user's `prefers-reduced-motion` setting.

<Canvas of={SmartScrollerStories.Overflowing} />

When the content fits within the container, no arrows are shown:

<Canvas of={SmartScrollerStories.NoOverflow} />

## Props

<ArgTypes of={SmartScrollerStories} />

## Responsive resizing

Because the arrows are derived from a live measurement, they stay correct when the container is resized — for example inside a resizable panel or a responsive layout. Drag the frame below to see the arrows recompute as the available width changes:

<Canvas of={SmartScrollerStories.Resizable} />
121 changes: 121 additions & 0 deletions src/components/smart-scroller/SmartScroller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import clsx from "clsx";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { IconSize } from ":/components/icon";
import { ArrowLeft, ArrowRight } from ":/icons";

export type SmartScrollerProps = {
children: React.ReactNode;
/** Custom CSS class applied to the root element. */
className?: string;
/**
* Fraction of the visible width scrolled on each arrow click.
* Defaults to 0.5 (half the viewport).
*/
scrollRatio?: number;
};

/**
* A horizontally scrollable container that overlays arrow buttons on the
* edges where content is hidden by overflow. Clicking an arrow scrolls the
* viewport by `scrollRatio` of its visible width (smoothly). Each arrow only
* shows while there is content to reveal on that side, and a gradient fade
* hints at the partially-cut content underneath.
*/
export const SmartScroller = ({
children,
className,
scrollRatio = 0.5,
}: SmartScrollerProps) => {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);

const updateArrows = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const { scrollLeft, clientWidth, scrollWidth } = viewport;
setCanScrollLeft(scrollLeft > 0);
// 1px tolerance absorbs sub-pixel rounding so the right arrow hides once
// the end is reached.
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1);
}, []);

// Measure on mount, on scroll, and whenever the viewport (container) or its
// content (children added/removed/grown) is resized.
useLayoutEffect(() => {
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) {
return;
}
updateArrows();
viewport.addEventListener("scroll", updateArrows, { passive: true });
const observer = new ResizeObserver(updateArrows);
observer.observe(viewport);
observer.observe(content);
return () => {
viewport.removeEventListener("scroll", updateArrows);
observer.disconnect();
};
}, [updateArrows]);

const scrollByRatio = (direction: 1 | -1) => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
viewport.scrollBy({
left: direction * viewport.clientWidth * scrollRatio,
behavior: "smooth",
});
Comment on lines +71 to +74

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollBy hardcodes behavior: "smooth", which overrides the CSS scroll-behavior property (the CSS value is only consulted when behavior is "auto"). As a result the SCSS prefers-reduced-motion media query is ignored on arrow clicks — contradicting the docs, which claim it is respected. Dropping the behavior option makes the programmatic scroll follow the CSS (smooth by default, auto under reduced-motion).

Suggested change
viewport.scrollBy({
left: direction * viewport.clientWidth * scrollRatio,
behavior: "smooth",
});
viewport.scrollBy({
left: direction * viewport.clientWidth * scrollRatio,
});

};

return (
<div className={clsx("c__smart-scroller", className)}>
{canScrollLeft && (
<>
<span className="c__smart-scroller__fade c__smart-scroller__fade--left" />
<Button
className="c__smart-scroller__arrow c__smart-scroller__arrow--left"
onClick={() => scrollByRatio(-1)}
// Pointer-only affordance: assistive-tech and keyboard users reach
// the content directly (focusing a child scrolls it into view), so
// the arrows are hidden from the a11y tree and the tab order.
Comment on lines +85 to +87

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not very sur about this

aria-hidden="true"
tabIndex={-1}
icon={<ArrowLeft size={IconSize.SMALL} />}
color="neutral"
variant="bordered"
size="small"
/>
</>
)}

<div className="c__smart-scroller__viewport" ref={viewportRef}>
<div className="c__smart-scroller__content" ref={contentRef}>
{children}
</div>
</div>

{canScrollRight && (
<>
<span className="c__smart-scroller__fade c__smart-scroller__fade--right" />
<Button
className="c__smart-scroller__arrow c__smart-scroller__arrow--right"
onClick={() => scrollByRatio(1)}
aria-hidden="true"
tabIndex={-1}
icon={<ArrowRight size={IconSize.SMALL} />}
color="neutral"
variant="bordered"
size="small"
/>
</>
)}
</div>
);
};
Loading
Loading