UI Library

Filter panel

A faceted-filter side panel that replaces a row of toolbar dropdowns with one button. Selection is lifted to the caller and applied live, so the list updates as boxes are ticked; active choices stay visible as removable chips while the panel is closed.

Overlayfilterfacetspanelcheckboxradix
Preview

Courses

  • Working Safely at Height
  • Permit to Work — Issuing Authority
  • Dropped Objects Prevention
  • Journey Management Basics
apps/site/blocks/components/filter-panel.tsx
'use client';

/**
 * A floating faceted-filter side panel — standalone.
 *
 * This is the icompetent-admin panel (`src/components/admin/filter-panel.tsx`)
 * rewritten to drop into any React + Tailwind project. The version in that repo
 * is built on shadcn's `sheet.tsx` and needs a patch to it (an `overlayClassName`
 * prop) that no other project will have; this one talks to Radix directly, so
 * there is nothing to patch.
 *
 * ── Dependencies ────────────────────────────────────────────────────────────
 *   npm i @radix-ui/react-dialog @radix-ui/react-checkbox \
 *         @radix-ui/react-collapsible lucide-react
 *
 * Tailwind is assumed. Nothing else is: no shadcn, no `cn()` helper, no
 * `tailwindcss-animate` / `tw-animate-css` — the slide-in keyframes are defined
 * at the bottom of this file and injected with the panel.
 *
 * Corner radii are written as explicit pixels (`rounded-[12px]`) rather than
 * `rounded-xl` and friends on purpose. A shadcn project remaps that scale from
 * its own `--radius`, so a host with `--radius: 1rem` renders `rounded-sm` at
 * 12px and turns the 16px checkbox into a circle. Explicit values look the same
 * in every project; change them if you would rather inherit the host's scale.
 *
 * ── Why it is built this way ────────────────────────────────────────────────
 * A toolbar grows one dropdown per facet until the page opens on its controls
 * instead of its records. This trades all of them for one button, and gives
 * each facet room for a label, a count and a checkbox list.
 *
 * Selection is **lifted to the caller** and applied live: the list behind the
 * panel updates as boxes are ticked, so the footer button only has to close it.
 * That is deliberate — a panel that batches changes until "Apply" makes you
 * guess what each choice will do.
 *
 * ── Usage ───────────────────────────────────────────────────────────────────
 *   const { selected, toggle, clear } = useFacetSelection();
 *
 *   const facets: FilterFacet[] = [
 *     { id: 'status', name: 'Status', options: [
 *         { value: facetValue('status', 'live'), label: 'Live' },
 *         { value: facetValue('status', 'draft'), label: 'Draft' },
 *     ]},
 *   ];
 *
 *   const shown = items.filter((item) =>
 *     matchesSelection(selected, valuesOf(item)),
 *   );
 *
 *   <FiltersButton count={selected.size} onClick={() => setOpen(true)} />
 *   <ActiveFilterChips facets={facets} selected={selected}
 *                      onToggle={toggle} onClear={clear} />
 *   <FilterPanel open={open} onOpenChange={setOpen} facets={facets}
 *                selected={selected} onToggle={toggle} onClear={clear}
 *                resultCount={shown.length} resultNoun="course" />
 */

import * as React from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import * as Checkbox from '@radix-ui/react-checkbox';
import * as Collapsible from '@radix-ui/react-collapsible';
import { Check, ChevronDown, Search, SlidersHorizontal, X } from 'lucide-react';

// ── Types ───────────────────────────────────────────────────────────────────

export type FilterOption = {
  /** Unique across ALL facets — see `facetValue()`. */
  value: string;
  label: string;
};

export type FilterFacet = {
  id: string;
  name: string;
  options: FilterOption[];
};

// ── Selection state + matching ──────────────────────────────────────────────

/**
 * Namespace a raw value to its facet.
 *
 * Two facets can hold the same string — a category called "Free" and a price
 * facet's `free` — and without a namespace they become one checkbox driving
 * both. Skip this only when your values are already globally unique (ids, say).
 */
