Documentation
Guides

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.

Tab overflow: select or scroll
Gallery

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
});