v1.0

SortPanel

Preview

Basic

Loading…

Preview

Code

ts
import SortPanel from '@/components/form/SortPanel';

src/components/form/SortPanel.tsx

AI prompt

text
Build a multi-level "Sort by" toolbar popover component (Lark Base / Airtable style) in React + TypeScript + Tailwind CSS.

## Look
- Trigger: a 32px icon-only button (`h-8 w-8 rounded-lg`) with the lucide `ArrowUpDown` icon at 16px. Idle: `text-slate-500 hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-700/60 dark:hover:text-slate-100`. While open OR while any sort is applied, tinted indigo: `bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300`.
- Count badge when sorted: `absolute -right-0.5 -top-0.5 h-3.5 min-w-3.5 rounded-full bg-indigo-600 px-1 text-[9px] font-semibold leading-none text-white ring-2 ring-white dark:ring-slate-800`, showing the number of levels.
- Panel: in-flow, `absolute left-0 top-full mt-1 z-50`, width from a prop (default 420px) capped at `max-w-[calc(100vw-2rem)]`, opaque floating surface with `p-3`, entering with a scale-in from the top-left (0.22s `cubic-bezier(0.34, 1.56, 0.64, 1)`, from opacity 0 / scale 0.96 / translateY 6px).
- Heading: `title` in 12px semibold slate-700 (dark slate-200), `mb-2`.
- Empty state strip: `rounded-lg bg-slate-50 px-3 py-4 text-center text-xs text-slate-400 dark:bg-slate-900/40` — "Not sorted. Rows appear in the order they arrived."
- Level rows, `flex items-center gap-1.5`, stacked `space-y-1.5`: a drag grip (lucide `GripVertical` 14px, `rounded p-1 text-slate-300 hover:text-slate-500 dark:text-slate-600 dark:hover:text-slate-300 cursor-grab touch-none`); the connector word "By" (first row) or "then" in `w-8 text-right text-[11px] text-slate-400`; a native column `<select>` (text-input recipe, `py-1.5 flex-1 min-w-0`); a two-segment direction control (`rounded-lg border border-slate-300 dark:border-slate-700 overflow-hidden`, segments `px-2 py-1 text-[11px] font-medium`, inactive `text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700`, active `bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300`); a remove × (`rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600 dark:hover:bg-slate-700`).
- Footer: `mt-2 pt-2 border-t border-slate-100 dark:border-slate-700 flex items-center gap-2` — indigo text button "+ Add a level" (`px-2 py-1 text-xs font-medium text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-950/40`, 40% opacity when disabled); at the limit an ⓘ info tooltip "3 levels is the limit."; a ghost "Clear sort" (`ml-auto`) when any level exists; a primary "Apply" (`ml-auto`) always.

## Behaviour
- The direction labels say what they MEAN, from the column's `kind`: text "A → Z" / "Z → A", number "0 → 9" / "9 → 0", date "Old → New" / "New → Old", boolean "No → Yes" / "Yes → No". Never "asc/desc". Missing kind = text.
- Works on a DRAFT. Nothing reaches `onChange` until Apply (which then closes). Closing any other way — outside click, Escape, clicking the trigger — reverts the draft. Re-sync the draft when `sorts` changes from outside.
- A column can be used once: each row's select lists its own column plus unused ones. "Add a level" appends the first unused column ascending; disabled at 3 levels or when none are unused. "Clear sort" empties the draft (still needs Apply).
- Level order is the meaning (status-then-date ≠ date-then-status), so rows reorder by native HTML5 drag: a row is `draggable` only while the pointer is down on its grip; rows reorder live as the dragged one enters another; the dragged row is `opacity-40` with `bg-slate-100 dark:bg-slate-700`; a drag that ends without a drop restores the previous order.
- The trigger's label uses the committed sorts: "Sorted by Due, then Task", or "Sort".

