# EditorTabs

> The frame around a document editor with a tab strip of open documents, a status bar for words, cursor and save state, and a backlinks and related panel.

Source: https://docs.nasaqui.com/components/editor-chrome

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/editor-chrome.json
```

Three presentational pieces that turn a bare text area into a document editor: `EditorTabs` (the open documents, like a code editor's tab strip), `EditorStatusBar` (word and character counts, line and column, and whether the text is saved) and `EditorBacklinks` (documents that link here, and related ones). You own the text and the state; the chrome shows it and reports what the person did.

## When to use

- A notes or writing app where several documents can be open at once.
- Any editor that autosaves and needs a calm, always visible save state.
- Knowledge tools that link documents to each other.

## When not to use

- Page-level navigation: use [tabs](https://docs.nasaqui.com/components/tabs) (Base UI tabs, panels included). `EditorTabs` has no panels; the editor is yours.
- Editing the rich text itself: use [rich-text-editor](https://docs.nasaqui.com/components/rich-text-editor).
- A list of every note: use [notes](https://docs.nasaqui.com/components/notes) or `EntityList`.

## Import

```tsx
import { EditorTabs, EditorStatusBar, EditorBacklinks } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { useState } from "react";
import { EditorBacklinks, EditorStatusBar, EditorTabs, closeEditorTab, editorCursorAt, editorTextStats } from "@fadymondy/nasaq/web";

export function Editor() {
  const [tabs, setTabs] = useState([
    { id: "a", title: "Trip plan", dirty: true },
    { id: "b", title: "Ideas" },
  ]);
  const [active, setActive] = useState<string | null>("a");
  const [text, setText] = useState("Book the flights");
  const [caret, setCaret] = useState(0);
  const stats = editorTextStats(text);
  const { line, column } = editorCursorAt(text, caret);

  return (
    <div className="flex flex-col">
      <EditorTabs
        tabs={tabs}
        activeId={active}
        onSelect={setActive}
        onClose={(id) => {
          const next = closeEditorTab(tabs, active, id);
          setTabs(next.tabs);
          setActive(next.activeId);
        }}
      />
      <textarea value={text} onChange={(e) => setText(e.target.value)} onSelect={(e) => setCaret(e.currentTarget.selectionStart)} />
      <EditorStatusBar words={stats.words} characters={stats.characters} line={line} column={column} saveState="saved" />
    </div>
  );
}
```

## Anatomy

```
EditorTabs         data-slot="editor-tabs"        (inner element has role="tablist")
├─ tab             role="tab", dirty dot, close button
│   (wrapped in ContextMenuActions: close, close others, close all, pin, your tabActions)
└─ new button      (the + button)

EditorStatusBar    data-slot="editor-status-bar"  role="group"
├─ items · words · characters · line/column · selection
└─ save state      data-slot="editor-save-state"  role="status" (role="alert" for error)

EditorBacklinks    data-slot="editor-backlinks"   <aside>
├─ Backlinks list  
└─ Related list
```

## API

### EditorTabs

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tabs` | `EditorTab[]` | required | `{ id, title, dirty?, pinned?, path? }`. Pinned tabs sort first. |
| `activeId` | `string \| null` | required | The tab whose document is showing. |
| `onSelect` | `(id) => void` | required | A tab was picked. |
| `onClose` | `(id) => void` | none | The tab's close button, middle-click, or Delete on the focused tab. Omit to make tabs permanent. |
| `onNew` | `() => void` | none | Adds the "+" button. |
| `onCloseOthers` / `onCloseAll` / `onPin` | callbacks | none | Add those items to the context menu. |
| `tabActions` | `(tab) => ContextMenuAction[]` | none | Extra context menu items after the built-in ones. |
| `labels` | `EditorChromeLabels` | English or Arabic | Overrides for every string. |

