Skip to content

Labs component

NbSpreadsheet is published from the Labs entry. The component is functional and tested, but its API may still change before it graduates into the core library. Pull it in with app.use(NubiscoUILabs) (see Installation).

NbSpreadsheet is a spreadsheet-style data grid. It draws on the same design language as NbBoard and IBM Carbon's Data Spreadsheet pattern: dense rows, hairline borders, a sticky header, an inset focus ring, and tabular numerics throughout.

The contract is intentionally narrow: the host owns the data and the formulas, the component renders the grid, captures input, and emits changes. There is no built-in formula engine — derived cells come from a computed callback the host supplies, so you can wire it into Vuex, Pinia, a worker thread, or a remote service without forking the component.

vue
<template>
  <NbSpreadsheet :columns="columns" :rows="rows" @commit="onCommit" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type {
  ISpreadsheetColumn,
  ISpreadsheetRow,
  ISpreadsheetChange,
} from '@nubisco/ui'

const columns: ISpreadsheetColumn[] = [
  { id: 'name', label: 'Name', width: 180 },
  { id: 'qty', label: 'Quantity', type: 'number', align: 'right' },
  { id: 'price', label: 'Unit price', type: 'number', align: 'right' },
]

const rows = ref<ISpreadsheetRow[]>([
  { id: 'r1', values: { name: 'Hex bolt M8', qty: 50, price: 0.42 } },
  { id: 'r2', values: { name: 'Washer M8', qty: 50, price: 0.08 } },
])

function onCommit(change: ISpreadsheetChange) {
  const row = rows.value.find((r) => r.id === change.rowId)
  if (row) row.values[change.columnId] = change.after
}
</script>

Installation

NbSpreadsheet lives in the Labs plugin. Install it alongside the core plugin:

ts
import { createApp } from 'vue'
import NubiscoUI, { NubiscoUILabs } from '@nubisco/ui'
import '@nubisco/ui/dist/ui.css'

const app = createApp(App)
app.use(NubiscoUI)
app.use(NubiscoUILabs)
app.mount('#app')

You can also import NbSpreadsheet directly for tree-shaken usage:

ts
import { NbSpreadsheet } from '@nubisco/ui'

Columns and rows

A column declares its identity, label, type, alignment, default width and whether the column is pinned, sortable, or read-only. The format and parse hooks let you take over display and commit-time validation.

A row carries its own id and a values map keyed by columnId. Rows can pin themselves to the top or bottom of the viewport, which is useful for totals, headers, and reference rows that need to stay on screen while the user scrolls.

ts
const columns: ISpreadsheetColumn[] = [
  { id: 'day', label: 'Day', pinned: 'left', readOnly: true, align: 'center' },
  {
    id: 'eur',
    label: 'Revenue',
    type: 'number',
    align: 'right',
    format: (v) => `€ ${Number(v ?? 0).toLocaleString('pt-PT')}`,
    parse: (input) => Number(input.replace(/[^0-9.,-]/g, '').replace(',', '.')),
  },
]

Derived cells (totals row)

Mark a row as computed: true and pin it to the bottom; provide a computed prop that returns the derived value. The function receives a get(rowId, columnId) reader so you can sum, multiply, or join other cells without storing the result in values.

ts
const rows: ISpreadsheetRow[] = [
  { id: 'r1', values: { item: 'Sensors', qty: 12, unit: 14 } },
  { id: 'r2', values: { item: 'Actuators', qty: 6, unit: 39 } },
  {
    id: 'totals',
    pinned: 'bottom',
    computed: true,
    values: { item: 'Total' },
  },
]

function computeTotal(
  rowId: string,
  columnId: string,
  get: (r: string, c: string) => unknown,
) {
  if (rowId !== 'totals') return undefined
  if (columnId === 'qty')
    return Number(get('r1', 'qty')) + Number(get('r2', 'qty'))
  if (columnId === 'unit') return undefined // not summable
  if (columnId === 'amt') {
    const a = Number(get('r1', 'qty')) * Number(get('r1', 'unit'))
    const b = Number(get('r2', 'qty')) * Number(get('r2', 'unit'))
    return a + b
  }
}

Per-cell attributes

cellAttrs(rowId, columnId, row, column) runs lazily for visible cells. Return a partial ISpreadsheetCellAttrs to decorate the cell: tag it read-only, attach a CSS class for conditional styling, set a tooltip, or override the displayed string. Returning void keeps the defaults.

ts
function cellAttrs(rowId: string, columnId: string) {
  // Weekend columns are read-only and tinted.
  if (columnId === 'sat' || columnId === 'sun') {
    return { readOnly: true, className: 'weekend' }
  }
}

Pair this with a small block of scoped CSS:

scss
:deep(.nb-spreadsheet__cell.weekend) {
  background: var(--nb-c-surface);
}

Pinned columns and pinned rows

Set column.pinned to 'left' or 'right' to keep a column visible while the user scrolls horizontally. Set row.pinned to 'top' or 'bottom' to do the same vertically. Multiple columns or rows can be pinned at the same edge — the order they appear in the array is preserved.

Sorting

Set column.sortable = true to allow header-click sorting. The first click sorts ascending, the second descending, the third clears the sort. Only the unpinned data rows are sorted; pinned rows stay in their slot.

