Sorting and columns
Click a column header to sort; click again to reverse.
<DocumentExplorer
data={documents}
sort={{ key: 'updatedAt', direction: 'desc', foldersFirst: true }}
onSort={(sort) => console.log(sort)}
/>What the sorting gets right
- Natural number order.
file9.pdfsorts beforefile10.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:
<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
| Option | Meaning |
|---|---|
key | 'name', 'size', 'updatedAt', or any 'metadata.x' dot path. |
label | Header text. |
sortable | Whether the header is clickable. |
align | 'left' (default), 'center' or 'right'. |
numeric | Tabular numerals — digits keep the same width. Does not move anything. |
width | A CSS grid track, e.g. '120px' or 'minmax(0, 1fr)'. |
optional | Hidden on narrow screens. Defaults to true for everything but name. |
resizable | Set false to pin this column when resizing is on. |
minWidth / maxWidth | Pixel bounds honoured while resizing. |
render | Full control of the cell body (React; Angular uses a deColumn template). |
Custom cell rendering
{
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.
<DocumentExplorer
data={documents}
resizableColumns
columnWidths={savedWidths} // restore a saved layout
onColumnResize={(key, width, widths) => save(widths)}
/><doc-explorer
[data]="documents"
[resizableColumns]="true"
[columnWidths]="savedWidths"
(columnResize)="save($event.widths)"
/>Gestures
| Gesture | Result |
|---|---|
| Drag the handle | Resize the column |
| Double-click the handle | Reset it to its declared width |
Focus the handle, ← / → | Resize by 16px — resizing is not drag-only |
Focus the handle, Enter or Home | Reset 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:
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 },
]}| Field | What it does |
|---|---|
width | The starting track. '120px', '20%', 'minmax(0, 1fr)' — anything CSS grid accepts. |
minWidth | Floor for dragging, and the floor of a flexible track. |
maxWidth | Ceiling for dragging. |
resizable: false | No 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:
<DocumentExplorer columnAlign="right" /><doc-explorer columnAlign="right" />Or per column, which overrides the explorer-wide setting:
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
{ 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:
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:
<DocumentExplorer sort={{ key: 'metadata.owner' }} />