### EditorStatusBar

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words`, `characters` | `number` | none | Counts, formatted for the locale. Hidden when omitted. |
| `line`, `column` | `number` | none | 1-based caret position. |
| `selection` | `number` | none | Selected characters, shown only above zero. |
| `saveState` | `"saved" \| "saving" \| "dirty" \| "error" \| "offline"` | none | The save state. `error` and `offline` need attention. |
| `onRetry` | `() => void` | none | Shows a Retry button in the error state. |
| `items`, `trailing` | `ReactNode` | none | Extra items at the start and before the save state. |
| `labels` | `EditorChromeLabels` | | String overrides. |

### EditorBacklinks

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `backlinks` | `EditorLink[]` | required | `{ id, title, snippet?, path?, href? }`: documents that link here. |
| `related` | `EditorLink[]` | none | Related documents. |
| `onOpen` | `(link) => void` | none | A link was chosen. |
| `highlight` | `string` | none | Word to mark inside snippets. |
| `labels` | `EditorChromeLabels` | | String overrides. |

### Helpers

Pure functions, also exported: `closeEditorTab(tabs, activeId, id)`, `closeOtherEditorTabs(tabs, activeId, id)`, `orderEditorTabs(tabs)`, `editorTabKeyTarget(ids, currentId, key, dir)`, `editorCursorAt(text, offset)`, `editorTextStats(text)`, `editorSaveNeedsAttention(state)`.

## Examples

```tsx
<EditorTabs tabs={tabs} activeId={active} onSelect={setActive} onClose={close} onNew={createDoc} onPin={pin} onCloseOthers={closeOthers} onCloseAll={closeAll} />
```

```tsx
<EditorStatusBar saveState="error" onRetry={retrySave} words={120} characters={640} line={4} column={12} />
```

```tsx
<EditorBacklinks
  backlinks={[{ id: "1", title: "Weekly review", snippet: "…see [[Trip plan]] for dates…", path: "Journal" }]}
  related={[{ id: "2", title: "Packing list" }]}
  highlight="Trip plan"
  onOpen={(link) => open(link.id)}
/>
```

## Accessibility

| Key | Action |
| --- | --- |
| Arrow keys | Move between tabs (Left and Right swap in RTL). |
| Home, End | First and last tab. |
| Enter, Space | Select the focused tab. |
| Delete | Close the focused tab (when `onClose` is set). |
| Shift+F10, Menu key | Open the tab's context menu. |

- The strip is a `tablist` with a roving tab stop. A dirty tab has visually hidden text "Unsaved changes", not only a dot.
- The save state is a `role="status"` live region; the error state is `role="alert"`. Every state has an icon and text, never colour alone.
- Localise `labels` when the app has its own wording.

## RTL & i18n

- The strip, arrows and counts follow the Nasaq direction. Left and Right arrows swap in RTL.
- Counts use the locale's formatting with Latin digits by default. Document titles use `dir="auto"`.
- Line and column labels are localised ("Ln 4" and "سطر 4").

## Styling & tokens

Uses `--nq-*` surface, border, selected and focus tokens. Target `data-slot`, and `data-active` and `data-dirty` on tabs. Extend with `className`.

## Do / Don't

- Do keep the save state visible. Autosave without feedback makes people press Ctrl+S anyway.
- Do close a tab and move to its neighbour (`closeEditorTab` does this).
- Don't use the tab strip for navigation between app sections.
- Don't hide a failed save behind a toast. The status bar keeps it in view.

## Related

- [notes](https://docs.nasaqui.com/components/notes), [rich-text-editor](https://docs.nasaqui.com/components/rich-text-editor), [context-menu](https://docs.nasaqui.com/components/context-menu), [tabs](https://docs.nasaqui.com/components/tabs)

## Lab

https://docs.nasaqui.com/?path=/docs/components-editors-editor-chrome--docs

## Code

### React

```tsx
import { useState } from "react";
import { EditorBacklinks, EditorStatusBar, EditorTabs, closeEditorTab, editorCursorAt, editorTextStats } from "@fadymondy/nasaq/web";

