Angular  

Implementing an Excel-like Editable Grid in Angular — Without Third-Party Libraries

Building an Excel-like grid using only Angular and browser APIs is a great exercise: you keep full control, avoid heavy dependencies, and tailor the UX for your product. Below is a practical, production-ready guide that covers architecture, UX, accessibility, performance, and a working minimal implementation you can extend.

Language: simple Indian English. No emojis. Diagrams use smaller headers as requested.

Why build your own editable grid?

  • Avoid large third-party bundles and license constraints.

  • Customise behaviour exactly (keyboard shortcuts, validations, formulas).

  • Keep tight control on accessibility, testing, and performance.

  • Integrate tightly with your existing ASP.NET Core APIs and SQL Server.

Use this approach when you need a grid tailored to business workflows (work orders, spreadsheets, inventory edits) and want predictable behaviour.

High-level features to implement

  • Cell editing (inline edit)

  • Keyboard navigation (arrows, Enter, Tab, Esc)

  • Copy / Paste (single cell and multi-cell)

  • Selection (single / range)

  • Column types (text, number, date, dropdown)

  • Validation and error states

  • Undo / redo and change tracking

  • Virtual scrolling for large data sets

  • Bulk commit and server sync with patch/optimistic updates

  • Accessibility (ARIA, screen reader support)

Workflow (smaller header)

User opens grid → Navigate cells with keyboard/mouse → Enter edit mode → Edit value → Commit or cancel →
Change stored in client-change-buffer → Validate locally → Sync to server (bulk or per-row) → Server returns status → Update UI

Flowchart (smaller header)

+-------------------+
| Load page / data  |
+--------+----------+
         |
         v
+--------+----------+
| Render grid with  |
| visible rows      |
+--------+----------+
         |
         v
+--------+----------+
| User interaction  |
| (keyboard / mouse)|
+--------+----------+
         |
   +-----+-----+
   |           |
   v           v
Edit cell   Select range
   |           |
   v           v
Validate   Copy/Paste, Bulk ops
   |           |
   +-----+-----+
         |
         v
Persist changes (local buffer) → Sync to server

Architecture diagram (Visio-style, smaller header)

+----------------------+      +-------------------------+      +----------------------+
|   Angular Frontend   | <--> | State & Change Manager  | <--> |  ASP.NET Core API     |
| (Grid Component, UI) |      | (Undo/Redo, Buffer)     |      | (PATCH/Batch endpoints)|
+----------------------+      +-------------------------+      +----------------------+
            |                            |
            v                            v
     Browser Clipboard              SQL Server (committed)
     (navigator.clipboard)          (transactions, audit)

ER diagram (smaller header)

(If you store edits, audit and grid metadata)

+----------------------+    +-------------------------+    +----------------------+
| GridDefinition       | 1--*| GridRow                | 1--*| GridCellChange       |
+----------------------+    +-------------------------+    +----------------------+
| GridId (PK)          |    | RowId (PK)             |    | ChangeId (PK)        |
| Name                 |    | GridId (FK)            |    | RowId (FK)           |
| Columns (JSON)       |    | Data (JSON)            |    | ColumnKey            |
| CreatedOn            |    | CreatedOn              |    | OldValue             |
+----------------------+    +-------------------------+    | NewValue             |
                                                             | UserId               |
                                                             | Timestamp            |
                                                             +----------------------+

Sequence diagram (smaller header)

User → GridComponent: navigate & edit
GridComponent → ChangeManager: store edit (buffer)
GridComponent → Validator: validate value
ChangeManager → UI: update cell state
User → GridComponent: save all
GridComponent → API: send batch PATCH
API → DB: apply transaction
API → GridComponent: return status
GridComponent → ChangeManager: mark as committed

Minimal but practical Angular implementation

Below is a compact Angular component that demonstrates:

  • Inline editing with contenteditable for simple cells

  • Keyboard navigation (arrows, Enter, Tab, Esc)

  • Selection and copy/paste using the Clipboard API

  • A client change buffer and a simple batch save method

This is intentionally small — extend to add virtualization, complex validation, undo/redo, and column types.

Notes: This code targets Angular 15+ and TypeScript. Adapt styles and templates as needed.

grid.model.ts (types)

export interface Column {
  key: string;
  title: string;
  width?: number;
  type?: 'text'|'number'|'date'|'select';
  options?: string[]; // for select
}

export interface Row {
  id: string | number;
  [key: string]: any;
}

export interface Change {
  rowId: string | number;
  columnKey: string;
  oldValue: any;
  newValue: any;
}

grid.component.ts

