Skip to content

[FEATURE] New ScrollContainer component #64

@lorant-one

Description

@lorant-one

A new horizontal scroll container with scroll-snapping and controls. Include component, add props for control placement (top-start, top-center, top-end, bottom-start, bottom-center, bottom-end), and include in docs.

Component draft:

"use client";

import { useRef, useState, useEffect, useCallback } from "react";
import { Column, Row, IconButton } from "@once-ui-system/core";

interface ScrollItem {
  id: string;
  content: React.ReactNode;
}

interface ScrollContainerProps extends React.ComponentProps<typeof Row> {
  items: ScrollItem[];
  onItemClick?: (id: string) => void;
}

export const ScrollContainer = ({ items, onItemClick, ...flex }: ScrollContainerProps) => {
  const scrollRef = useRef<HTMLDivElement>(null);
  const [canScrollLeft, setCanScrollLeft] = useState(false);
  const [canScrollRight, setCanScrollRight] = useState(true);

  const handleItemClick = (id: string) => {
    onItemClick?.(id);
  };

  const checkScrollPosition = useCallback(() => {
    const element = scrollRef.current;
    if (!element) return;

    const { scrollLeft, scrollWidth, clientWidth } = element;
    setCanScrollLeft(scrollLeft > 0);
    setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
  }, []);

  useEffect(() => {
    const element = scrollRef.current;
    if (!element) return;

    checkScrollPosition();

    element.addEventListener("scroll", checkScrollPosition);
    window.addEventListener("resize", checkScrollPosition);

    return () => {
      element.removeEventListener("scroll", checkScrollPosition);
      window.removeEventListener("resize", checkScrollPosition);
    };
  }, [checkScrollPosition]);

  const scroll = (direction: "left" | "right") => {
    const element = scrollRef.current;
    if (!element) return;

    const firstChild = element.firstElementChild as HTMLElement;
    if (!firstChild) return;

    const gap = parseFloat(getComputedStyle(element).gap) || 0;
    const tileWidth = firstChild.offsetWidth + gap;
    const targetScroll = element.scrollLeft + (direction === "right" ? tileWidth : -tileWidth);

    element.scrollTo({
      left: targetScroll,
      behavior: "smooth",
    });
  };

  return (
    <Column fillWidth gap="8">
      <Row fillWidth gap="8" paddingX="24">
        <IconButton
          icon="chevronLeft"
          onClick={() => scroll("left")}
          disabled={!canScrollLeft}
          style={{border: "none"}}
        />
        <IconButton
          icon="chevronRight"
          onClick={() => scroll("right")}
          disabled={!canScrollRight}
          style={{border: "none"}}
        />
      </Row>
      <Row
        ref={scrollRef}
        gap="8"
        overflowX="auto"
        style={{
          scrollSnapType: "x mandatory",
          scrollbarWidth: "none",
        }}
      >
        {items.map((item) => (
          <Column
            key={item.id}
            maxWidth={48}
            minWidth={28}
            border
            radius="xl"
            overflow="hidden"
            gap="8"
            aspectRatio="3/4"
            onClick={() => handleItemClick(item.id)}
            style={{
              scrollSnapAlign: "start",
              flexShrink: 0,
            }}
            {...flex}
          >
            {item.content}
          </Column>
        ))}
      </Row>
    </Column>
  );
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is needed

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions