RouteTabs
route-tabsRenders controlled workspace tabs with pinning, reordering, overflow, refresh, and contextual close actions.
Usage
Basic usage (fully controlled)
items is not a component. The batch action gives "Which key will actually be turned off this time" in onAction. You can filter according to it. You don't have to calculate pinned/cannot be turned off by yourself.
const [items, setItems] = useState(INITIAL)
const [active, setActive] = useState("orders")
const closeKeys = (keys: string[]) => {
setActive((cur) => nextActiveKey(items, keys, cur) ?? cur)
setItems((prev) => prev.filter((t) => !keys.includes(t.key)))
}
<RouteTabs
items={items}
activeKey={active}
onChange={setActive}
onClose={(k) => closeKeys([k])}
onAction={(action, key, affected) => {
if (action === "refresh") return remountPage(key)
closeKeys(affected)
}}
/>Fixed tab
The tab of pinned can never be closed, is ranked first, and is not affected by "Close Others/All" (workbench, homepage, etc.).
const items = [
{ key: "home", label: "Workbench", pinned: true },
{ key: "orders", label: "Order Management" },
]Drag and drop to sequence
sortable + onReorder. The fixed segment and the ordinary segment are independent, and pinned will not be dragged into the middle.
<RouteTabs
items={items}
activeKey={active}
sortable
onReorder={(keys) => setItems(keys.map((k) => byKey[k]))}
/>When to use
Use RouteTabs for the tab strip of a multi-page admin workspace: opened routes remain side by side and can be activated, closed, reordered, or batch-closed. AdminLayout includes it; use this component when assembling your own shell.
It is not a content tab. Use Tabs to switch content within one page. RouteTabs tracks opened pages while routing determines the content.
Import
import { RouteTabs } from "@hulianui/ui"Props
| Name | Type | Default | Description |
|---|---|---|---|
| items* | RouteTabItem[] | — | Controlled tab list. The component never mutates it. |
| activeKey | string | — | Active tab key. |
| actions | RouteTabsAction[] | All | Built-in context-menu actions to expose. |
| extraMenuItems | { key, label, disabled? }[] | — | Custom entries appended after built-in actions. |
| sortable | boolean | false | Enables drag reordering; pair with onReorder. |
| disableAutoScroll | boolean | false | Disables scrolling the active tab into view. |
| className | string | — | Root class name. |
RouteTabItem is { key, label, icon?, closable?, pinned? }. Pinned tabs are never closable. Other tabs remain closable only while more than one closable tab exists.
RouteTabsAction is "close" | "closeOthers" | "closeLeft" | "closeRight" | "closeAll" | "refresh".
Events
| Event | Type | Description |
|---|---|---|
| onChange | (key: string) => void | Requests a new active tab. |
| onClose | (key: string) => void | Requests closing one tab from its button or menu. |
| onAction | (action, tabKey, affectedKeys) => void | Reports a batch action and the actual affected keys after pinned and non-closable exclusions. |
| onExtraAction | (menuKey, tabKey) => void | Reports a custom menu action. |
| onReorder | (keys: string[]) => void | Reports the complete reordered key list, with pinned tabs first. |
Pure helpers let the controlled consumer apply exactly the component's rules:
import { affectedKeys, nextActiveKey, isClosable, orderTabs, reorderTabs } from "@hulianui/ui"
affectedKeys("closeAll", "b", items)
nextActiveKey(items, closing, active)Example
const [items, setItems] = useState(INITIAL)
const [active, setActive] = useState("orders")
const closeKeys = (keys: string[]) => {
setActive((cur) => nextActiveKey(items, keys, cur) ?? cur)
setItems((prev) => prev.filter((t) => !keys.includes(t.key)))
}
<RouteTabs
items={items}
activeKey={active}
onChange={setActive}
onClose={(k) => closeKeys([k])}
onAction={(action, key, affected) => {
if (action === "refresh") return remountPage(key)
closeKeys(affected)
}}
/>Usage notes
- RouteTabs is fully controlled. Without
onAction, batch close menu actions cannot changeitems. closeAllincludes the current tab among all closable tabs; pinned tabs remain.- Refresh never changes
items; handle the intent with a remount key, cache invalidation, or new request.affectedKeysis empty for refresh. - Left and right use display order after pinned tabs are moved to the front.
- Pinned and regular segments cannot be dragged across each other.
- Overflow controls depend on
ResizeObserver; their absence in jsdom is expected.
Related
AdminLayout · Tabs · ContextMenu · NavMenu · Breadcrumb · Layout
Playground
<RouteTabs
items={items}
activeKey={active}
onChange={setActive}
onClose={(k) => closeKeys([k])}
onAction={(a, k, affected) => closeKeys(affected)}
/>