Skip to content

Custom rendering

React

tsx
<DocumentExplorer
  renderers={{
    file: (item) => <MyFileRow file={item} />,
    folder: (item) => <MyFolderRow folder={item} />,
    row: (item) => <MyEntireRow item={item} />,   // replaces the whole row
    icon: (item) => <MyIcon type={item.mimeType} />,
    empty: () => <MyEmptyState />,
    loading: () => <MySkeleton />,
    error: (error) => <MyError error={error} />,
    toolbar: () => <button onClick={upload}>Upload</button>,
  }}
/>

Anything you do not provide falls back to the default rendering.

For a single column, render on a ColumnDef is usually simpler than replacing the whole row — see avatars and other cell content below.

Angular

Angular's equivalent is ng-template slots:

html
<doc-explorer [data]="documents">
  <ng-template #deFile let-item>
    <my-file-row [file]="item" />
  </ng-template>

  <ng-template #deFolder let-item>
    <my-folder-row [folder]="item" />
  </ng-template>

  <ng-template #deEmpty>
    <my-empty-state />
  </ng-template>

  <ng-template #deToolbar>
    <button (click)="upload()">Upload</button>
  </ng-template>
</doc-explorer>

The item is available both as let-item and as the implicit value (let-item / let-x).

Cell content

To change one column rather than a whole row — an avatar beside an owner, a status pill, a badge — render just that cell.

React

tsx
const ownerColumn: ColumnDef = {
  key: 'metadata.owner',
  label: 'Owner',
  sortable: true,
  minWidth: 120,
  render: (item) => {
    const owner = item.metadata?.owner as string | undefined;
    if (!owner) return null;
    return (
      <>
        <span className="de-avatar" aria-hidden="true">{initials(owner)}</span>
        <span className="de-avatar-label">{owner}</span>
      </>
    );
  },
};

<DocumentExplorer columns={['name', ownerColumn, 'size']} />

Angular

Angular matches templates to columns with the deColumn directive:

ts
import { DeColumnTemplateDirective } from '@document-explorer/angular';
html
<doc-explorer [data]="documents" [columns]="columns">
  <ng-template deColumn="metadata.owner" let-item let-value="value">
    @if (value) {
      <span class="de-avatar" aria-hidden="true">{{ initials(value) }}</span>
      <span class="de-avatar-label">{{ value }}</span>
    }
  </ng-template>
</doc-explorer>

The template context gives you both the whole item and value — the column's already-formatted text — so you rarely have to re-derive it.

Badges

A status, a state, a category — built in, with defaults you can throw away.

tsx
import { badgeColumn } from '@document-explorer/react';

<DocumentExplorer columns={['name', badgeColumn({ key: 'metadata.status' }), 'size']} />
ts
readonly status = badgeColumn({ key: 'metadata.status' });
html
<ng-template deColumn="metadata.status" let-item>
  <de-badge-cell [item]="item" columnKey="metadata.status" [options]="status.options" />
</ng-template>

Defaults, not decisions

Common values get a conventional tone with no configuration at all:

ValueTone
approved, active, complete, published, donesuccess
review, in_review, pendingwarning
rejected, failed, error, expireddanger
draft, archivedneutral
newinfo

Matching is case-insensitive, and labels are humanised — in_review renders as “In review”. Anything unrecognised is neutral.

Overriding all of it

tsx
badgeColumn({ key: 'metadata.status', tones: { draft: 'info' } })          // remap one value
badgeColumn({ key: 'metadata.status', tones: (v) => myTone(v) })          // decide it yourself
badgeColumn({ key: 'metadata.status', format: (v) => v.toUpperCase() })   // your own label
badgeColumn({ key: 'metadata.status', variant: 'pill' })                  // filled pill
badgeColumn({ key: 'metadata.status', fallbackTone: 'info' })             // different default

A tones function wins over the map, which wins over the conventions — so you can override one value, or take the decision away entirely.

Colours

The five tones read from theme tokens, so recolouring every badge in the app is a stylesheet change rather than a prop:

css
.my-explorer {
  --de-tone-success: #15803d;
  --de-tone-success-soft: #f0fdf4;   /* pill background */
}

Rows with no value render an empty cell, not a meaningless grey pill.

If none of that fits, a plain render (React) or deColumn template (Angular) puts the cell entirely in your hands — see cell content.

Avatars

Showing a person in a column is common enough that it is built in.

