blob: 15d04c0ef292a8fbaa38b39417977322ff7c3903 [file] [edit]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import './tabs.scss';
import m from 'mithril';
import {classNames} from '../base/classnames';
import {Gate, isEmptyVnodes} from '../base/mithril_utils';
import {Button} from './button';
import {Icons} from '../base/semantic_icons';
import {maybeUndefined} from '../base/utils';
import {TabStrip} from './tab_strip';
export interface TabsTab {
// Unique identifier for the tab.
readonly key: string;
// Content to display in the tab handle.
readonly title: m.Children;
// Content to display when this tab is active.
readonly content: m.Children;
// When set, the content is not rendered until the tab is first activated.
// Useful for expensive views that should not be built eagerly. Once
// activated, the tab behaves like a regular tab: the content stays mounted
// (and keeps its state) when the tab is deactivated.
readonly lazy?: boolean;
// Whether to show a close button on the tab.
readonly closeButton?: boolean;
// Called when this tab's close button is clicked (before
// TabsAttrs.onTabClose).
readonly onClose?: () => void;
// Icon to display on the left side of the tab title.
readonly leftIcon?: string | m.Children;
// Optional menu items to show in a dropdown menu on the tab.
// When provided, a menu button appears on hover.
readonly menuItems?: m.Children;
}
export interface TabsAttrs {
// The tabs to display.
readonly tabs: TabsTab[];
// The currently active tab key (controlled mode).
// If not provided, the component manages its own state (uncontrolled mode).
readonly activeTabKey?: string;
// Called when a tab is clicked.
readonly onTabChange?: (key: string) => void;
// Called when a tab's close button is clicked.
readonly onTabClose?: (key: string) => void;
// Called when a tab's title is renamed via inline editing. When set, tabs
// with a string title become renamable on double-click (tabs with non-string
// titles are not affected). If the input is cleared (empty after trim) or
// Escape is pressed, the rename is cancelled and this callback is not fired.
readonly onTabRename?: (key: string, newTitle: string) => void;
// Whether tabs can be reordered via drag and drop.
readonly reorderable?: boolean;
// Called when tabs are reordered. Receives the key of the dragged tab and
// the key of the tab it was dropped before (or undefined if dropped at end).
readonly onTabReorder?: (
draggedKey: string,
beforeKey: string | undefined,
) => void;
// Called when the "new tab" button is clicked. When set, a "+" button is
// shown at the end of the tab bar.
readonly onNewTab?: () => void;
// Custom content to render in place of the default "+" button. When set,
// onNewTab is ignored and this content is rendered instead.
readonly newTabContent?: m.Children;
// Content to render on the right side of the tab bar.
readonly rightContent?: m.Children;
// Visual style of the tab bar. 'card' (the default) renders classic
// boxed tab handles on a secondary-background bar; 'underline' renders
// flat text tabs with a primary underline on the active tab.
readonly variant?: 'card' | 'underline';
// When true, hides the tab bar and renders only the active tab's content.
readonly hideTabBar?: boolean;
// Additional class name for the container.
readonly className?: string;
}
// A TabStrip that also manages the active tab and renders its content. The
// tab handles themselves (icons, close buttons, menus, renaming) are provided
// by TabStrip.Tab; this component adds active-tab tracking, lazy content, and
// drag-and-drop reordering on top.
export class Tabs implements m.ClassComponent<TabsAttrs> {
// Current active tab key (for uncontrolled mode).
private internalActiveTab?: string;
// Drag state for reordering.
private draggedKey?: string;
private dropTargetKey?: string;
private dropPosition?: 'before' | 'after';
// Keys of the tabs that have been active at least once. Content of lazy
// tabs is only rendered after their key lands here.
private activatedKeys = new Set<string>();
view({attrs}: m.CVnode<TabsAttrs>): m.Children {
const {
tabs,
activeTabKey,
onNewTab,
newTabContent,
rightContent,
variant = 'card',
hideTabBar,
className,
} = attrs;
// Get active tab key (controlled or uncontrolled)
const activeKey = activeTabKey ?? this.internalActiveTab ?? tabs[0]?.key;
// The active tab counts as activated, so a lazy tab renders its content
// on the same render in which it becomes active.
if (activeKey !== undefined) {
this.activatedKeys.add(activeKey);
}
return m('.pf-tabs', {className}, [
!hideTabBar &&
m(
TabStrip,
{
variant,
rightContent: isEmptyVnodes(rightContent)
? undefined
: rightContent,
},
tabs.map((tab, index) =>
this.renderTab(attrs, tab, index, tab.key === activeKey),
),
newTabContent ??
(onNewTab &&
m(Button, {
icon: Icons.Add,
className: 'pf-tabs__new-tab-btn',
onclick: () => onNewTab(),
})),
),
m(
'.pf-tabs__content',
tabs.map((tab) =>
m(
Gate,
{key: tab.key, open: tab.key === activeKey},
// Lazy tabs render no content until they are first activated.
tab.lazy && !this.activatedKeys.has(tab.key)
? undefined
: tab.content,
),
),
),
]);
}
private renderTab(
attrs: TabsAttrs,
tab: TabsTab,
index: number,
active: boolean,
): m.Children {
const {tabs, onTabChange, onTabClose, onTabRename, reorderable} = attrs;
const isDragTarget = this.dropTargetKey === tab.key;
const showDropBefore =
isDragTarget &&
this.dropPosition === 'before' &&
this.draggedKey !== tab.key;
const showDropAfter =
isDragTarget &&
this.dropPosition === 'after' &&
this.draggedKey !== tab.key;
// Also show drop-after on the previous tab if we're dropping before
const prevTab = maybeUndefined(tabs[index - 1]);
const showDropAfterFromNext =
prevTab &&
this.dropTargetKey === tabs[index]?.key &&
this.dropPosition === 'before' &&
this.draggedKey !== prevTab.key &&
this.draggedKey !== tab.key;
return m(
TabStrip.Tab,
{
key: tab.key,
className: classNames(
showDropBefore && 'pf-tabs__tab--drop-before',
(showDropAfter || showDropAfterFromNext) &&
'pf-tabs__tab--drop-after',
this.draggedKey === tab.key && 'pf-tabs__tab--dragging',
),
active,
icon: tab.leftIcon,
menuItems: tab.menuItems,
onClose: tab.closeButton
? () => {
tab.onClose?.();
onTabClose?.(tab.key);
}
: undefined,
onRename:
onTabRename && typeof tab.title === 'string'
? (newTitle: string) => onTabRename(tab.key, newTitle)
: undefined,
onpointerdown: () => {
this.internalActiveTab = tab.key;
onTabChange?.(tab.key);
},
...(reorderable && this.dragAttrs(attrs, tab.key)),
},
tab.title,
);
}
private dragAttrs(attrs: TabsAttrs, tabKey: string) {
const {tabs, onTabReorder} = attrs;
const resetDrag = () => {
this.draggedKey = undefined;
this.dropTargetKey = undefined;
this.dropPosition = undefined;
};
return {
draggable: true,
ondragstart: (e: DragEvent) => {
e.dataTransfer?.setData('text/plain', tabKey);
this.draggedKey = tabKey;
},
ondragend: resetDrag,
ondragover: (e: DragEvent) => {
e.preventDefault();
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
this.dropTargetKey = tabKey;
this.dropPosition = e.clientX < midpoint ? 'before' : 'after';
},
ondragleave: (e: DragEvent) => {
const target = e.currentTarget as HTMLElement;
const related = e.relatedTarget as HTMLElement | null;
if (related && !target.contains(related)) {
this.dropTargetKey = undefined;
this.dropPosition = undefined;
}
},
ondrop: (e: DragEvent) => {
e.preventDefault();
if (this.draggedKey && this.draggedKey !== tabKey && onTabReorder) {
// Find the key of the tab to insert before
const targetIndex = tabs.findIndex((t) => t.key === tabKey);
let beforeKey: string | undefined;
if (this.dropPosition === 'before') {
beforeKey = tabKey;
} else {
// 'after' - insert before the next tab
beforeKey = tabs[targetIndex + 1]?.key;
}
onTabReorder(this.draggedKey, beforeKey);
}
resetDrag();
},
};
}
}