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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/sort-builder.jsonPrompt
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-disabledplus a written reason, never the nativedisabledattribute that would blur the button the user is standing on.
Key Value Editor
An editable list of flat key/value pairs — drag or Alt+Arrow to reorder, switch a row off instead of deleting it, paste a block of Key: value lines, and see both halves of every duplicate key.
Number Ticker
A count-up number that eases from 0 to its target the first time it scrolls into view — Intl-formatted, prefix/suffix aware.