Back to components

Filter Tags

A horizontally scrollable multi-select tag row that groups selected tags first in selection order, with responsive controls and reduced-motion support.

formlayout-reorder

今天想聽什麼?

選幾個喜歡的標籤,新選的會接在已選標籤後面。

已選 0 個

左右滑動瀏覽 · 可多選 · 再點一次取消

"use client";

import * as React from "react";
import { Toggle } from "@base-ui/react/toggle";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion, useReducedMotion, type Transition } from "motion/react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export type FilterTagItem = {
  /** Unique, stable identifier. Duplicate values produce duplicate React keys. */
  value: string;
  label: React.ReactNode;
  disabled?: boolean;
};

export type FilterTagsClassNames = Partial<Record<
  "row" | "viewport" | "list" | "tag" | "arrow" | "backwardArrow" | "forwardArrow",
  string
>>;

export type FilterTagsArrows = {
  variant?: React.ComponentProps<typeof Button>["variant"];
  size?: React.ComponentProps<typeof Button>["size"];
  backwardIcon?: React.ReactNode;
  forwardIcon?: React.ReactNode;
  backwardLabel?: string;
  forwardLabel?: string;
};

export type FilterTagsProps = Omit<
  React.ComponentProps<"div">,
  "children" | "defaultValue" | "onChange"
> & {
  items: FilterTagItem[];
  /**
   * Selected values in selection order. An empty array clears selection.
   * Values that don't match an item are ignored and dropped from the next
   * `onValueChange` payload.
   */
  value?: string[];
  defaultValue?: string[];
  /** Receives the full next selection and the tag that was toggled. */
  onValueChange?: (value: string[], item: FilterTagItem) => void;
  disabled?: boolean;
  /** Transition for the reorder animation when a tag moves to or from the selected group. */
  transition?: Transition;
  /** Hide the native scrollbar without disabling touch/trackpad scrolling. */
  showScrollbar?: boolean;
  /**
   * true: always; false: never; auto: only when a fine pointer (mouse or
   * trackpad) is available, since touch users scroll the row directly.
   */
  showArrows?: boolean | "auto";
  classNames?: FilterTagsClassNames;
  arrows?: FilterTagsArrows;
};

/** Ease-out: a tag should start moving the instant it is toggled. */
const REORDER_TRANSITION: Transition = {
  duration: 0.2,
  ease: [0.22, 1, 0.36, 1],
};

/** Breathing room kept between a revealed tag and the viewport edge. */
const REVEAL_MARGIN = 4;

/**
 * Tag height per input modality (44px touch target, 36px with a fine pointer)
 * and the list's vertical padding. The arrow rails derive their height from
 * both, so they stay centred on the tags until the ResizeObserver takes over.
 */
const SIZE_VARS =
  "[--filter-tags-list-py:0.5rem] [--filter-tags-tag-h:2.75rem] pointer-fine:[--filter-tags-tag-h:2.25rem]";

function useOrderedSelection(
  items: FilterTagItem[],
  value: string[] | undefined,
  defaultValue: string[],
) {
  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const isControlled = value !== undefined;
  const sourceValue = isControlled ? value : internalValue;

  const byValue = React.useMemo(
    () => new Map(items.map((item) => [item.value, item])),
    [items],
  );
  const selected = React.useMemo(
    () => [...new Set(sourceValue)].filter((id) => byValue.has(id)),
    [sourceValue, byValue],
  );
  const selectedSet = React.useMemo(() => new Set(selected), [selected]);
  const orderedItems = React.useMemo(
    () => [
      ...selected.map((id) => byValue.get(id)!),
      ...items.filter((item) => !selectedSet.has(item.value)),
    ],
    [items, selected, selectedSet, byValue],
  );

  return { isControlled, selected, selectedSet, orderedItems, setInternalValue };
}

function useScrollEdges(scrollerRef: React.RefObject<HTMLElement | null>) {
  const [edges, setEdges] = React.useState({ backward: false, forward: false });

  const measure = React.useCallback(() => {
    const node = scrollerRef.current;
    if (!node) return;
    const backward = node.scrollLeft > 1;
    const forward = node.scrollLeft + node.clientWidth < node.scrollWidth - 1;
    setEdges((previous) =>
      previous.backward === backward && previous.forward === forward
        ? previous
        : { backward, forward },
    );
  }, [scrollerRef]);

  return { edges, measure };
}

function isKeyboardEvent(event: Event | undefined) {
  return event?.type === "keydown" || event?.type === "keyup" || (event as MouseEvent | undefined)?.detail === 0;
}

/**
 * Multi-select filters: selected tags lead in selection order; deselection
 * restores source order.
 *
 * The row always lays out left-to-right, even in RTL documents, so the
 * selected group and the backward arrow sit on the physical left. Labels
 * still resolve their own direction via `dir="auto"`.
 */
