TabGroup
OrganismAccessible tab navigation following the role="tablist" / role="tab" / role="tabpanel" ARIA pattern. Arrow-key navigation; tabIndex=-1 on inactive tabs. Tabs take order and a per-tab lazy; isolate renders function contents inside try/catch so one failing contributed tab shows a placeholder instead of taking the page down.
Overview panel content.
Details panel content.
History panel content.
<%- include('modules/ui/TabGroup', {
tabs: [
{ id: 'overview', label: 'Overview', content: '<p>Overview panel content.</p>' },
{ id: 'details', label: 'Details', content: '<p>Details panel content.</p>' },
{ id: 'history', label: 'History', content: '<p>History panel content.</p>' },
]
}) %>
Home content.
Users content.
Settings content.
<%- include('modules/ui/TabGroup', {
tabs: [
{ id: 'home', label: 'Home', icon: 'fa-house', content: '<p>Home content.</p>' },
{ id: 'users', label: 'Users', icon: 'fa-users', content: '<p>Users content.</p>' },
{ id: 'settings', label: 'Settings', icon: 'fa-gear', content: '<p>Settings content.</p>' },
]
}) %>
Active tab content.
Another tab content.
<%- include('modules/ui/TabGroup', {
tabs: [
{ id: 'active', label: 'Active', content: '<p>Active tab content.</p>' },
{ id: 'disabled', label: 'Disabled', content: '', disabled: true },
{ id: 'another', label: 'Another', content: '<p>Another tab content.</p>' },
]
}) %>
Host tab: contact details.
<%# A function content renders inside try/catch when isolated: a throwing tab shows a
"failed" placeholder with Retry (or renderError) and is reported to onTabError. %>
<%- include('modules/ui/TabGroup', {
isolate: true,
onTabError: function (tabId, error) { report(tabId, error); },
tabs: [
{ id: 'overview', label: 'Overview', content: overviewHtml }, // host: order 0
{ id: 'tasks', label: 'Tasks', order: 10, lazy: true, content: renderTasks },
{ id: 'calls', label: 'Calls', order: 20, lazy: true, content: renderCallLog },
]
}) %>
<%
// Tabs take `order` (like CSS order: missing is 0, ties keep array order), a per-tab
// `lazy` overriding the group's, and `isolate` (group or per tab): a tab whose `content`
// is a function is rendered inside try/catch, and one that throws shows `renderError`
// ({ tab, error } => HTML) or a "failed" UnavailableContribution with Retry (a reload),
// and is reported to `onTabError(tabId, error)`. Parity with kui-react TabGroup.
var _tabs = (locals.tabs || [])
.map(function (t, i) { return { t: t, i: i, o: (typeof t.order === 'number' && isFinite(t.order)) ? t.order : 0 }; })
.sort(function (a, b) { return a.o - b.o || a.i - b.i; })
.map(function (e) { return e.t; });
var _hasTab = function (id) { return _tabs.some(function (t) { return t.id === id; }); };
var _activeTab = locals.activeTab && _hasTab(locals.activeTab) ? locals.activeTab : (_tabs.length > 0 ? _tabs[0].id : '');
var _label = locals.label || 'Tabs';
var _lazy = !!locals.lazy;
var _isolate = !!locals.isolate;
var _retryLabel = locals.retryLabel || 'Retry';
var _id = locals.id || 'tabgroup-' + Math.random().toString(36).substr(2, 9);
var _inactiveCls = 'border-transparent text-text-secondary hover:text-text-primary hover:border-border';
var _activeCls = 'border-primary text-primary';
var _tabLazy = function (tab) { return tab.lazy !== undefined ? !!tab.lazy : _lazy; };
// Resolve every panel's HTML once (the panels and the lazy payload both use it).
var _contents = {};
_tabs.forEach(function (tab) {
var isolate = tab.isolate !== undefined ? !!tab.isolate : _isolate;
if (typeof tab.content !== 'function') { _contents[tab.id] = tab.content || ''; return; }
if (!isolate) { _contents[tab.id] = tab.content() || ''; return; }
try {
_contents[tab.id] = tab.content() || '';
} catch (err) {
var error = err instanceof Error ? err : new Error(String(err));
if (typeof locals.onTabError === 'function') {
try { locals.onTabError(tab.id, error); } catch (_reportErr) { /* reporting never breaks the host */ }
}
_contents[tab.id] = typeof locals.renderError === 'function'
? (locals.renderError({ tab: tab, error: error }) || '')
: include('./UnavailableContribution', { reason: 'failed', size: 'tab', action: { label: _retryLabel, onClick: 'location.reload()' } });
}
});
%>
<div id="<%= _id %>" class="w-full min-w-0" data-tg-root data-tg-lazy="<%= _lazy ? '1' : '0' %>">
<%# A row wider than its box (many tabs, a phone) scrolls sideways inside the tab list instead of the page. %>
<div role="tablist" aria-label="<%= _label %>" class="flex overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border pb-3">
<% _tabs.forEach(function (tab, ti) { %>
<button
type="button"
role="tab"
id="<%= _id %>-tab-<%= tab.id %>"
aria-selected="<%= tab.id === _activeTab ? 'true' : 'false' %>"
aria-controls="<%= _id %>-panel-<%= tab.id %>"
<% if (tab.disabled) { %>aria-disabled="true"<% } %>
tabindex="<%= tab.id === _activeTab ? '0' : '-1' %>"
data-tg-tab="<%= tab.id %>"
data-tg-index="<%= ti %>"
<% if (tab.disabled) { %>data-tg-disabled="1"<% } %>
class="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus <%= tab.disabled ? 'opacity-40 cursor-not-allowed pointer-events-none border-transparent text-text-secondary' : (tab.id === _activeTab ? _activeCls : _inactiveCls) %>"
>
<% if (tab.icon) { %><span aria-hidden="true" class="shrink-0"><%- tab.icon %></span><% } %>
<%= tab.label %>
<% if (tab.badge) { %><span class="shrink-0"><%- tab.badge %></span><% } %>
</button>
<% }); %>
</div>
<% _tabs.forEach(function (tab) {
var _isActive = tab.id === _activeTab;
var _panelLazy = _tabLazy(tab);
var _shouldRender = !_panelLazy || _isActive;
%>
<div
id="<%= _id %>-panel-<%= tab.id %>"
role="tabpanel"
aria-labelledby="<%= _id %>-tab-<%= tab.id %>"
tabindex="0"
data-tg-panel="<%= tab.id %>"
<% if (_panelLazy) { %>data-tg-lazy-panel="1"<% } %>
<%= _isActive ? '' : 'hidden' %>
class="py-4 focus-visible:outline-none"
>
<% if (_shouldRender) { %>
<%- _contents[tab.id] %>
<% } %>
</div>
<% }); %>
</div>
<%
/* TAB_CONTENTS goes into the script through _script-json (script-safe JSON). */
var _tabContents = _tabs.filter(_tabLazy).map(function (t) { return { id: t.id, content: _contents[t.id] }; });
%>
<script>
(function () {
var root = document.getElementById(<%- include('./_script-json', { value: String(_id) }) %>);
if (!root) return;
var lazy = root.getAttribute('data-tg-lazy') === '1';
var activated = new Set();
var inactiveCls = ['border-transparent', 'text-text-secondary', 'hover:text-text-primary', 'hover:border-border'];
var activeCls = ['border-primary', 'text-primary'];
var TAB_CONTENTS = <%- include('./_script-json', { value: _tabContents }) %>;
// Pre-mark currently active panel.
root.querySelectorAll('[role="tab"]').forEach(function (t) {
if (t.getAttribute('aria-selected') === 'true') {
activated.add(t.getAttribute('data-tg-tab'));
}
});
var tabBtns = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
function activate(tabId) {
var groupId = root.id;
// Tabs.
tabBtns.forEach(function (t) {
var isActive = t.getAttribute('data-tg-tab') === tabId;
var isDisabled = t.getAttribute('data-tg-disabled') === '1';
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
t.setAttribute('tabindex', isActive ? '0' : '-1');
if (isDisabled) return;
activeCls.forEach(function (c) { t.classList.toggle(c, isActive); });
inactiveCls.forEach(function (c) { t.classList.toggle(c, !isActive); });
});
// Panels.
root.querySelectorAll('[role="tabpanel"]').forEach(function (p) {
var pid = p.getAttribute('data-tg-panel');
var isActive = pid === tabId;
if (isActive) {
if ((lazy || p.hasAttribute('data-tg-lazy-panel')) && !activated.has(pid)) {
var match = TAB_CONTENTS.find(function (c) { return c.id === pid; });
if (match) p.innerHTML = match.content;
}
p.removeAttribute('hidden');
activated.add(pid);
} else {
p.setAttribute('hidden', '');
}
});
}
function focusTab(idx) {
var btn = tabBtns[idx];
if (btn) { btn.focus(); activate(btn.getAttribute('data-tg-tab')); }
}
function nextEnabled(start, dir) {
var n = tabBtns.length;
if (n === 0) return -1;
var i = start;
for (var c = 0; c < n; c++) {
i = (i + dir + n) % n;
if (tabBtns[i].getAttribute('data-tg-disabled') !== '1') return i;
}
return -1;
}
function firstEnabled() {
for (var i = 0; i < tabBtns.length; i++) {
if (tabBtns[i].getAttribute('data-tg-disabled') !== '1') return i;
}
return -1;
}
function lastEnabled() {
for (var i = tabBtns.length - 1; i >= 0; i--) {
if (tabBtns[i].getAttribute('data-tg-disabled') !== '1') return i;
}
return -1;
}
tabBtns.forEach(function (btn, idx) {
btn.addEventListener('click', function () {
if (btn.getAttribute('data-tg-disabled') === '1') return;
activate(btn.getAttribute('data-tg-tab'));
});
btn.addEventListener('keydown', function (e) {
var target = null;
if (e.key === 'ArrowRight') target = nextEnabled(idx, +1);
else if (e.key === 'ArrowLeft') target = nextEnabled(idx, -1);
else if (e.key === 'Home') target = firstEnabled();
else if (e.key === 'End') target = lastEnabled();
if (target !== null && target >= 0) {
e.preventDefault();
focusTab(target);
}
});
});
})();
</script>