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
| Prop | Type | Default | Description |
|---|---|---|---|
sorts* | SortLevel[] | — | |
columns* | SortableColumn[] | — | |
onChange* | (sorts: SortLevel[]) => void | — | |
title | string | 'Sort by' | |
width | number | 420 | |
className | string | — |