## API
- `type SortLevel = { key: string; dir: 'asc' | 'desc' }`; `type SortableColumn = { key: string; label: string; kind?: 'text' | 'number' | 'date' | 'boolean' }`.
- Props: `sorts: SortLevel[]`, `columns: SortableColumn[]`, `onChange(sorts)`, `title = 'Sort by'`, `width = 420`, `className`.

## Accessibility
- Trigger: `aria-expanded`, `aria-label`/`title` = the "Sorted by …" sentence. Grip: "Drag to reorder this sort level". Remove: "Remove this sort level".

## Demo
Columns Task (text), Estimate (number), Due (date), Billable (boolean); start sorted by Due, Old → New; show the applied levels as JSON beneath.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: ticket-management (96S2) `tickets/SortPanel.tsx`. */

import { useEffect, useRef, useState } from 'react';
import { ArrowUpDown, GripVertical, Plus, X } from 'lucide-react';
import { MAX_SORT_LEVELS, SORT_DIRECTION_LABELS, type SortKind, type SortLevel } from '@/lib/sort';
import { cn } from '@/lib/cn';
import {
  PANEL_EMPTY_CLASS,
  PANEL_GRIP_CLASS,
  PANEL_REMOVE_CLASS,
  TOOLBAR_BADGE_CLASS,
  TOOLBAR_PANEL_CLASS,
  panelAddButtonClass,
  segmentClass,
  toolbarButtonClass,
} from '@/lib/toolbar';
import { useDismiss } from '@/lib/use-dismiss';
import { InfoTooltip } from '@/components/overlay/Tooltip';
import SortableList from '@/components/table/SortableList';

/** A sortable column, as the panel needs to name it. */
export type SortableColumn = { key: string; label: string; kind?: SortKind };

/**
 * Sort by several columns at once, each with its own direction — the Lark
 * Base sort builder.
 *
 * ── The direction control says what it MEANS ────────────────────────────────
 *
 * "A → Z" on a name, "Old → New" on a date, never "asc/desc". The direction of
 * a sort is the one thing a person double-checks, and asc/desc makes them
 * translate it against the column's type every time. A column's `kind` is
 * where that lives (`SORT_DIRECTION_LABELS` in `lib/sort`), so the label and
 * the comparison are derived from one fact.
 *
 * ── A DRAFT, and an Apply button ────────────────────────────────────────────
 *
 * Nothing reaches `onChange` until Apply is clicked; closing any other way —
 * outside click, Escape, the trigger again — reverts the draft. Dragging
 * reorders the LEVELS, and the order is the whole meaning: status-then-date
 * and date-then-status are different views of the same rows.
 */