export function facetValue(facetId: string, value: string): string {
  return `${facetId}:${value}`;
}

/** Split on the FIRST colon only: the raw value may well contain one. */
export function splitFacetValue(selected: string): { facetId: string; value: string } {
  const at = selected.indexOf(':');
  return at === -1
    ? { facetId: selected, value: '' }
    : { facetId: selected.slice(0, at), value: selected.slice(at + 1) };
}

/** The selected set, plus the two operations the panel needs. */
export function useFacetSelection(initial?: Iterable<string>) {
  const [selected, setSelected] = React.useState<Set<string>>(() => new Set(initial ?? []));

  const toggle = React.useCallback((value: string) => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (!next.delete(value)) next.add(value);
      return next;
    });
  }, []);

  const clear = React.useCallback(() => setSelected(new Set()), []);

  return { selected, setSelected, toggle, clear };
}

/**
 * **AND across facets, OR within one.**
 *
 * This is the only rule that makes ticking a second box in a group *widen* the
 * result instead of emptying it, which is what people expect a checkbox list to
 * do. Pass every value the item holds — one per facet, or many where a facet is
 * multi-valued (tags).
 */
export function matchesSelection(selected: Set<string>, itemValues: Iterable<string>): boolean {
  if (selected.size === 0) return true;

  const wantedByFacet = new Map<string, Set<string>>();
  for (const value of selected) {
    const { facetId } = splitFacetValue(value);
    if (!wantedByFacet.has(facetId)) wantedByFacet.set(facetId, new Set());
    wantedByFacet.get(facetId)!.add(value);
  }

  const held = itemValues instanceof Set ? itemValues : new Set(itemValues);
  for (const wanted of wantedByFacet.values()) {
    let hit = false;
    for (const value of wanted) {
      if (held.has(value)) {
        hit = true;
        break;
      }
    }
    if (!hit) return false;
  }
  return true;
}

// ── Toolbar button ──────────────────────────────────────────────────────────

/** The one control that replaces a row of dropdowns, badged with the count. */
export function FiltersButton({
  count,
  onClick,
  label = 'Filters',
  className = '',
}: {
  count: number;
  onClick: () => void;
  label?: string;
  className?: string;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={count > 0 ? `${label} (${count} active)` : label}
      className={
        'inline-flex h-10 items-center rounded-[12px] border bg-white px-4 text-sm font-bold ' +
        'text-slate-700 transition hover:bg-slate-50 ' +
        (count > 0 ? 'border-slate-900 ' : 'border-slate-200 ') +
        className
      }
    >
      <SlidersHorizontal className="mr-2 h-4 w-4" aria-hidden />
      {label}
      {count > 0 && (
        <span className="ml-2 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 text-[11px] font-bold text-white">
          {count}
        </span>
      )}
    </button>
  );
}

// ── Active filter chips ─────────────────────────────────────────────────────

/**
 * What is currently selected, as removable chips.
 *
 * Not optional decoration: a closed panel otherwise hides the reason the list
 * is short, which is the one real cost of moving filters off the toolbar.
 */
export function ActiveFilterChips({
  facets,
  selected,
  onToggle,
  onClear,
  className = '',
}: {
  facets: FilterFacet[];
  selected: Set<string>;
  onToggle: (value: string) => void;
  onClear: () => void;
  className?: string;
}) {
  const chips = facets.flatMap((facet) =>
    facet.options
      .filter((option) => selected.has(option.value))
      .map((option) => ({ ...option, facet: facet.name })),
  );
  if (chips.length === 0) return null;

  return (
    <div className={'mb-4 flex flex-wrap items-center gap-2 ' + className}>
      {chips.map((chip) => (
        <span
          key={chip.value}
          className="inline-flex items-center gap-1 rounded-full bg-slate-100 py-1 pl-2.5 pr-1 text-xs font-medium text-slate-700"
        >
          <span className="text-slate-500">{chip.facet}:</span> {chip.label}
          <button
            type="button"
            aria-label={`Remove filter ${chip.label}`}
            onClick={() => onToggle(chip.value)}
            className="rounded-full p-0.5 hover:bg-slate-300/60"
          >
            <X className="h-3 w-3" aria-hidden />
          </button>
        </span>
      ))}
      <button
        type="button"
        onClick={onClear}
        className="h-7 rounded-full px-2 text-xs font-bold text-slate-500 hover:text-slate-700"
      >
        Clear all
      </button>
    </div>
  );
}