import { Component, HostListener, Input, OnInit } from '@angular/core';
import { Column, Row, Change } from './grid.model';

@Component({
  selector: 'app-editable-grid',
  templateUrl: './grid.component.html',
  styleUrls: ['./grid.component.scss']
})
export class GridComponent implements OnInit {
  @Input() columns: Column[] = [];
  @Input() rows: Row[] = [];

  // position of focused cell
  focusedRowIndex = 0;
  focusedColIndex = 0;

  // edit state
  editing = false;

  // change buffer for batch commit
  changes: Change[] = [];

  // selection range
  selectionStart: { r: number, c: number } | null = null;
  selectionEnd: { r: number, c: number } | null = null;

  ngOnInit() {}

  // click to focus cell
  focusCell(rIndex: number, cIndex: number) {
    this.focusedRowIndex = rIndex;
    this.focusedColIndex = cIndex;
    this.editing = false;
    // collapse selection
    this.selectionStart = null;
    this.selectionEnd = null;
  }

  // double click or Enter -> start editing
  startEdit() {
    this.editing = true;
    // setTimeout to place caret if contenteditable
  }

  // commit value when leaving edit or pressing Enter
  commitEdit(newValue: any) {
    const row = this.rows[this.focusedRowIndex];
    const col = this.columns[this.focusedColIndex];
    const oldVal = row[col.key];
    if (newValue !== oldVal) {
      row[col.key] = newValue;
      this.changes.push({ rowId: row.id, columnKey: col.key, oldValue: oldVal, newValue });
    }
    this.editing = false;
  }

  // cancel edit
  cancelEdit() {
    this.editing = false;
  }

  // keyboard navigation and edit handling
  @HostListener('document:keydown', ['$event'])
  handleKey(e: KeyboardEvent) {
    const key = e.key;
    if (this.editing) {
      if (key === 'Escape') {
        this.cancelEdit();
        e.preventDefault();
      }
      // do not intercept other keys while editing
      return;
    }

    // arrow navigation
    if (key === 'ArrowDown') { this.moveFocus(1,0); e.preventDefault(); }
    if (key === 'ArrowUp')   { this.moveFocus(-1,0); e.preventDefault(); }
    if (key === 'ArrowRight'){ this.moveFocus(0,1); e.preventDefault(); }
    if (key === 'ArrowLeft') { this.moveFocus(0,-1); e.preventDefault(); }

    if (key === 'Enter') { this.startEdit(); e.preventDefault(); }
    if (key === 'Tab') { this.moveFocus(0, e.shiftKey ? -1 : 1); e.preventDefault(); }

    // copy (Ctrl+C) and paste (Ctrl+V)
    if ((e.ctrlKey || e.metaKey) && key.toLowerCase() === 'c') { this.copySelection(); e.preventDefault(); }
    if ((e.ctrlKey || e.metaKey) && key.toLowerCase() === 'v') { /* rely on paste handler */ }
  }

  moveFocus(deltaRow: number, deltaCol: number) {
    const r = Math.max(0, Math.min(this.rows.length - 1, this.focusedRowIndex + deltaRow));
    const c = Math.max(0, Math.min(this.columns.length - 1, this.focusedColIndex + deltaCol));
    this.focusedRowIndex = r;
    this.focusedColIndex = c;
  }

  // copy the currently focused cell (or selection) to clipboard
  async copySelection() {
    // for now only single cell
    const row = this.rows[this.focusedRowIndex];
    const col = this.columns[this.focusedColIndex];
    const text = row[col.key] != null ? String(row[col.key]) : '';
    try {
      await navigator.clipboard.writeText(text);
    } catch (err) {
      console.warn('clipboard write failed', err);
    }
  }

  // paste handler attached to cell input or window
  async handlePasteEvent(event: ClipboardEvent) {
    event.preventDefault();
    const text = event.clipboardData?.getData('text/plain') ?? '';
    // if selection is single cell, paste there
    const row = this.rows[this.focusedRowIndex];
    const col = this.columns[this.focusedColIndex];
    if (row && col) {
      this.commitEdit(text);
    }
  }

  // save all changes to server (example batch)
  async saveAll() {
    if (this.changes.length === 0) return;
    // build patch payload; server should accept array of changes
    const payload = this.changes.map(c => ({
      rowId: c.rowId,
      column: c.columnKey,
      value: c.newValue
    }));
    // call service (pseudo)
    try {
      // await this.gridService.batchUpdate(payload).toPromise();
      // on success
      this.changes = [];
    } catch (err) {
      // handle server errors (reconcile, show errors)
      console.error(err);
    }
  }
}