export function Editor() {
  const [tabs, setTabs] = useState([
    { id: "a", title: "Trip plan", dirty: true },
    { id: "b", title: "Ideas" },
  ]);
  const [active, setActive] = useState<string | null>("a");
  const [text, setText] = useState("Book the flights");
  const [caret, setCaret] = useState(0);
  const stats = editorTextStats(text);
  const { line, column } = editorCursorAt(text, caret);

  return (
    <div className="flex flex-col">
      <EditorTabs
        tabs={tabs}
        activeId={active}
        onSelect={setActive}
        onClose={(id) => {
          const next = closeEditorTab(tabs, active, id);
          setTabs(next.tabs);
          setActive(next.activeId);
        }}
      />
      <textarea value={text} onChange={(e) => setText(e.target.value)} onSelect={(e) => setCaret(e.currentTarget.selectionStart)} />
      <EditorStatusBar words={stats.words} characters={stats.characters} line={line} column={column} saveState="saved" />
    </div>
  );
}
```

### shadcn

```tsx
import { useState } from "react";
import { EditorBacklinks, EditorStatusBar, EditorTabs, closeEditorTab, editorCursorAt, editorTextStats } from "@/components/ui/editor-chrome";

export function Editor() {
  const [tabs, setTabs] = useState([
    { id: "a", title: "Trip plan", dirty: true },
    { id: "b", title: "Ideas" },
  ]);
  const [active, setActive] = useState<string | null>("a");
  const [text, setText] = useState("Book the flights");
  const [caret, setCaret] = useState(0);
  const stats = editorTextStats(text);
  const { line, column } = editorCursorAt(text, caret);

  return (
    <div className="flex flex-col">
      <EditorTabs
        tabs={tabs}
        activeId={active}
        onSelect={setActive}
        onClose={(id) => {
          const next = closeEditorTab(tabs, active, id);
          setTabs(next.tabs);
          setActive(next.activeId);
        }}
      />
      <textarea value={text} onChange={(e) => setText(e.target.value)} onSelect={(e) => setCaret(e.currentTarget.selectionStart)} />
      <EditorStatusBar words={stats.words} characters={stats.characters} line={line} column={column} saveState="saved" />
    </div>
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { computed, ref } from "vue";
import { NqEditorStatusBar, NqEditorTabs, closeEditorTab, editorCursorAt, editorTextStats } from "@fadymondy/nasaq/vue";

const tabs = ref([
  { id: "a", title: "Trip plan", dirty: true },
  { id: "b", title: "Ideas" },
]);
const active = ref<string | null>("a");
const text = ref("Book the flights");
const caret = ref(0);
const stats = computed(() => editorTextStats(text.value));
const cursor = computed(() => editorCursorAt(text.value, caret.value));

function close(id: string) {
  const next = closeEditorTab(tabs.value, active.value, id);
  tabs.value = next.tabs;
  active.value = next.activeId;
}
</script>

<template>
  <div class="flex flex-col">
    <NqEditorTabs :tabs="tabs" :active-id="active" @select="(id) => (active = id)" @close="close" />
    <textarea v-model="text" class="min-h-24 p-3" @select="caret = ($event.currentTarget as HTMLTextAreaElement).selectionStart" />
    <NqEditorStatusBar :words="stats.words" :characters="stats.characters" :line="cursor.line" :column="cursor.column" save-state="saved" />
  </div>
</template>
```

### Blade

```blade
<div class="flex flex-col">
    <x-nq::editor-chrome.tabs :tabs="[['id' => 'a', 'title' => 'Trip plan', 'dirty' => true], ['id' => 'b', 'title' => 'Ideas'], ['id' => 'c', 'title' => 'Packing', 'pinned' => true]]" active="a" new />
    <textarea id="editor-chrome-text" class="min-h-24 p-3">Book the flights</textarea>
    <x-nq::editor-chrome.status-bar source="#editor-chrome-text" save-state="saved" retry />
    <x-nq::editor-chrome.backlinks class="p-3" :backlinks="[['id' => 'n1', 'title' => 'Packing list', 'snippet' => 'See the Trip plan for dates.', 'path' => 'Travel']]" highlight="Trip plan" />
</div>
```

### HTML + Alpine

```html
<div class="flex flex-col">
    <div data-slot="editor-tabs" x-data="nqEditorTabs(JSON.parse('[{\u0022id\u0022:\u0022c\u0022,\u0022title\u0022:\u0022Packing\u0022,\u0022pinned\u0022:true},{\u0022id\u0022:\u0022a\u0022,\u0022title\u0022:\u0022Trip plan\u0022,\u0022dirty\u0022:true},{\u0022id\u0022:\u0022b\u0022,\u0022title\u0022:\u0022Ideas\u0022}]'), 'a', JSON.parse('{\u0022closable\u0022:true}'))" x-modelable="active"
    class="flex items-stretch border-b border-border bg-nq-surface-soft">
    <div role="tablist" aria-label="Open documents" class="flex min-w-0 flex-1 items-stretch overflow-x-auto [scrollbar-width:thin]">
        <template x-for="tab in tabs" :key="tab.id">
            <div role="tab" x-bind:data-tab-id="tab.id" x-bind:data-active="tab.id === active ? '' : null" x-bind:data-dirty="tab.dirty ? '' : null"
                x-bind:aria-selected="tab.id === active" x-bind:tabindex="tab.id === active ? 0 : -1" x-bind:title="tab.path || title(tab)"
                x-bind:class="tab.id === active ? 'bg-background text-foreground' : 'text-muted-foreground hover:bg-nq-hover hover:text-foreground'"
                x-on:click="select(tab.id)" x-on:keydown="onKey($event, tab)" x-on:auxclick="onAux($event, tab)"
                class="group/tab relative flex h-row max-w-56 min-w-24 shrink-0 cursor-default items-center gap-1.5 border-e border-border ps-3 pe-1 text-body-sm outline-none transition-colors duration-150 ease-nq focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-nq-focus">
                <span x-show="tab.id === active" aria-hidden="true" class="absolute inset-x-0 top-0 h-0.5 bg-primary"></span>
                <svg aria-hidden="true" class="size-3.5 shrink-0 opacity-70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>
  <path d="M14 2v5a1 1 0 0 0 1 1h5"/>
  <path d="M10 9H8"/>
  <path d="M16 13H8"/>
  <path d="M16 17H8"/>
</svg>                <span dir="auto" x-text="title(tab)" x-bind:class="tab.pinned ? 'font-medium' : ''" class="min-w-0 flex-1 truncate text-start"></span>
                <span x-show="tab.dirty" class="contents">
                    <span aria-hidden="true" class="size-2 shrink-0 rounded-full bg-nq-accent"></span>
                    <span class="sr-only">Unsaved changes</span>
                </span>
                <button type="button" tabindex="-1" x-show="closable && !tab.pinned" x-bind:aria-label="closeLabel(tab)" x-on:click.stop="close(tab.id)"
                    class="inline-flex size-5 shrink-0 items-center justify-center rounded-[4px] text-muted-foreground outline-none hover:bg-nq-hover hover:text-foreground pointer-coarse:size-7">
                    <svg aria-hidden="true" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M18 6 6 18"/>
  <path d="m6 6 12 12"/>
</svg>                </button>
            </div>
        </template>
    </div>
            <button data-slot="button"
     type="button"                         aria-label="New document" x-on:click="add()" class="inline-flex select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 text-foreground hover:bg-nq-hover size-control-sm p-0 m-1 shrink-0">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M5 12h14"/>
  <path d="M12 5v14"/>
</svg></button>
    </div>
    <textarea id="editor-chrome-text" class="min-h-24 p-3">Book the flights</textarea>
    <div data-slot="editor-status-bar" role="group" aria-label="Editor status" x-data="nqEditorStatus(JSON.parse('{\u0022saveState\u0022:\u0022saved\u0022}'), JSON.parse('{\u0022source\u0022:\u0022#editor-chrome-text\u0022}'))" x-on:nq-editor-status.window="set($event.detail)"
    class="flex min-h-8 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border bg-nq-surface-soft px-3 py-1 text-caption text-muted-foreground">
    <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
        <span x-show="s.line !== undefined" x-text="position()" class="tabular-nums"></span>
        <span x-show="s.selection > 0" x-text="selected()" class="tabular-nums" style="display: none"></span>
        <span x-show="s.words !== undefined" x-text="wordsText()" class="tabular-nums"></span>
        <span x-show="s.characters !== undefined" x-text="charsText()" class="tabular-nums"></span>
    </div>
    <div class="flex items-center gap-x-4">
        <span data-slot="editor-save-state" x-show="s.saveState" x-bind:data-state="s.saveState" x-bind:role="attention() ? 'alert' : 'status'"
            x-bind:class="s.saveState === 'error' ? 'text-nq-danger-text' : (s.saveState === 'offline' ? 'text-nq-warning-text' : '')"
            class="inline-flex items-center gap-1.5" >
            <svg x-show="s.saveState === 'saving'" aria-hidden="true" style="display: none" class="size-3 animate-spin motion-reduce:animate-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>            <span x-show="s.saveState === 'dirty'" aria-hidden="true" class="size-2 rounded-full bg-nq-accent" style="display: none"></span>
            <svg x-show="s.saveState === 'saved'" aria-hidden="true" style="display: none" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 6 9 17l-5-5"/>
</svg>            <svg x-show="s.saveState === 'error'" aria-hidden="true" style="display: none" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <circle cx="12" cy="12" r="10"/>
    <line x1="12" x2="12" y1="8" y2="12"/>
    <line x1="12" x2="12.01" y1="16" y2="16"/>
</svg>            <svg x-show="s.saveState === 'offline'" aria-hidden="true" style="display: none" class="size-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M10.94 5.274A7 7 0 0 1 15.71 10h1.79a4.5 4.5 0 0 1 4.222 6.057"/>
  <path d="M18.796 18.81A4.5 4.5 0 0 1 17.5 19H9A7 7 0 0 1 5.79 5.78"/>
  <path d="m2 2 20 20"/>
</svg>            <span x-text="stateText()"></span>
                            <button type="button" x-show="s.saveState === 'error'" x-on:click="retry()" style="display: none"
                    class="rounded-[2px] text-foreground underline underline-offset-2 outline-none focus-visible:outline-2 focus-visible:outline-nq-focus">Retry</button>
                    </span>
    </div>
</div>
    <aside data-slot="editor-backlinks" aria-label="Links and related documents" class="flex flex-col gap-5 p-3">
            <section aria-labelledby="nq-eb-000001-backlinks" class="flex flex-col gap-2">
            <h3 id="nq-eb-000001-backlinks" class="eyebrow flex items-center justify-between">
                Backlinks
                <span class="tabular-nums">1</span>
            </h3>
                            <ul class="flex flex-col gap-1">
                                                                    <li>
                            <a href="#n1" class="flex flex-col gap-0.5 rounded-control p-2 text-start outline-none transition-colors duration-150 ease-nq hover:bg-nq-hover focus-visible:outline-2 focus-visible:outline-nq-focus">
                                <span dir="auto" class="flex items-center gap-1.5 text-label text-foreground">
                                    <svg aria-hidden="true" class="size-3.5 shrink-0 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M9 17H7A5 5 0 0 1 7 7h2"/>
  <path d="M15 7h2a5 5 0 1 1 0 10h-2"/>
  <line x1="8" x2="16" y1="12" y2="12"/>
</svg>                                    <span class="truncate">Packing list</span>
                                </span>
                                                                    <span dir="auto" class="line-clamp-2 text-caption text-muted-foreground">See the <mark class="rounded-[2px] bg-nq-selected px-0.5 text-foreground">Trip plan</mark> for dates.</span>
                                                                                                    <span dir="auto" class="truncate text-caption text-muted-foreground/80">Travel</span>
                                                            </a>
                        </li>
                                    </ul>
                    </section>
            <section aria-labelledby="nq-eb-000001-related" class="flex flex-col gap-2">
            <h3 id="nq-eb-000001-related" class="eyebrow flex items-center justify-between">
                Related
                <span class="tabular-nums">0</span>
            </h3>
                            <p class="text-body-sm text-muted-foreground">No related documents.</p>
                    </section>
    </aside>
</div>
```