export function FilterTags({
  items,
  value,
  defaultValue = [],
  onValueChange,
  disabled = false,
  transition = REORDER_TRANSITION,
  className,
  showScrollbar = false,
  showArrows = "auto",
  classNames,
  arrows,
  "aria-label": ariaLabel = "Filters",
  "aria-labelledby": ariaLabelledBy,
  ...props
}: FilterTagsProps) {
  const {
    variant: arrowVariant = "ghost",
    size: arrowSize = "icon",
    backwardIcon = <ChevronLeft aria-hidden="true" />,
    forwardIcon = <ChevronRight aria-hidden="true" />,
    backwardLabel = "Scroll tags left",
    forwardLabel = "Scroll tags right",
  } = arrows ?? {};

  const reduceMotion = useReducedMotion();
  const scrollerRef = React.useRef<HTMLDivElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);
  const tagRefs = React.useRef(new Map<string, HTMLDivElement>());
  const [listHeight, setListHeight] = React.useState<number>();
  // Keyboard activation reorders and scrolls instantly: the focused tag stays
  // put under its focus ring and the reveal below reads settled coordinates.
  const [instant, setInstant] = React.useState(false);
  // The tag currently travelling between groups. A selected mover rides above
  // every other tag; a deselected one slips back underneath, like a raised
  // layer settling down.
  const [flying, setFlying] = React.useState<string>();
  const { isControlled, selected, selectedSet, orderedItems, setInternalValue } =
    useOrderedSelection(items, value, defaultValue);
  const { edges, measure: measureEdges } = useScrollEdges(scrollerRef);
  const previousSelected = React.useRef(selected);
  const previousOrder = React.useRef(orderedItems);
  // Motion re-measures every tag whenever this changes and nowhere else, so
  // parent re-renders, scrollbar toggles, and resizes never animate the row.
  const layoutDependency = orderedItems.map((item) => item.value).join("");
  const animateLayout = !(reduceMotion || instant);

  React.useEffect(() => {
    const scroller = scrollerRef.current;
    const list = listRef.current;
    if (!scroller || !list) return;
    const measure = () => {
      measureEdges();
      // The list excludes the native scrollbar and includes its own padding.
      // Observe it separately so custom tag sizes and wrapped labels also align.
      const height = list.getBoundingClientRect().height;
      setListHeight((previous) => previous === height ? previous : height);
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(scroller);
    observer.observe(list);
    return () => observer.disconnect();
  }, [measureEdges]);

  React.useEffect(() => {
    const added = selected.filter((id) => !previousSelected.current.includes(id));
    const removed = previousSelected.current.filter((id) => !selectedSet.has(id));
    previousSelected.current = selected;
    const newest = added[added.length - 1];
    // Track the mover only for accepted changes that actually move it. A
    // rejected controlled update, or a toggle that leaves the order intact,
    // runs no layout animation and would otherwise strand the raised layer.
    const mover = newest ?? removed[removed.length - 1];
    const indexOf = (order: FilterTagItem[]) => order.findIndex((item) => item.value === mover);
    const moved = mover !== undefined && indexOf(previousOrder.current) !== indexOf(orderedItems);
    previousOrder.current = orderedItems;
    if (animateLayout && moved) setFlying(mover);

    // Only reveal newly accepted selections, never a removal or rejected change.
    const node = scrollerRef.current;
    const tag = newest === undefined ? undefined : tagRefs.current.get(newest);
    if (!node || !tag) return;

    // Read final layout coordinates; Motion's transform is still in flight.
    const left = Math.max(0, tag.offsetLeft - REVEAL_MARGIN);
    const right = tag.offsetLeft + tag.offsetWidth + REVEAL_MARGIN;
    const target = left < node.scrollLeft
      ? left
      : right > node.scrollLeft + node.clientWidth
        ? right - node.clientWidth
        : null;
    if (target !== null) {
      node.scrollTo({ left: target, behavior: animateLayout ? "smooth" : "instant" });
    }
  }, [selected, selectedSet, orderedItems, animateLayout]);

  function toggle(item: FilterTagItem, event: Event | undefined) {
    if (disabled || item.disabled) return;
    setInstant(isKeyboardEvent(event));
    const next = selectedSet.has(item.value)
      ? selected.filter((id) => id !== item.value)
      : [...selected, item.value];
    if (!isControlled) setInternalValue(next);
    onValueChange?.(next, item);
  }

  function scroll(direction: 1 | -1, event: React.MouseEvent) {
    const node = scrollerRef.current;
    if (!node) return;
    node.scrollBy({
      left: direction * node.clientWidth * 0.8,
      behavior: reduceMotion || isKeyboardEvent(event.nativeEvent) ? "instant" : "smooth",
    });
  }

  function revealFocused(event: React.FocusEvent<HTMLElement>) {
    // Reveal focus without scrolling the surrounding page.
    const node = scrollerRef.current;
    if (!node) return;
    const target = event.currentTarget.getBoundingClientRect();
    const viewport = node.getBoundingClientRect();
    const delta = target.left < viewport.left
      ? target.left - viewport.left - REVEAL_MARGIN
      : target.right > viewport.right
        ? target.right - viewport.right + REVEAL_MARGIN
        : 0;
    if (delta) node.scrollBy({ left: delta, behavior: "instant" });
  }

  const arrowRailClassName = cn(
    "flex h-[calc(var(--filter-tags-tag-h)_+_2_*_var(--filter-tags-list-py))] shrink-0 items-center",
    showArrows === "auto" && "hidden pointer-fine:flex",
  );

  return (
    <div
      {...props}
      data-slot="filter-tags"
      className={cn("w-full min-w-0", SIZE_VARS, className)}
    >
      <div className={cn("flex min-w-0 items-start gap-1", classNames?.row)}>
        {showArrows !== false && (
          <div
            data-slot="filter-tags-backward-control"
            className={arrowRailClassName}
            style={{ height: listHeight }}
          >
            <Button
              type="button"
              variant={arrowVariant}
              size={arrowSize}
              className={cn(classNames?.arrow, classNames?.backwardArrow)}
              aria-label={backwardLabel}
              disabled={!edges.backward || disabled}
              onClick={(event) => scroll(-1, event)}
            >
              {backwardIcon}
            </Button>
          </div>
        )}
        <motion.div
          ref={scrollerRef}
          layoutScroll
          onScroll={measureEdges}
          data-slot="filter-tags-viewport"
          data-scrollbar={showScrollbar ? "visible" : "hidden"}
          className={cn(
            "min-w-0 flex-1 overflow-x-auto overscroll-x-contain",
            showScrollbar
              ? "[scrollbar-width:thin]"
              : "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
            classNames?.viewport,
          )}
          dir="ltr"
        >
          <div
            ref={listRef}
            role="group"
            aria-label={ariaLabelledBy ? undefined : ariaLabel}
            aria-labelledby={ariaLabelledBy}
            className={cn(
              "relative isolate flex w-max min-w-full items-center gap-2 px-1 py-(--filter-tags-list-py)",
              classNames?.list,
            )}
          >
            {orderedItems.map((item) => {
              const pressed = selectedSet.has(item.value);
              const isMover = flying === item.value;
              return (
                <motion.div
                  key={item.value}
                  ref={(node) => {
                    if (node) tagRefs.current.set(item.value, node);
                    else tagRefs.current.delete(item.value);
                  }}
                  layout={animateLayout ? "position" : false}
                  layoutDependency={layoutDependency}
                  transition={transition}
                  onLayoutAnimationComplete={() =>
                    setFlying((current) => current === item.value ? undefined : current)
                  }
                  data-flying={isMover || undefined}
                  className={cn(
                    "flex shrink-0 rounded-lg transition-shadow duration-200",
                    pressed ? (isMover ? "z-20 shadow-md" : "z-10") : isMover && "-z-10",
                  )}
                >
                  <Toggle
                    pressed={pressed}
                    disabled={disabled || item.disabled}
                    onPressedChange={(_, details) => toggle(item, details.event)}
                    onFocus={revealFocused}
                    data-slot="filter-tags-tag"
                    // Apply selected foreground/background together: interpolating
                    // their inverted colors briefly washes out the label. Keep
                    // the existing color transition when returning to unselected.
                    className={cn(
                      "inline-flex min-h-(--filter-tags-tag-h) shrink-0 items-center justify-center gap-1.5 rounded-lg bg-muted px-3 py-2 text-sm font-medium whitespace-nowrap text-foreground outline-none transition-colors duration-150 ease-[ease] hover:not-aria-pressed:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:transition-none motion-reduce:transition-none",
                      classNames?.tag,
                    )}
                  >
                    <span dir="auto">{item.label}</span>
                  </Toggle>
                </motion.div>
              );
            })}
          </div>
        </motion.div>
        {showArrows !== false && (
          <div
            data-slot="filter-tags-forward-control"
            className={arrowRailClassName}
            style={{ height: listHeight }}
          >
            <Button
              type="button"
              variant={arrowVariant}
              size={arrowSize}
              className={cn(classNames?.arrow, classNames?.forwardArrow)}
              aria-label={forwardLabel}
              disabled={!edges.forward || disabled}
              onClick={(event) => scroll(1, event)}
            >
              {forwardIcon}
            </Button>
          </div>
        )}
      </div>
    </div>
  );
}

Installation