Session tabs
UI::SessionTabsBrowser-style tabs the user opens, renames, duplicates, reorders and closes.
# 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>
```
bin/rails generate velvetkit:add session_tabs
- Stimulus
- ui--session-tabs ui--dialog
- Installs
- Nothing
Examples
Saved views
The table for the selected view goes here.
saved_views.html.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>
Touch, with close confirmation
touch_with_confirmation.html.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>
Handling the events
handling_events.html.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>
API
Arguments
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