// ── The panel ───────────────────────────────────────────────────────────────

export function FilterPanel({
  open,
  onOpenChange,
  facets,
  selected,
  onToggle,
  onClear,
  resultCount,
  resultNoun = 'result',
  title = 'Filters',
  description = 'Narrow the list. Choices apply as you tick them.',
  searchPlaceholder = 'Find a filter…',
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  facets: FilterFacet[];
  selected: Set<string>;
  onToggle: (value: string) => void;
  onClear: () => void;
  resultCount: number;
  /** Singular; pluralised with a trailing "s" in the footer button. */
  resultNoun?: string;
  title?: string;
  description?: string;
  searchPlaceholder?: string;
}) {
  const [query, setQuery] = React.useState('');

  // A stale search term would hide facets the next time the panel opens.
  React.useEffect(() => {
    if (!open) setQuery('');
  }, [open]);

  const q = query.trim().toLowerCase();
  const visible = React.useMemo(() => {
    if (!q) return facets;
    return facets
      .map((facet) => ({
        ...facet,
        options: facet.options.filter((option) => option.label.toLowerCase().includes(q)),
      }))
      .filter((facet) => facet.options.length > 0);
  }, [facets, q]);

  const selectedCount = facets.reduce(
    (total, facet) => total + facet.options.filter((o) => selected.has(o.value)).length,
    0,
  );

  return (
    <Dialog.Root open={open} onOpenChange={onOpenChange}>
      <Dialog.Portal>
        <style>{PANEL_KEYFRAMES}</style>

        <Dialog.Overlay
          className="fixed inset-0 z-50 bg-slate-900/40 backdrop-blur-[2px]"
          style={{ animation: 'filterPanelFade 200ms ease-out' }}
        />

        {/*
         * The geometry, which is the whole point of the look:
         *
         * `top/bottom` TOGETHER are what give the panel its height — so height
         * stays `auto`. Set a height as well and you get a full-viewport box
         * starting 16px down, hanging off the bottom of the screen.
         *
         * The width is the same trick: 100% would run past the right margin, so
         * it is the viewport less BOTH margins, then capped on larger screens.
         *
         * `overflow-hidden` is load-bearing next to the rounded corners — the
         * header, search row and footer carry full-width borders that would
         * otherwise cut straight across them.
         */}
        <Dialog.Content
          className={
            'fixed inset-y-4 right-4 z-50 flex h-auto w-[calc(100%-2rem)] flex-col ' +
            'overflow-hidden rounded-[16px] border border-slate-200 bg-white shadow-2xl sm:max-w-md'
          }
          style={{ animation: 'filterPanelIn 250ms cubic-bezier(0.32, 0.72, 0, 1)' }}
        >
          <div className="border-b border-slate-200 px-6 py-5">
            <Dialog.Title className="text-xl font-extrabold tracking-tight text-slate-900">
              {title}
            </Dialog.Title>
            <Dialog.Description className="mt-1 text-sm text-slate-500">
              {description}
            </Dialog.Description>
            <Dialog.Close
              className="absolute right-4 top-4 rounded-[6px] p-1 text-slate-400 opacity-70 transition hover:bg-slate-100 hover:opacity-100"
              aria-label="Close filters"
            >
              <X className="h-4 w-4" aria-hidden />
            </Dialog.Close>
          </div>

          <div className="border-b border-slate-200 px-6 py-4">
            <div className="relative">
              <Search
                className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
                aria-hidden
              />
              <input
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder={searchPlaceholder}
                aria-label={searchPlaceholder}
                className="h-10 w-full rounded-[12px] border border-slate-200 pl-9 pr-3 text-sm outline-none focus:border-slate-400"
              />
            </div>
          </div>

          <div className="min-h-0 flex-1 overflow-y-auto px-6 py-2">
            {visible.length === 0 ? (
              <p className="py-8 text-center text-sm text-slate-500">
                No filters match &ldquo;{query}&rdquo;.
              </p>
            ) : (
              visible.map((facet) => (
                <FacetSection
                  key={facet.id}
                  facet={facet}
                  selected={selected}
                  onToggle={onToggle}
                  // While searching, every remaining group is a match —
                  // collapsing them would hide the very thing searched for.
                  forceOpen={q.length > 0}
                />
              ))
            )}
          </div>

          <div className="flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4">
            <button
              type="button"
              onClick={onClear}
              disabled={selectedCount === 0}
              className="h-10 rounded-[12px] px-3 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-40 disabled:hover:text-slate-500"
            >
              Clear all
            </button>
            <button
              type="button"
              onClick={() => onOpenChange(false)}
              className="h-10 rounded-[12px] bg-slate-900 px-4 text-sm font-bold text-white shadow-sm transition hover:bg-slate-800"
            >
              Show {resultCount} {resultNoun}
              {resultCount === 1 ? '' : 's'}
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

// ── One facet ───────────────────────────────────────────────────────────────

function FacetSection({
  facet,
  selected,
  onToggle,
  forceOpen,
}: {
  facet: FilterFacet;
  selected: Set<string>;
  onToggle: (value: string) => void;
  forceOpen: boolean;
}) {
  const count = facet.options.filter((o) => selected.has(o.value)).length;
  // A dozen groups open at once is a scroll, not a panel: start closed, except
  // where a choice is already active and its absence would be a surprise.
  const [open, setOpen] = React.useState(count > 0);

  return (
    <Collapsible.Root
      open={forceOpen || open}
      onOpenChange={setOpen}
      className="border-b border-slate-100 py-1 last:border-0"
    >
      <Collapsible.Trigger
        disabled={forceOpen}
        className="flex w-full items-center justify-between gap-2 rounded-[8px] px-1 py-3 text-left hover:bg-slate-50 disabled:hover:bg-transparent"
      >
        <span className="flex items-center gap-2 text-sm font-bold text-slate-800">
          {facet.name}
          {count > 0 && (
            <span className="inline-flex h-5 items-center rounded-full bg-slate-100 px-2 text-[11px] font-bold text-slate-600">
              {count}
            </span>
          )}
        </span>
        {!forceOpen && (
          <ChevronDown
            className={
              'h-4 w-4 shrink-0 text-slate-400 transition-transform ' + (open ? 'rotate-180' : '')
            }
            aria-hidden
          />
        )}
      </Collapsible.Trigger>

      <Collapsible.Content className="pb-2">
        <div className="grid gap-0.5">
          {facet.options.map((option) => {
            const id = `filter-${facet.id}-${option.value}`;
            return (
              <label
                key={option.value}
                htmlFor={id}
                className="flex cursor-pointer items-center gap-3 rounded-[8px] px-1 py-2 text-sm text-slate-600 hover:bg-slate-50"
              >
                <Checkbox.Root
                  id={id}
                  checked={selected.has(option.value)}
                  onCheckedChange={() => onToggle(option.value)}
                  className="flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border border-slate-300 data-[state=checked]:border-slate-900 data-[state=checked]:bg-slate-900 data-[state=checked]:text-white"
                >
                  <Checkbox.Indicator>
                    <Check className="h-3.5 w-3.5" aria-hidden />
                  </Checkbox.Indicator>
                </Checkbox.Root>
                {option.label}
              </label>
            );
          })}
        </div>
      </Collapsible.Content>
    </Collapsible.Root>
  );
}

/**
 * Slide-in and fade, as plain CSS.
 *
 * Inlined so the file needs no animation plugin. If your project already has
 * `tailwindcss-animate` or `tw-animate-css`, delete this and the two `style`
 * attributes above, and use the utility classes instead.
 */
const PANEL_KEYFRAMES = `
@keyframes filterPanelIn {
  from { opacity: 0; transform: translateX(16px) scale(0.98); }
  to   { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes filterPanelFade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  @keyframes filterPanelIn { from { opacity: 1; } to { opacity: 1; } }
  @keyframes filterPanelFade { from { opacity: 1; } to { opacity: 1; } }
}
`;

// ── Demo ────────────────────────────────────────────────────────────────────

const DEMO_FACETS: FilterFacet[] = [
  {
    id: 'status',
    name: 'Status',
    options: [
      { value: facetValue('status', 'live'), label: 'Live' },
      { value: facetValue('status', 'draft'), label: 'Draft' },
      { value: facetValue('status', 'archived'), label: 'Archived' },
    ],
  },
  {
    id: 'level',
    name: 'Level',
    options: [
      { value: facetValue('level', 'intro'), label: 'Introductory' },
      { value: facetValue('level', 'intermediate'), label: 'Intermediate' },
      { value: facetValue('level', 'advanced'), label: 'Advanced' },
    ],
  },
  {
    id: 'format',
    name: 'Format',
    options: [
      { value: facetValue('format', 'self-paced'), label: 'Self-paced' },
      { value: facetValue('format', 'cohort'), label: 'Cohort' },
      { value: facetValue('format', 'in-person'), label: 'In person' },
    ],
  },
];

const DEMO_ITEMS: { id: string; title: string; values: string[] }[] = [
  {
    id: 'a',
    title: 'Working Safely at Height',
    values: [facetValue('status', 'live'), facetValue('level', 'intro'), facetValue('format', 'self-paced')],
  },
  {
    id: 'b',
    title: 'Permit to Work — Issuing Authority',
    values: [facetValue('status', 'live'), facetValue('level', 'advanced'), facetValue('format', 'cohort')],
  },
  {
    id: 'c',
    title: 'Dropped Objects Prevention',
    values: [facetValue('status', 'draft'), facetValue('level', 'intermediate'), facetValue('format', 'in-person')],
  },
  {
    id: 'd',
    title: 'Journey Management Basics',
    values: [facetValue('status', 'archived'), facetValue('level', 'intro'), facetValue('format', 'self-paced')],
  },
];

/**
 * A self-contained harness for the three exports above.
 *
 * It exists because `FilterPanel` is controlled: the open flag and the selected
 * set belong to the caller, so the component cannot render on its own. This is
 * what the /ui preview shows, and it doubles as the copy-paste wiring the
 * header comment describes.
 */
export function FilterPanelDemo() {
  const [open, setOpen] = React.useState(false);
  const { selected, toggle, clear } = useFacetSelection();

  const shown = DEMO_ITEMS.filter((item) => matchesSelection(selected, item.values));

  return (
    <div className="mx-auto w-full max-w-2xl">
      <div className="mb-4 flex items-center justify-between">
        <h3 className="text-base font-bold text-slate-900">Courses</h3>
        <FiltersButton count={selected.size} onClick={() => setOpen(true)} />
      </div>

      <ActiveFilterChips
        facets={DEMO_FACETS}
        selected={selected}
        onToggle={toggle}
        onClear={clear}
      />

      <ul className="divide-y divide-slate-200 rounded-[12px] border border-slate-200 bg-white">
        {shown.map((item) => (
          <li key={item.id} className="px-4 py-3 text-sm font-medium text-slate-700">
            {item.title}
          </li>
        ))}
        {shown.length === 0 && (
          <li className="px-4 py-8 text-center text-sm text-slate-500">
            Nothing matches those filters.
          </li>
        )}
      </ul>

      <FilterPanel
        open={open}
        onOpenChange={setOpen}
        facets={DEMO_FACETS}
        selected={selected}
        onToggle={toggle}
        onClear={clear}
        resultCount={shown.length}
        resultNoun="course"
      />
    </div>
  );
}

Dependencies

  • @radix-ui/react-dialog — npm package
  • @radix-ui/react-checkbox — npm package
  • @radix-ui/react-collapsible — npm package
  • lucide-react — npm package

Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.