Inputs

Sort Builder

A multi-key sort composer — ordered field plus direction rules, drag or keyboard reprioritising, direction wording that follows the field type, and a live plain-language summary of the whole chain.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDown,
  ArrowUp,
  ChevronDown,
  ChevronsUpDown,
  ChevronUp,
  GripVertical,
  Plus,
  TriangleAlert,
  X,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sort-builder.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "SortBuilder" component using @dnd-kit
(core + sortable + utilities), lucide-react icons and the shadcn DropdownMenu
primitive. It composes a multi-key sort order: an ordered list of
field + direction rules where the list order IS the meaning.

Contract
- export type SortDirection = "asc" | "desc"
- export type SortFieldType = "text" | "number" | "date" | "enum" | "boolean"
- export interface SortField { id: string; label: string; type?: SortFieldType;
  disabled?: boolean; disabledReason?: string }  // id doubles as the drag id
- export interface SortRule { field: string; direction: SortDirection }
- Props (forwardRef <div>, extends
  Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">):
  fields: SortField[], value: SortRule[] (controlled, never mutated),
  onChange: (next: SortRule[]) => void, max?: number,
  addLabel = "Add sort", emptyLabel, emptySummary, summary = true,
  disabled = false, label = "Sort order" (accessible name of the group).
- Also export describeSort(rules, fields, emptyText?) -> string: the exact same
  sentence the component prints, for a toolbar chip or a saved-view title.
- Two exported lookup tables carry every user-facing word:
  SORT_DIRECTION_WORDS[type][direction] = { button, phrase } — e.g. text
  "A -> Z" / "A to Z", date "New -> Old" / "newest first", number
  "9 -> 0" / "largest first", enum "List order" / "in list order", boolean
  "Yes -> No" / "checked first"; and SORT_DEFAULT_DIRECTION[type], where date
  and number start descending and text/enum start ascending.

Behavior
- Priority maths: rule[0] is the primary key, rule[i] only breaks ties left by
  rules[0..i-1]. A consumer's comparator is exactly: walk the rules in order,
  return the first non-zero comparison, negated when direction is "desc";
  return 0 when the whole chain ties. Reordering the array is therefore the
  whole feature — it is not decoration.
- One field, one key. A field appearing twice is corrupt state, not a
  preference: the second comparison can never run. Dedupe `value` on the way in
  (first occurrence wins) and return the SAME array reference when nothing was
  dropped, so memoisation still works. That dedupe is what makes `field` usable
  as the drag id and the React key.
- Adding: the Add control is a menu of the fields that are neither locked nor
  already used; picking one appends { field, direction: default for its type }.
  Render the Add control exactly ONCE, in the same place whether or not there
  are rules — if it moved between an empty panel and a footer it would unmount
  mid-pick and the menu would have no trigger to return focus to.
- Changing a rule's field: keep the direction when the old and new field share
  a type, re-derive it from SORT_DEFAULT_DIRECTION when they do not — "newest
  first" is not a meaning that survives a move to a text column.
- Reordering has four entry points, all landing on arrayMove(from, to):
  pointer drag from the grip (PointerSensor, 4px activation distance), grip +
  Space then arrow keys (KeyboardSensor + sortableKeyboardCoordinates),
  Alt+ArrowUp / Alt+ArrowDown from anywhere in a row, and explicit Move
  up / Move down buttons for touch. The Alt handler must bail while a keyboard
  drag is live (track it with onDragStart / onDragEnd / onDragCancel), or one
  keypress applies the step twice.
- Keyboard map: Tab walks grip -> field -> direction -> up -> down -> remove per
  row; Space/Enter on the grip picks up, arrows move, Space drops, Escape
  cancels; Alt+Arrow reorders without drag mode; Enter/Space activates every
  other control. Rows are keyed by field id, so a reorder moves the DOM node
  and the focused button keeps focus with no restoration code — but picking a
  different field CHANGES that key, so the row is replaced and focus has to be
  put back by hand (see below).
- ARIA contract: root is role="group" with aria-label; the rules are an
  <ol role="list"> (role survives display:flex, which drops list semantics in
  WebKit); the grip's label is "Reorder <field>, priority 2 of 3: drag, or press
  Space and then the arrow keys"; the direction button's accessible name STARTS
  with its visible text so "click A to Z" still hits it, then explains the
  switch. Replace dnd-kit's default announcements — they read raw ids out loud —
  with ones that name the field and read positions off the drag event itself.
- The summary sentence is the product: "Sorted by Status in list order, then
  Updated newest first." It is a role="status" aria-atomic live region, so every
  edit (add, remove, reorder, direction flip) is announced as the new complete
  order — which is also why there must be no second live region competing with
  it. `summary={false}` hides it visually with sr-only; it must never leave
  the DOM.
- Refusals are visible, focusable and honest. At `max`, or when every field is
  used, or when `fields` is empty: the Add trigger gets aria-disabled plus a
  spelled-out reason wired through aria-describedby, and — because aria-disabled
  has no browser behaviour behind it — a controlled `open` guard that refuses to
  open the menu. Locked fields stay listed in the menus with their
  disabledReason, because a field that silently vanishes reads as a bug.
- Never use the native `disabled` attribute on these controls. The browser
  blurs a node the instant it becomes disabled, and "Move up" on the row that
  just reached the top is exactly that node. Use aria-disabled + a handler
  guard; for the whole-field `disabled` prop add tabIndex={-1} and
  pointer-events-none, which removes the controls from the tab sequence without
  touching whatever is focused right now.
- Degenerate cases, all first-class: no rules -> dashed strip + emptySummary;
  one rule -> the grip is aria-disabled (there is nowhere to move) but stays
  focusable; a rule whose field is gone -> rendered with a warning icon, a
  destructive-tinted trigger and "(field no longer exists)" in the sentence,
  repairable through its own menu instead of silently dropped; max=0 -> Add
  refuses from the first render; duplicate ids in `value` -> collapsed.
- Focus moves deliberately after the two edits that unmount the node the reader
  is standing on. Removal: onto the remove button of the row that slid into the
  freed slot, or onto Add once the list is empty. Field change: onto the new
  row's field trigger — the menu tries to hand focus back to the trigger it
  opened from, which the re-key has already destroyed, so without this the
  caret lands on <body>. Drive both from one state object that is fresh per
  edit (so two edits at the same index both fire), never from a boolean, and
  resolve the target by DOM position under a ref on the list.
- Cleanup: the only long-lived subscription is the reduced-motion media query,
  held through useSyncExternalStore so its listener is removed on unmount; no
  timers, no rAF, no observers. dnd-kit's DndContext gets an explicit
  id={React.useId()} — its internal aria ids come off a global counter and
  drift between server and client otherwise.

Rendering & styling
- Semantic tokens only: rows border + bg-card, triggers border + bg-background,
  hover:bg-accent / hover:text-accent-foreground, text-muted-foreground for
  chrome and hints, text-foreground for the field names inside the sentence,
  text-destructive (+ border-destructive/50) for a missing field, dashed border
  for the empty strip and the Add trigger. No hex, no rgb, no oklch.
- Row layout: flex flex-wrap items-center gap-2 — grip, a w-12 aria-hidden
  "Sort by" / "then by" prefix, then a min-w-0 flex-1 basis-40 group holding the
  field trigger (truncating) and the direction toggle, then ml-auto for the
  three icon buttons. Narrow containers wrap the icon group onto a second line
  instead of squeezing or overflowing.
- Merge every className through cn(). Focus is always visible:
  focus-visible:ring-2 ring-ring on every control.
- Reduced motion: dnd-kit writes its settle transition inline, so a CSS
  motion-reduce variant cannot beat it — read prefers-reduced-motion and pass
  `transition: undefined` to the row style instead. Dragging keeps working;
  only the easing goes away. Colour transitions carry motion-reduce:transition-none.

Customization levers
- Wording and i18n: SORT_DIRECTION_WORDS is the only place user-facing
  direction text exists — translate it, or make it richer per domain
  ("cheapest first", "closest first"). Adding a field type means one entry there
  plus one in SORT_DEFAULT_DIRECTION, and nothing else changes.
- Density: swap the size-7 icon buttons and h-8 triggers for size-8 / h-9 in
  roomy layouts, or drop the "Sort by / then by" prefix and the Move up / Move
  down buttons entirely when the surface is desktop-only and drag is enough.
- Ceilings: `max` is the explicit cap; the pickable-field count is always the
  implicit one. Set max={1} to degrade the whole thing into a single-key picker
  without changing the value shape.
- Field menu: swap DropdownMenu for a Command/Combobox popover when a schema
  has fifty columns and the list needs typeahead — the contract does not change,
  only the picker.
- Summary placement: `summary={false}` plus describeSort() lets you print the
  sentence somewhere else (a toolbar chip, a saved-view header) while the
  component keeps announcing it.
- Persistence: SortRule[] is already the serialisable shape — store it in a
  saved view, or map it to `?sort=status,-updated` and back; unknown ids coming
  back are handled, not fatal.

Concepts

  • Order is the value — the array is a tie-breaker chain: rule 1 sorts, rule 2 only decides the rows rule 1 called equal. That is why reprioritising is the main gesture and why the component never sorts anything itself — it emits the chain and the consumer's comparator walks it.
  • One field, one key — a column listed twice can never fire its second comparison, so duplicates are collapsed on the way in rather than rendered. That collapse is also what lets the field id serve as the drag id and the React key.
  • Four ways to reprioritise — pointer drag, grip + Space + arrows, Alt+arrows from anywhere in the row, and plain Move up / Move down buttons. The Alt path stands down while a keyboard drag is live, so a single keypress can never move a rule twice.
  • Type-aware direction wording — "ascending" is a word nobody uses about dates. One lookup table turns the field's type into both the button label ("New → Old") and the sentence fragment ("newest first"), which makes it the single translation seam.
  • The sentence is the interface — the plain-language summary is a live region, so an add, a drop, a removal and a direction flip all announce the same thing a sighted user re-reads: the complete new order. Hiding it visually keeps it in the DOM.
  • Refusals stay focusable — a cap, an exhausted field list or a locked column produce aria-disabled plus a written reason, never the native disabled attribute that would blur the button the user is standing on.

On This Page