Keyboard, mouse, and clipboard

ActionKeys
Move the active cell / Tab / Shift+Tab / Enter
Start editingAny printable key / F2 / double-click
Commit and move downEnter
Commit and move rightTab
Cancel editingEsc
Clear the selected rangeDelete / Backspace
Extend the selectionHold Shift while moving / Shift-click
Copy / cut / paste (TSV)Cmd/Ctrl+C / Cmd/Ctrl+X / Cmd/Ctrl+V
Undo / redoCmd/Ctrl+Z / Cmd/Ctrl+Shift+Z

Clipboard payloads are tab-separated values, so copy-paste round-trips cleanly with Excel, Numbers, and Google Sheets.

Theming

NbSpreadsheet is built entirely against the Surface API and inherits any layer the parent provides. Drop it inside .nb-layer-2 or under a dark theme and it will follow without overrides.

The component publishes per-instance CSS custom properties for the few decisions that callers regularly want to tune:

TokenDefaultPurpose
--nbss-row-height32pxData row height
--nbss-header-height36pxHeader row height
--nbss-cell-padding-x0.625remHorizontal padding inside cells
--nbss-focus-ringvar(--nb-c-primary)Inset outline color
scss
// Denser variant
.my-compact-sheet {
  --nbss-row-height: 24px;
  --nbss-header-height: 28px;
  --nbss-cell-padding-x: 0.4rem;
}

Props

PropTypeDefaultDescription
columnsISpreadsheetColumn[]-Column definitions, ordered left-to-right
rowsISpreadsheetRow[]-Row data. Pinned rows can appear anywhere in the array
cellAttrs(rowId, columnId, row, column) => ISpreadsheetCellAttrs | void-Per-cell read-only / class / tooltip / display override
computed(rowId, columnId, get) => unknown-Returns the derived value for cells that are not in row.values
resizablebooleantrueAllow column resize from the right edge of the header
rowReorderablebooleanfalseAllow row drag-reorder via the gutter (Labs follow-up; emits row-reorder)
showGutterbooleantrueShow the row-index gutter on the left
windowRowsnumber80Approximate visible rows used for the simple row virtualization
localestring'pt-PT'Default locale for Intl-based number/date formatting

Column interface

typescript
interface ISpreadsheetColumn {
  id: string // stable id
  label: string // header text
  width?: number // default 120
  minWidth?: number // default 60
  pinned?: 'left' | 'right' // freeze the column
  align?: 'left' | 'center' | 'right' // text alignment
  type?: 'text' | 'number' | 'date' // drives default format + parse + inputmode
  readOnly?: boolean // column-wide read-only
  sortable?: boolean // header click sorts
  format?: (raw: unknown, row: ISpreadsheetRow) => string
  parse?: (input: string) => unknown
}

Row interface

typescript
interface ISpreadsheetRow {
  id: string
  values: Record<string, unknown> // columnId -> raw value
  pinned?: 'top' | 'bottom' // freeze the row
  className?: string // row-wide CSS class
  computed?: boolean // marks as derived (read-only + styled)
}

Cell attributes

typescript
interface ISpreadsheetCellAttrs {
  readOnly?: boolean
  className?: string
  tooltip?: string
  display?: string // override the rendered string
  computed?: boolean // cell-level derived marker
}

Events

EventPayloadDescription
commitISpreadsheetChangeA single cell was committed (edit, paste, delete, undo)
changeISpreadsheetChange[]Batched changes for bulk operations (paste, delete-range, undo group)
selectionISpreadsheetSelection | nullThe active selection range changed
focus{ rowId: string; columnId: string } | nullThe active cell changed
row-reorder{ rowId: string; toIndex: number }Emitted when a row is dropped at a new index (only when rowReorderable)

Change interface

typescript
interface ISpreadsheetChange {
  rowId: string
  columnId: string
  before: unknown
  after: unknown
}

Selection interface

typescript
interface ISpreadsheetSelection {
  startRowId: string
  startColumnId: string
  endRowId: string
  endColumnId: string
}

Exposed

typescript
const sheet = ref<InstanceType<typeof NbSpreadsheet>>()
sheet.value?.focus(rowId, columnId)
sheet.value?.undo()
sheet.value?.redo()
sheet.value?.getSelection()

Slots

NamePurpose
toolbarRenders above the grid. Use for save status, filters, actions.

Accessibility

  • The scrollable surface is focusable and announces a focus ring via box-shadow on the inner edge so it does not move adjacent content.
  • The active cell carries the same inset ring so keyboard users always see where they are.
  • Header cells are role columnheader; data cells are role gridcell. (Labs follow-up: wire up aria-rowindex / aria-colindex for screen reader virtual-row support.)

Surface API and theming

The component reads:

  • --nb-c-surface for the header, toolbar, gutter, and read-only cells.
  • --nb-c-surface-raised for the editable data area.
  • --nb-c-surface-hover for row hover and sortable header hover.
  • --nb-c-border for every line.
  • --nb-c-text, --nb-c-text-muted for foreground.
  • --nb-c-primary (and --nb-c-primary-100) for the focus ring and selection tint.

Anywhere a .nb-layer-N modifier reassigns those tokens, NbSpreadsheet follows automatically.