KQ-2113 · SORTED/INSERT

Sorted Insert

A sidebar where New opens an inline field in the list instead of a dialog: every row, header and the field are placed by computed y, so the rows below part on springs.glide to make the field's slot. On Enter the field becomes the new row where it stands, then the row flies on glide to its sorted place — riding 8px aside on a solid lifted card while the rows it passes slide up a slot — settles home on springs.snap and holds a highlight so you see where it went; a refused name nudges the field on a short tween and slides its message in. Rows are one roving tab stop where Up and Down move, F2 renames (a rename that sorts elsewhere flies there too), N opens the field in the focused section and Escape cancels, and under 300px the sidebar becomes a drawer whose field opens at its top.

KQ-2113 · SORTED/INSERT

Enter adds the name, Escape cancels. In the list, Up and Down move, F2 renames and N adds to the focused section.

9 clients · 4 prospects · press n in the list

Install

One command — the source lands in your repo. Or copy it from the Code tab.

$ pnpm dlx shadcn@latest add @kinetiq/sorted-insert

Props

ref
React.Ref<SortedInsertHandle>
default —
Drive the field from outside: startCreate(sectionId?, text?), typeDraft(text), submitDraft(), cancelDraft(). startCreate never moves focus.
sections
InsertSection[]
default defaultInsertSections
The sections, top to bottom: { id, label, noun }.
items / defaultItems
InsertItem[]
default defaultInsertItems
The rows, controlled or initial: { id, name, section, createdAt (ISO date), projects? }.
onItemsChange
(items: InsertItem[]) => void
default —
Fires with the whole list after an add, a rename or a failed add taken back out.
sortBy
"name" | "recent" | "size"
default "name"
The order within each section: A–Z, newest first, or most projects first. Changing it re-sorts every row on glide.
highlight
"wash" | "bar" | "ring"
default "wash"
How a row shows where it landed, for 1.6 s: an accent wash, a bar on its leading edge that shrinks away, or a ring.
accent
"cobalt" | "plum" | "mint" | "ink" | string
default "cobalt"
Field, highlight and selection. A name or any CSS colour.
maxNameLength
number
default 40
The longest name the field accepts.
validateName
(name: string, sectionId: string, items: InsertItem[]) => string | null
default —
Your own naming rule, run after the built-in ones (not empty, not too long, not a duplicate in the section): return a message to refuse.
onCreate
(name: string, sectionId: string) => void | Promise<unknown>
default —
A name was accepted. Return a promise and the row shows as adding until it settles; a rejection takes the row back out and reopens the field with the name.
onRename
(id: string, name: string, previous: string) => void
default —
A row was renamed.
onCancel
(kind: "create" | "rename", target: string) => void
default —
The field closed without saving: Escape, or an empty field losing focus.
selected / defaultSelected
string
default the first item
The chosen row, controlled or initial.
onSelect
(id: string) => void
default —
A row was chosen.
now
string
default "2026-10-14"
Today as an ISO date: the createdAt of new rows.
status
"ready" | "loading" | "error"
default "ready"
Whether the items have arrived: still skeleton rows while loading, a message and Retry on error.
onRetry
() => void
default —
Retry was pressed after an error.
rowHeight
number
default 34
Every row's height in px.
studio
{ name: string; initials: string }
default { name: "Fernworks", initials: "Fw" }
The studio in the header.
frameHeight
number
default 520
The frame's height in px; the list scrolls inside it and scrolls a landing slot into view.
title
string
default "Clients"
The sidebar's heading and accessible name.
disabled
boolean
default false
Read only: nothing can be added, renamed or chosen.
className
string
default —
Classes for the frame.