React

tsx
import { DocumentExplorer, avatarColumn } from '@document-explorer/react';

<DocumentExplorer
  columns={['name', avatarColumn({ key: 'metadata.owner' }), 'size']}
/>

Angular

ts
readonly owner = avatarColumn({ key: 'metadata.owner' });
readonly columns = ['name', this.owner.column, 'size'];
html
<doc-explorer [data]="documents" [columns]="columns">
  <ng-template deColumn="metadata.owner" let-item>
    <de-avatar-cell [item]="item" columnKey="metadata.owner" [options]="owner.options" />
  </ng-template>
</doc-explorer>

Angular cannot return markup from a plain object the way React's render can, so avatarColumn hands back the column and the options to feed <de-avatar-cell>.

Two independent settings

This is the part worth reading slowly, because the two are easy to conflate:

  • display — how much of the person to show: the avatar, the name, or both.
  • image — what the avatar is: a photo, or generated initials.

They compose freely. A photo still shows the name unless you also ask for display: 'avatar':

display: 'both' (default)display: 'avatar'display: 'name'
no imageinitials + nameinitials, name on hovername only
with imagephoto + namephoto, name on hovername only (no avatar drawn)
tsx
avatarColumn({ key: 'metadata.owner' })
// initials + name

avatarColumn({ key: 'metadata.owner', display: 'avatar' })
// initials alone, name on hover

avatarColumn({ key: 'metadata.owner', image: 'metadata.pic' })
// photo + name

avatarColumn({ key: 'metadata.owner', image: 'metadata.pic', display: 'avatar' })
// photo alone, name on hover  ← the compact one

display: 'avatar' is for narrow columns: the name moves to a tooltip and to screen-reader-only text, so hovering reveals it and assistive technology still reads it. An avatar with no way to recover the name is a puzzle, not a feature, so the tooltip is on by default in that mode and off when the name is already visible. Force it either way with tooltip: true | false.

display: 'name' draws no avatar at all, so an image alongside it has nothing to render into — the combination is harmless, just inert.

An image falls back to initials if it fails to load, so a broken URL degrades to something readable rather than an empty circle.

See it

The Playground in the React example has display and image as separate controls, so you can flip between all six combinations and copy the resulting props.

Options

OptionMeaning
keyWhere the name lives. Also the column key.
nameRead the name from somewhere else — dot path or function.
imageImage URL — dot path or function.
display'both' (default), 'avatar', 'name'.
tooltipForce the hover name on or off.
colorA fixed colour, or a function of the name.
fallbackLabel for rows with no name, e.g. 'Unassigned'.

Anything a ColumnDef accepts — label, width, minWidth, sortable, resizable — passes straight through:

tsx
avatarColumn({ key: 'metadata.owner', label: 'Assignee', width: '220px', display: 'avatar' })

Rows with no name and no image render an empty cell, rather than an anonymous grey circle on every row.

Colours

Each name gets a stable swatch from six theme tokens, so the same person is the same colour everywhere and in both frameworks — and the palette follows light and dark:

css
.my-explorer {
  --de-avatar-1: #0f766e;
  --de-avatar-size: 28px;
  --de-avatar-foreground: #fff;
}

Pass color to override entirely.

Rolling your own

avatarColumn is only a ColumnDef with a render, so nothing stops you writing the cell by hand — see cell content above. These stylesheet helpers are available either way:

ClassWhat it does
.de-avatarCircle sized by --de-avatar-size, crops any <img> inside
.de-avatar-labelTruncates the name with an ellipsis
.de-avatar-stackOverlaps several avatars, for a shared-with column

A cell with custom content is laid out as a flex row rather than a text run, so an icon and a label sit side by side without extra wrapping.

Rebuilding the shell entirely

Every sub-component is exported, so you can keep the engine and write your own chrome:

tsx
import {
  useDocumentExplorer,
  Breadcrumbs,
  SearchInput,
  ListView,
} from '@document-explorer/react';

function MyExplorer({ data }) {
  const { store, snapshot } = useDocumentExplorer({ data });
  return (
    <div>
      <MyOwnHeader />
      <Breadcrumbs items={snapshot.breadcrumbs} onNavigate={store.actions.openFolder} />
      <ListView items={snapshot.items} /* … */ />
    </div>
  );
}

Or drop the UI package altogether and drive the core store yourself.

Released under the MIT License.