← Files DataARCHIVED FILE

templates/data-app/base/src/components/Controls.jsx

7.96 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import React, { useLayoutEffect, useRef } from "react";

import { stickyFilterState } from "../chrome-layout.js";
import { formatDateRange } from "../date-range.js";
import { isTemporalField } from "../source-provenance.js";
import { DateRangePicker } from "./DateRangePicker.jsx";
import { Dropdown } from "./Dropdown.jsx";
import { useSectionFilterPlacement } from "./Section.jsx";

export { DataTable } from "./DataTable.jsx";
export { DateRangePicker, Dropdown };

export function Filters({ filters = [], queries, values, onChange, sticky = false,
  ariaLabel = "Data app filters", clearLabel = "Clear all", showClear = true, dateAllValue,
  sectionScope, children, trailingControls }) {
  const filterBarRef = useRef(null);
  const placement = useSectionFilterPlacement(sectionScope);

  useLayoutEffect(() => {
    if (!sticky) return undefined;
    const filterBar = filterBarRef.current;
    const dashboard = filterBar?.closest(".dashboard-root");
    const topbar = dashboard?.querySelector(".dashboard-topbar");
    if (!filterBar || !topbar) return undefined;
    const themeDrawer = dashboard.querySelector(".theme-drawer");
    let themeHeight = 0;
    const updatePageWidth = () => {
      const width = `${dashboard.getBoundingClientRect().width}px`;
      if (filterBar.style.getPropertyValue("--filter-bar-page-width") !== width) {
        filterBar.style.setProperty("--filter-bar-page-width", width);
      }
    };
    const updateStickyState = () => {
      const topbarBounds = topbar.getBoundingClientRect();
      const filterBounds = filterBar.getBoundingClientRect();
      const state = stickyFilterState({ scrollY: window.scrollY, top:filterBounds.top, bottom:filterBounds.bottom,
        headerBottom:topbarBounds.bottom + themeHeight });
      if (filterBar.hasAttribute("data-stuck") !== state.stuck) {
        filterBar.toggleAttribute("data-stuck", state.stuck);
      }
    };
    const updateLayout = () => {
      themeHeight = themeDrawer?.getBoundingClientRect().height ?? 0;
      const height = `${themeHeight}px`;
      if (filterBar.style.getPropertyValue("--filter-bar-theme-height") !== height) {
        filterBar.style.setProperty("--filter-bar-theme-height", height);
      }
      updatePageWidth();
      updateStickyState();
    };
    const sizes = typeof ResizeObserver === "function" ? new ResizeObserver(updateLayout) : null;
    sizes?.observe(dashboard);
    sizes?.observe(topbar);
    if (themeDrawer) sizes?.observe(themeDrawer);
    const observer = new MutationObserver(updateStickyState);
    observer.observe(topbar, {attributes:true,attributeFilter:["data-scroll-controls-hidden"]});
    updateLayout();
    window.addEventListener("scroll", updateStickyState, { passive: true });
    window.addEventListener("resize", updateLayout);
    return () => {
      window.removeEventListener("scroll", updateStickyState);
      window.removeEventListener("resize", updateLayout);
      sizes?.disconnect();
      observer.disconnect();
      filterBar.removeAttribute("data-stuck");
      filterBar.style.removeProperty("--filter-bar-page-width");
      filterBar.style.removeProperty("--filter-bar-theme-height");
    };
  }, [filters.length, sticky]);

  if ((!filters.length && !children && !trailingControls) || !placement.header) return null;
  const hasActiveFilters = filters.some(({ id, defaultValue, multiple }) => {
    const normalize = value => multiple ? JSON.stringify([...new Set(Array.isArray(value) ? value : value === "all" ? [] : [value])].sort()) : value;
    const fallback = defaultValue ?? "all";
    return normalize(values[id] ?? fallback) !== normalize(fallback);
  });

  return (
    <section ref={filterBarRef} className={sticky ? "filters filter-bar" : "filters"} aria-label={ariaLabel}>
      {children}
      {filters.map((filter) => {
        const scopedQueries = Array.isArray(filter.queryIds)
          ? filter.queryIds.map((queryId) => queries[queryId]).filter(Boolean)
          : Object.values(queries);
        const choices = [...new Set(scopedQueries.flatMap(({ rows }) => rows
          .map((row) => String(row[filter.field] ?? ""))
          .filter((value) => value && value.toLowerCase() !== "all")))];
        const temporal = isTemporalField(filter.field, filter.type ?? filter.valueType);
        if (temporal) {
          choices.sort((left, right) => {
            const leftDate = Date.parse(left);
            const rightDate = Date.parse(right);
            return Number.isFinite(leftDate) && Number.isFinite(rightDate)
              ? rightDate - leftDate : right.localeCompare(left, undefined, { numeric: true });
          });
        }

        const label = temporal && filter.mode === "through" ? "Date range"
          : temporal && /^as of$/i.test(filter.label.trim()) ? "Reporting date" : filter.label;
        const formatChoice = temporal ? (choice) => {
          if (choice === "all") return "All dates";
          if (typeof choice !== "string") return choice;
          const format = (value, year = false) => new Intl.DateTimeFormat(undefined,
            { month: "short", day: "numeric", ...(year ? { year: "numeric" } : {}), timeZone: "UTC" })
            .format(new Date(`${value}T00:00:00Z`));
          if (choice.includes("..")) return formatDateRange(choice);
          if (!/^\d{4}-\d{2}-\d{2}$/.test(choice)) return choice;
          if (filter.mode !== "through") return format(choice, true);
          const earliest = choices.at(-1);
          return earliest && earliest !== choice ? `${format(earliest)} – ${format(choice, true)}` : format(choice, true);
        } : undefined;
        if (temporal && filter.mode === "through" && choices.length) {
          return <DateRangePicker key={filter.id} label={label} value={values[filter.id]}
            choices={choices} formatChoice={formatChoice} allValue={dateAllValue}
            onChange={(choice) => onChange(filter.id, choice)} />;
        }
        if (filter.searchable || filter.multiple || filter.control === "typeahead") {
          const searchable = filter.searchable || filter.control === "typeahead";
          return <div className="filter-typeahead" key={filter.id}
            onClick={(event) => {
              if (!event.target.closest("button")) {
                event.currentTarget.querySelector(".typeahead-input")?.focus({ preventScroll: true });
              }
            }}>
            {filter.showLabel !== false && <span className="filter-label">{label}</span>}
            <Dropdown searchable={searchable} multiple={filter.multiple} label={label}
              value={values[filter.id] ?? filter.defaultValue ?? (filter.multiple ? [] : "all")}
              choices={["all", ...choices]} allLabel={filter.allLabel ?? "All"}
              placeholder={filter.placeholder} choiceLabels={filter.choiceLabels}
              onChange={(choice) => onChange(filter.id, choice)} />
          </div>;
        }
        return <Dropdown key={filter.id} label={label} value={values[filter.id]}
          choices={temporal && filter.defaultValue && filter.defaultValue !== "all" ? choices : ["all", ...choices]}
          onChange={(choice) => onChange(filter.id, choice)} showLabel
          allLabel={temporal ? "All dates" : filter.allLabel ?? "All"}
          choiceLabels={filter.choiceLabels} formatChoice={formatChoice} />;
      })}
      {trailingControls}
      {showClear && hasActiveFilters && <button type="button" className="clear-filters"
        onClick={() => filters.forEach(({ id, defaultValue }) =>
          onChange(id, defaultValue ?? "all"))}>{clearLabel}</button>}
    </section>
  );
}

export function InlineFilters({ label, field, rows = [], value = "all", onChange }) {
  const choices = [...new Set(rows.map((row) => row?.[field])
    .filter((entry) => typeof entry === "string" && entry && entry.toLowerCase() !== "all"))];
  if (choices.length < 2) return null;
  return <div className="inline-filters" role="group" aria-label={`${label} chart filter`}>
    <Dropdown label={label} value={value} choices={["all", ...choices]}
      onChange={(choice) => onChange?.(choice)} showLabel />
  </div>;
}

SHA-256: d9077aac0dd095dcc26f8accb904ce834db130c4d6700a73ab6732c30c815602