hyperhive/frontend/packages/shared/src/tabs/tabs.js
iris fbc09f1b3d frontend: move shared/tabs.js+css into one-dir tabs/
Pure structural move, no API or behaviour change: tabs.js/tabs.css
move into shared/src/tabs/, matching the one-dir-per-component
layout the other shared components already use (hive-btn,
hive-dialog, hive-toast, hive-menu, side-panel).

Both files are consumed exclusively through @hive/shared's
package.json exports map (./tabs.js, ./tabs.css), never by a raw
relative path, so updating the two export targets is the only
change needed -- none of the 9 call sites (dashboard tabbar, logs,
core, builds, credentials, stats x2, agent stats) touch anything.
Verified the built dashboard/agent bundles still resolve both
files correctly.
2026-08-01 01:56:41 +02:00

71 lines
2.7 KiB
JavaScript

// Shared hash-routed tab strip — the generic behaviour behind the
// dashboard tabbar, the logs sub-tabs, and the agent stat-window
// selector. Pairs with `@hive/shared/tabs.css` (the .hive-tab* styles).
//
// Convention over configuration (no element-resolver callbacks):
// - Tab controls are the `[data-tab="<id>"]` elements inside the
// `tabbar` you pass in (any element — `<a>` or `<button>`; give
// them class `hive-tab`).
// - Panels are matched by `[data-tab-pane="<id>"]` anywhere in the
// document.
//
// Routing is always via `location.hash` so tabs are deep-linkable and
// honour back/forward. A page may only host one strip (one hash); if a
// second independent strip is ever needed we add a key prefix then.
//
// On show: toggles `.hive-tab--active` + `aria-selected` on each control
// and `hidden` on each panel, then fires `onShow(id)` for per-tab
// side-effects (lazy loads, etc.). See docs/web-ui/css-vars.md.
/**
* @param {Element|null} tabbar container holding the `[data-tab]` controls
* @param {{ defaultId?: string, onShow?: (id: string) => void }} [opts]
* @returns {{ show: (id: string) => void, active: () => string|null }}
*/
export function createTabStrip(tabbar, opts = {}) {
const { defaultId, onShow } = opts;
if (!tabbar) {
return { show() {}, active: () => null };
}
const tabs = Array.from(tabbar.querySelectorAll('[data-tab]'));
const ids = tabs.map((t) => t.getAttribute('data-tab'));
const fallback = defaultId && ids.includes(defaultId) ? defaultId : ids[0];
const paneFor = (id) => document.querySelector(`[data-tab-pane="${id}"]`);
const active = () => {
const hash = location.hash.replace(/^#/, '');
return ids.includes(hash) ? hash : fallback;
};
const show = (id) => {
const target = ids.includes(id) ? id : fallback;
for (const tab of tabs) {
const tid = tab.getAttribute('data-tab');
const on = tid === target;
tab.classList.toggle('hive-tab--active', on);
tab.setAttribute('aria-selected', String(on));
const pane = paneFor(tid);
if (pane) pane.hidden = !on;
}
if (typeof onShow === 'function') onShow(target);
};
// Drive every activation through the hash so deep-links + back/forward
// stay authoritative; the hashchange listener does the actual showing.
for (const tab of tabs) {
tab.addEventListener('click', (e) => {
e.preventDefault();
const id = tab.getAttribute('data-tab');
if (location.hash.replace(/^#/, '') === id) {
show(id); // same hash → no hashchange, re-show directly
} else {
location.hash = id;
}
});
}
window.addEventListener('hashchange', () => show(active()));
show(active()); // initial render from the current hash
return { show, active };
}