Tab overflow
When a strip has more tabs than fit, the ones that do not fit go to a menu of yours and the selected tab stays in view. Or wrap and scroll.
When a strip has more tabs than it can show, Dockable hides the ones that do not fit, one by one
as the strip narrows, and always keeps the selected tab in view (the VS Code behavior). You list
the hidden tabs in a menu of your own, opened by Dockable.TabOverflowTrigger. A strip can also
keep every tab and scroll instead: the example shows both, one per tabset.
1. Let the strip overflow
Nothing to switch on: Dockable.TabList measures itself and its tabs. Give it a size the tabs can
overflow. In a header row, let it shrink:
<div className="flex">
<Dockable.TabList<Types> className="flex min-w-0 flex-1 overflow-hidden gap-1 ps-1">
{(tab) => <Dockable.Tab node={tab}>{tab.label}</Dockable.Tab>}
</Dockable.TabList>
{/* the trigger, step 2 */}
</div>The engine compares the tabs' sizes (and the list's gap) with the list's inner size (its size
minus padding and border). The tabs fit from the start of the strip; when the selected tab is past
the end, tabs are taken off the end until it fits, and it shows in their place. Down to a single
visible tab: the selected one.
A tab that does not fit gets display: none and data-overflow-hidden, and the tab list gets
data-overflowing. The arrow keys still reach a hidden tab: moving onto it selects it, which
brings it into the strip.
2. Add the trigger and your menu
Dockable.TabOverflowTrigger renders only while tabs are hidden, next to the tab list. The engine
reserves the space it takes, so tabs and trigger never overlap. It is a button with no text and
no menu: make it the trigger of yours, and list the hidden tabs from useTabOverflow. It takes
the tabset (or border) node and returns its tabs typed by the same registry, so data narrows on
component in the menu too:
import {
Dockable,
type TabOf,
type TabsetNode,
useDockable,
useTabOverflow,
} from "@fragiola/dockable-react";
// an icon-only tab has no name: `altName` names it in the menu
type Types = { tabs: { file: { altName?: string } } };
const label = (tab: TabOf<Types>) => tab.label || tab.data.altName;
function OverflowMenu({ tabset }: { tabset: TabsetNode<Types> }) {
const { model } = useDockable<Types>();
const { hiddenTabs } = useTabOverflow(tabset); // TabOf<Types>[]
return (
<Menu.Root>
<Dockable.TabOverflowTrigger aria-label="More tabs" render={<Menu.Trigger />}>
{`+${hiddenTabs.length}`}
</Dockable.TabOverflowTrigger>
<Menu.Content>
{hiddenTabs.map((tab) => (
<Menu.Item key={tab.id} onClick={() => model.run("tab.select", { tabId: tab.id })}>
{label(tab)}
</Menu.Item>
))}
</Menu.Content>
</Menu.Root>
);
}Selecting a hidden tab (tab.select, through the model's middleware) brings it into the strip;
another tab goes to the menu in its place. The result also has overflowing. What a tab is called in the menu is your data: altName above is a field of
the registry, not of the package. The trigger has no name of its own: give it an aria-label.
The overflow-select example uses Fragiola UI's Select, with
Dockable.TabOverflowTrigger rendered as its trigger. Its second tabset scrolls instead (see
Wrap or scroll instead).
It works everywhere tabs do
- Borders: a side border's vertical tab list hides along its height.
- Popout windows: each window measures its own strips.
- Drag and drop: a drop into a strip lands between the visible tabs; hidden tabs take no drop position.
Wrap or scroll instead
A strip that wraps (flex-wrap: wrap, several rows of tabs) or scrolls (overflow-x: auto)
keeps every tab: turn tab overflow off with overflow={false} on the tab list. A per-tabset
switch is your data: the overflow-select example's registry has
tabset: { overflow: "select" | "scroll" }, and its tabset reads node.data?.overflow.
For a scrolling strip, scroll the selected tab into view when the selection changes.
useModelState re-renders the tab only when its answer changes:
import { useModelState } from "@fragiola/dockable-react";
function ScrollingTab({ tab }: { tab: TabOf<Types> }) {
const ref = useRef<HTMLElement>(null);
const selected = useModelState<Types, boolean>((_, model) => model.is("tab-selected", { tabId: tab.id }));
useEffect(() => {
if (selected) ref.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [selected]);
return (
<Dockable.Tab node={tab} ref={ref}>
{tab.label}
</Dockable.Tab>
);
}Tabs that shrink (flex-shrink: 1 with a minimum width, a truncated label) combine with either:
they shrink first, and overflow once they reach their minimum.
Your own measuring
The decision itself is a pure function of the core, for a custom strip:
import { computeTabOverflow } from "@fragiola/dockable-react";
const { visible, hidden } = computeTabOverflow({
available: 420, // the strip's inner size
sizes: [96, 80, 120, 88], // each tab's natural size, in model order
gap: 4,
selectedIndex: 3,
reserve: 32, // the trigger's space, used only when something is hidden
});