export default function SortPanel({
  sorts,
  columns,
  onChange,
  title = 'Sort by',
  width = 420,
  className,
}: {
  sorts: SortLevel[];
  columns: SortableColumn[];
  onChange: (sorts: SortLevel[]) => void;
  title?: string;
  width?: number;
  className?: string;
}) {
  const [open, setOpen] = useState(false);
  const [draft, setDraft] = useState<SortLevel[]>(sorts);
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    setDraft(sorts);
  }, [sorts]);

  const revertAndClose = () => {
    setDraft(sorts);
    setOpen(false);
  };
  useDismiss(root, open, revertAndClose);

  const apply = () => {
    onChange(draft);
    setOpen(false);
  };

  const byKey = new Map(columns.map((c) => [c.key, c]));
  /** A column already sorted on would be a level that can never break a tie. */
  const unused = columns.filter((c) => !draft.some((s) => s.key === c.key));

  const setAt = (i: number, patch: Partial<SortLevel>) =>
    setDraft((prev) => prev.map((s, j) => (j === i ? { ...s, ...patch } : s)));

  const label = sorts.length
    ? `Sorted by ${sorts.map((s) => byKey.get(s.key)?.label ?? s.key).join(', then ')}`
    : 'Sort';

  return (
    <div ref={root} className={cn('relative inline-block', className)}>
      <button
        type="button"
        aria-label={label}
        aria-expanded={open}
        title={label}
        onClick={() => (open ? revertAndClose() : setOpen(true))}
        className={toolbarButtonClass(open || sorts.length > 0)}
      >
        <ArrowUpDown className="h-4 w-4" aria-hidden />
        {sorts.length > 0 && <span className={TOOLBAR_BADGE_CLASS}>{sorts.length}</span>}
      </button>

      {open && (
        <div style={{ width }} className={TOOLBAR_PANEL_CLASS}>
          <p className="mb-2 text-xs font-semibold text-slate-700 dark:text-slate-200">{title}</p>

          {draft.length === 0 && <p className={PANEL_EMPTY_CLASS}>Not sorted. Rows appear in the order they arrived.</p>}

          <SortableList
            group="sort-levels"
            items={draft}
            getId={(s) => s.key}
            onReorder={setDraft}
            className="space-y-1.5"
            renderItem={(s, { isDragging, handleProps }) => {
              const i = draft.findIndex((x) => x.key === s.key);
              const labels = SORT_DIRECTION_LABELS[byKey.get(s.key)?.kind ?? 'text'];
              return (
                <div className={cn('flex items-center gap-1.5 rounded', isDragging && 'bg-slate-100 dark:bg-slate-700')}>
                  <button type="button" aria-label="Drag to reorder this sort level" {...handleProps} className={PANEL_GRIP_CLASS}>
                    <GripVertical className="h-3.5 w-3.5" aria-hidden />
                  </button>

                  <span className="w-8 shrink-0 text-right text-[11px] text-slate-400">{i === 0 ? 'By' : 'then'}</span>

                  <select value={s.key} onChange={(e) => setAt(i, { key: e.target.value })} className="field-input min-w-0 flex-1 py-1.5 text-xs">
                    {columns
                      .filter((c) => c.key === s.key || unused.some((u) => u.key === c.key))
                      .map((c) => (
                        <option key={c.key} value={c.key}>{c.label}</option>
                      ))}
                  </select>

                  <div className="flex shrink-0 overflow-hidden rounded-lg border border-slate-300 dark:border-slate-700">
                    {(['asc', 'desc'] as const).map((dir) => (
                      <button key={dir} type="button" onClick={() => setAt(i, { dir })} className={segmentClass(s.dir === dir)}>
                        {labels[dir]}
                      </button>
                    ))}
                  </div>

                  <button type="button" aria-label="Remove this sort level" onClick={() => setDraft((prev) => prev.filter((_, j) => j !== i))} className={PANEL_REMOVE_CLASS}>
                    <X className="h-3.5 w-3.5" />
                  </button>
                </div>
              );
            }}
          />

          <div className="mt-2 flex items-center gap-2 border-t border-slate-100 pt-2 dark:border-slate-700">
            <button
              type="button"
              onClick={() => unused[0] && setDraft((prev) => [...prev, { key: unused[0].key, dir: 'asc' }])}
              disabled={draft.length >= MAX_SORT_LEVELS || unused.length === 0}
              className={panelAddButtonClass()}
            >
              <Plus className="h-3.5 w-3.5" /> Add a level
            </button>
            {draft.length >= MAX_SORT_LEVELS && <InfoTooltip content={`${MAX_SORT_LEVELS} levels is the limit.`} label="Why can't I add more?" iconClassName="w-3 h-3" />}
            {draft.length > 0 && (
              <button type="button" onClick={() => setDraft([])} className="ml-auto btn-ghost text-xs">
                Clear sort
              </button>
            )}
            <button type="button" onClick={apply} className="ml-auto btn-primary">
              Apply
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
sorts*SortLevel[]—
columns*SortableColumn[]—
onChange*(sorts: SortLevel[]) => void—
titlestring'Sort by'
widthnumber420
classNamestring—