Skip to content

Sorting and columns

Click a column header to sort; click again to reverse.

tsx
<DocumentExplorer
  data={documents}
  sort={{ key: 'updatedAt', direction: 'desc', foldersFirst: true }}
  onSort={(sort) => console.log(sort)}
/>

What the sorting gets right

  • Natural number order. file9.pdf sorts before file10.pdf, which plain string comparison gets backwards.
  • Dates as dates. ISO date strings sort chronologically, not lexicographically.
  • Empty values last — in both directions. Rows with no size stay at the bottom whether you sort ascending or descending, rather than flipping to the top when you reverse.
  • Folders first, by default, as every file browser does. Turn it off with foldersFirst: false.
  • A stable tiebreak, so equal rows never jitter between renders.

Columns

The default columns are Name, Type, Size and Modified. Override them with strings, objects, or a mix:

tsx
<DocumentExplorer
  columns={[
    'name',
    { key: 'metadata.owner', label: 'Owner', sortable: true, width: '150px' },
    'size',
    'updatedAt',
  ]}
/>

A bare string is shorthand: known keys keep their default config, and anything else becomes a sortable column with a label derived from the key (metadata.owner → "Owner").

Column options

OptionMeaning
key'name', 'size', 'updatedAt', or any 'metadata.x' dot path.
labelHeader text.
sortableWhether the header is clickable.
align'left' (default), 'center' or 'right'.
numericTabular numerals — digits keep the same width. Does not move anything.
widthA CSS grid track, e.g. '120px' or 'minmax(0, 1fr)'.
optionalHidden on narrow screens. Defaults to true for everything but name.
resizableSet false to pin this column when resizing is on.
minWidth / maxWidthPixel bounds honoured while resizing.
renderFull control of the cell body (React; Angular uses a deColumn template).

Custom cell rendering

tsx
{
  key: 'metadata.status',
  label: 'Status',
  render: (item) => <StatusPill status={item.metadata?.status} />,
}

Resizable columns

Off by default — turn it on and every column gets a drag handle on its trailing edge.

tsx
<DocumentExplorer
  data={documents}
  resizableColumns
  columnWidths={savedWidths}                       // restore a saved layout
  onColumnResize={(key, width, widths) => save(widths)}
/>
html
<doc-explorer
  [data]="documents"
  [resizableColumns]="true"
  [columnWidths]="savedWidths"
  (columnResize)="save($event.widths)"
/>

Gestures

GestureResult
Drag the handleResize the column
Double-click the handleReset it to its declared width
Focus the handle, / Resize by 16px — resizing is not drag-only
Focus the handle, Enter or HomeReset it

The handle is a focusable role="separator". A resizer that only responds to a pointer drag is unusable for anyone navigating by keyboard, so it is operable both ways.

Width, and the bounds around it

Three separate things, and they compose:

tsx
columns={[
  // `width` is the starting track — any CSS grid value.
  { key: 'name', label: 'Name', width: 'minmax(0, 1fr)', minWidth: 200 },
  // Starts at 90px, can be dragged out to 160px and no further.
  { key: 'size', label: 'Size', width: '90px', maxWidth: 160 },
  // Fixed at 130px; no handle at all.
  { key: 'updatedAt', label: 'Modified', width: '130px', resizable: false },
]}
FieldWhat it does
widthThe starting track. '120px', '20%', 'minmax(0, 1fr)' — anything CSS grid accepts.
minWidthFloor for dragging, and the floor of a flexible track.
maxWidthCeiling for dragging.
resizable: falseNo handle; the column keeps its width.

A column can never be dragged below a usable minimum whatever bounds you set — narrower than its own label is not a state worth allowing.

What stops the layout collapsing

Two rules, both learned the hard way:

A flexible column keeps a floor. A bare minmax(0, 1fr) lets a neighbour's expansion squeeze it to nothing, which silently hides the file names. Every flexible track is rebuilt from its minWidth, and the default name column floors at 160px rather than the global minimum, because a name column narrower than that is unreadable.

The row grows with its tracks. Once the columns no longer fit, the list scrolls horizontally and the rows widen to match — otherwise a row's hover highlight, selection background and bottom border stop at the container edge while its cells carry on past it.

Alignment

Every column is left-aligned by default, including Size. Alignment is an explicit setting, never inferred from the kind of data — a column that quietly aligns itself differently from its neighbours is an inconsistency nobody can explain from the config.

Set them all at once:

tsx
<DocumentExplorer columnAlign="right" />
html
<doc-explorer columnAlign="right" />

Or per column, which overrides the explorer-wide setting:

tsx
columns={[
  'name',
  { key: 'metadata.owner', label: 'Owner', align: 'center' },
  { key: 'size', label: 'Size', align: 'right' },
]}

A header always carries the same alignment as its cells, so the label and the values sit on the same edge at any width — including after a resize.

numeric is about digits, not position

tsx
{ key: 'size', label: 'Size', numeric: true, align: 'right' }

numeric applies tabular numerals: every figure occupies the same width, so rows do not shift as values change or as you sort. It moves nothing — pair it with align when you want a right-hand column of figures.

Why Size is not right-aligned by default

Desktop file managers right-align it, but formatted sizes carry unit suffixes of different widths, so right-alignment lines up the trailing “B” rather than the digits — 40.1 MB and 1 MB end on the same edge while their numbers stay ragged. The tidy edge is real; the magnitude-scanning is not. Consistency won. Set align: 'right' if you prefer the file-manager look.

Persisting a layout

onColumnResize fires once per resize, on release — not on every pointer move — so it is the right moment to write to storage:

tsx
const [widths, setWidths] = useState(() =>
  JSON.parse(localStorage.getItem('doc-widths') ?? '{}'),
);

<DocumentExplorer
  resizableColumns
  columnWidths={widths}
  onColumnResize={(_key, _width, next) => {
    setWidths(next);
    localStorage.setItem('doc-widths', JSON.stringify(next));
  }}
/>

Widths are stored per column key, so they survive reordering the columns.

Sorting by metadata

Any dot path works as a sort key, and uses the same value accessor as columns and search — so a column you can display is a column you can sort:

tsx
<DocumentExplorer sort={{ key: 'metadata.owner' }} />

Released under the MIT License.