grid.component.html

<div class="grid" (paste)="handlePasteEvent($event)">
  <div class="grid-header">
    <div class="grid-cell header" *ngFor="let col of columns">{{col.title}}</div>
  </div>

  <div class="grid-body">
    <div class="grid-row" *ngFor="let row of rows; let rIdx = index">
      <div
        class="grid-cell"
        *ngFor="let col of columns; let cIdx = index"
        [class.focused]="rIdx === focusedRowIndex && cIdx === focusedColIndex"
        (click)="focusCell(rIdx, cIdx)"
        (dblclick)="startEdit()"
        tabindex="0"
      >
        <div *ngIf="!editing || !(rIdx === focusedRowIndex && cIdx === focusedColIndex)">
          {{row[col.key]}}
        </div>

        <div *ngIf="editing && rIdx === focusedRowIndex && cIdx === focusedColIndex">
          <!-- simple input editor; for numbers use type=number etc -->
          <input
            #editor
            [value]="rows[rIdx][col.key]"
            (blur)="commitEdit(editor.value)"
            (keydown.enter)="commitEdit(editor.value)"
            (keydown.escape)="cancelEdit()"
            autofocus
          />
        </div>
      </div>
    </div>
  </div>
</div>

grid.component.scss (minimal)

.grid { font-family: Arial; border: 1px solid #ddd; }
.grid-header { display:flex; background:#f5f5f5; }
.grid-row { display:flex; }
.grid-cell { padding: 6px 8px; border-right:1px solid #eee; border-bottom:1px solid #eee; min-width:120px; }
.grid-cell.focused { outline: 2px solid #4a90e2; background: #fffbe6; }
.header { font-weight:600; }
input { width:100%; box-sizing:border-box; }

Extending to production

1. Virtual scrolling

For tens of thousands of rows, implement virtual scrolling (render only visible rows). Implement your own simple viewport mechanism or use cdk-virtual-scroll-viewport if allowed; but you said without third-party — CDK is Angular first-party and acceptable if you allow it; otherwise implement a windowing technique.

2. Column types & editors

Abstract cell editors into a registry: type -> component. Use ngComponentOutlet to dynamically render editor components (text input, number input with spinner, date picker, dropdown).

3. Copy / paste multi-cell

Handle rectangular ranges and export/import tabular text (TSV). On copy, produce tab-separated values; on paste parse TSV and map into grid starting from focused cell.

4. Validation

  • Add validators per column (regex, min/max).

  • Validate on change and mark cell state (error tooltip).

  • Prevent commit if server rejects change, and show reconciliation UI.

5. Undo / redo

Implement a stack of changes (changes push to undo stack; redo stack on undo). Use change objects that can apply() and revert().

6. Optimistic update vs server truth

Choose optimistic UI updates (apply locally then send) or pessimistic (send first then apply). For better UX, optimistic + reconciliation is common.

7. Bulk commit & patch API

Design a server API that accepts a list of patches:

POST /api/grid/changes
[
  { "rowId": "...", "column": "qty", "value": 10 },
  { "rowId": "...", "column": "price", "value": 99.5 }
]

Apply in a server transaction and return conflict details if any.

8. Concurrency control

  • Use row versioning (rowVersion/timestamp) to detect concurrent edits.

  • If conflict, return 409 with latest row data; provide UI to merge or overwrite.

9. Performance & memory

  • Debounce frequent edits if you autosave.

  • Use immutable row copies for change detection efficiency.

  • Avoid large object graphs in templates; use trackBy in *ngFor.

10. Accessibility

  • Provide ARIA roles: role="grid", role="row", role="gridcell".

  • Ensure keyboard-only navigation works, and editor inputs receive proper labels.

  • Provide screen-reader announcements for errors and commits.

Testing strategy

  • Unit tests for ChangeManager, validator and cell renderer logic.

  • Integration tests for keyboard navigation sequences (use Protractor or Cypress).

  • Performance tests for rendering and batch save throughput.

  • Accessibility tests (axe-core integration) for ARIA and keyboard support.

Deployment & Observability

  • Log edit events and batch save metrics.

  • Track latency for commit operations and conflict rate.

  • Provide per-user autosave settings or server policies to reduce load.

Summary — recommended roadmap

  1. Build minimal grid (template above) with keyboard nav, edit, copy/paste and batch save.

  2. Add validators and server patch API.

  3. Implement selection ranges and multi-cell copy/paste.

  4. Add virtual scroll and editor registry for types.

  5. Implement undo/redo, optimistic updates, and conflict resolution.

  6. Harden accessibility and test coverage.