# VelvetKit

## Session tabs (`UI::SessionTabs`)

Browser-style tabs the user opens, renames, duplicates, reorders and closes.

Add it: `bin/rails generate velvetkit:add session_tabs`

Also adds: `icon`, `spinner`, `button`, `button_to`, `dialog`, `menu`

Stimulus: `ui--session-tabs`, `ui--dialog`

### Arguments

```ruby
UI::SessionTabs.new(label: nil, size: :default, min_tabs: 1, return_focus: nil, rename_param: "name", class: nil, **html_attrs)
```

Slots: `with_tab`, `with_add`, `with_meta`, `with_menu_item`

### Notes

UI::SessionTabs — tabs the *user* manages: open, rename, duplicate, reorder,
close — a browser-style tab bar over records the app keeps. (For a fixed set
of panels the developer defines, use UI::Tabs.)

The component never stores tabs. Each tab is either:
  - a link (`href:`) — the server decides which tab is active, or
  - a button (`value:` only) — the page reacts to `ui--session-tabs:select`.

Each action (rename, duplicate, move, close) is opt-in per tab and takes
either a URL — submitted as a Turbo form (PATCH/POST/PATCH/DELETE) — or
`true`, in which case only the event fires and the app does the work.
Every action dispatches a cancelable `ui--session-tabs:<action>` event
first; `preventDefault()` stops it.

Compound:
  UI::SessionTabs        — the bar, the confirm-close dialog
  UI::SessionTabs::Tab   — one tab + its options menu
  UI::SessionTabs::Add   — the "+" button

### Example: Saved views

```erb
<%# A "Default" view the user can't close or rename, plus views they made. %>
<div class="max-w-2xl">
  <%= render(UI::SessionTabs.new(label: "Saved views")) do |tabs| %>
    <% tabs.with_tab(value: "default", active: true, duplicate: true) do |tab| %>
      <% tab.with_meta { "128" } %>Default
    <% end %>
    <% tabs.with_tab(value: "unpaid", rename: true, duplicate: true, move: true, close: true) do |tab| %>
      <% tab.with_meta { "12" } %>Unpaid
    <% end %>
    <% tabs.with_tab(value: "this-week", rename: true, duplicate: true, move: true, close: true) do |tab| %>
      <% tab.with_meta { "41" } %>This week
    <% end %>
    <% tabs.with_add %>
  <% end %>
  <div class="rounded-b-md border border-t-0 p-4 text-sm text-muted-foreground">The table for the selected view goes here.</div>
</div>
```

### Example: Touch, with close confirmation

```erb
<%# Touch size: 44px tabs; long-press a tab to open its menu. Tabs with
    unsaved work ask before closing; empty ones close straight away. %>
<div class="max-w-2xl space-y-3">
  <%= render(UI::SessionTabs.new(size: :touch, return_focus: "#session-tabs-demo-input")) do |tabs| %>
    <% tabs.with_tab(value: "1", active: true, rename: true, move: true, close: true,
                     close_confirm: "This tab has 3 unsaved items. They will be lost.") do |tab| %>
      <% tab.with_meta { "3 items" } %>Tab 1
    <% end %>
    <% tabs.with_tab(value: "2", rename: true, move: true, close: true,
                     close_confirm: "This tab has 1 unsaved item. It will be lost.") do |tab| %>
      <% tab.with_meta { "1 item" } %>Tab 2
    <% end %>
    <% tabs.with_tab(value: "3", rename: true, move: true, close: true) do |tab| %>
      <% tab.with_meta { "Empty" } %>Tab 3
    <% end %>
    <% tabs.with_add %>
  <% end %>
  <input id="session-tabs-demo-input" class="h-11 w-full rounded-md border border-input bg-background px-3 text-sm"
         placeholder="Focus comes back here after each tab action (return_focus:)">
</div>
```

### Example: Handling the events

```erb
<%# Every action dispatches a cancelable ui--session-tabs:<action> event.
    This page logs them, refuses to close "Pinned", and handles "add" itself
    by cloning a <template>. In a real app, pass URLs instead and let the
    server re-render. %>
<div class="max-w-2xl space-y-3" id="session-tabs-events-demo">
  <%= render(UI::SessionTabs.new) do |tabs| %>
    <% tabs.with_tab(value: "pinned", active: true, rename: true, move: true, close: true) { "Pinned" } %>
    <% tabs.with_tab(value: "notes", rename: true, duplicate: true, move: true, close: true) { "Notes" } %>
    <% tabs.with_add %>
  <% end %>
  <pre class="h-40 overflow-auto rounded-md bg-muted p-3 text-xs" data-log></pre>
</div>
<script>
  (() => {
    const root = document.getElementById("session-tabs-events-demo");
    const log = root.querySelector("[data-log]");
    let n = 0;
    const write = (line) => { log.textContent = line + "\n" + log.textContent; };

    for (const action of ["select", "add", "rename", "duplicate", "move", "close"]) {
      root.addEventListener(`ui--session-tabs:${action}`, (event) => {
        write(`${action} ${JSON.stringify(event.detail)}`);

        if (action === "close" && event.detail.value === "pinned") {
          event.preventDefault();
          write("  ↳ cancelled: Pinned can't be closed");
        }

        if (action === "add" || action === "duplicate") {
          // The app supplies new markup; here, copy an existing tab.
          const list = root.querySelector('[role="tablist"]');
          const source = list.querySelector('[data-value="notes"]') || list.lastElementChild;
          const copy = source.cloneNode(true);
          n += 1;
          const value = `new-${n}`;
          copy.dataset.value = value;
          copy.dataset.state = "inactive";
          copy.querySelectorAll("[id]").forEach((el) => { el.id = `${el.id}-${value}`; });
          copy.querySelector('[data-ui--session-tabs-target="label"]').textContent =
            action === "add" ? `New tab ${n}` : `Copy ${n}`;
          list.appendChild(copy);
        }
      });
    }
  })();
</script>
```
