blob: 0ec1d4f6895111dbf1ff648ae2801efd9f5067ad [file] [log] [blame] [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 m from 'mithril';
import {classNames} from '../base/classnames';
import {Gate} from '../base/mithril_utils';
import {Button} from './button';
import {Icon} from './icon';
import {Icons} from '../base/semantic_icons';
import {maybeUndefined} from '../base/utils';
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;
// Whether to show a close button on the tab.
readonly closeButton?: boolean;
// Icon to display on the left side of the tab title.
readonly leftIcon?: string | 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.
onTabChange?(key: string): void;
// Called when a tab's close button is clicked.
onTabClose?(key: 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).
onTabReorder?(draggedKey: string, beforeKey: string | undefined): void;
// Additional class name for the container.
readonly className?: string;
}
interface TabHandleAttrs {
readonly active?: boolean;
readonly hasCloseButton?: boolean;
readonly onClose?: () => void;
readonly onclick?: () => void;
readonly leftIcon?: string | m.Children;
readonly tabKey?: string;
readonly reorderable?: boolean;
readonly onDragStart?: (key: string) => void;
readonly onDragEnd?: () => void;
readonly onDragOver?: (key: string, position: 'before' | 'after') => void;
readonly onDragLeave?: () => void;
readonly onDrop?: (key: string) => void;
}
class TabHandle implements m.ClassComponent<TabHandleAttrs> {
view({attrs, children}: m.CVnode<TabHandleAttrs>): m.Children {
const {
active,
hasCloseButton,
onClose,
onclick,
leftIcon,
tabKey,
reorderable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
} = attrs;
const renderLeftIcon = () => {
if (leftIcon === undefined) {
return undefined;
}
const style = {alignSelf: 'center'};
if (typeof leftIcon === 'string') {
return m(Icon, {icon: leftIcon, className: 'pf-tabs__tab-icon', style});
}
return m('.pf-tabs__tab-icon', {style}, leftIcon);
};
return m(
'.pf-tabs__tab',
{
className: classNames(active && 'pf-tabs__tab--active'),
onclick,
onauxclick: () => onClose?.(),
draggable: reorderable,
ondragstart: reorderable
? (e: DragEvent) => {
if (tabKey) {
e.dataTransfer?.setData('text/plain', tabKey);
onDragStart?.(tabKey);
}
}
: undefined,
ondragend: reorderable ? () => onDragEnd?.() : undefined,
ondragover: reorderable
? (e: DragEvent) => {
e.preventDefault();
if (tabKey) {
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const position = e.clientX < midpoint ? 'before' : 'after';
onDragOver?.(tabKey, position);
}
}
: undefined,
ondragleave: reorderable
? (e: DragEvent) => {
const target = e.currentTarget as HTMLElement;
const related = e.relatedTarget as HTMLElement | null;
if (related && !target.contains(related)) {
onDragLeave?.();
}
}
: undefined,
ondrop: reorderable
? (e: DragEvent) => {
e.preventDefault();
if (tabKey) {
onDrop?.(tabKey);
}
}
: undefined,
},
renderLeftIcon(),
m('.pf-tabs__tab-title', children),
hasCloseButton &&
m(Button, {
compact: true,
icon: Icons.Close,
onclick: (e: Event) => {
e.stopPropagation();
onClose?.();
},
}),
);
}
}
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';
view({attrs}: m.CVnode<TabsAttrs>): m.Children {
const {
tabs,
activeTabKey,
onTabChange,
onTabClose,
reorderable,
onTabReorder,
className,
} = attrs;
// Get active tab key (controlled or uncontrolled)
const activeKey = activeTabKey ?? this.internalActiveTab ?? tabs[0]?.key;
return m(
'.pf-tabs',
{className},
m(
'.pf-tabs__tabs',
tabs.map((tab, index) => {
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(
'.pf-tabs__tab-wrapper',
{
key: tab.key,
className: classNames(
showDropBefore && 'pf-tabs__tab-wrapper--drop-before',
(showDropAfter || showDropAfterFromNext) &&
'pf-tabs__tab-wrapper--drop-after',
this.draggedKey === tab.key && 'pf-tabs__tab-wrapper--dragging',
),
},
m(
TabHandle,
{
active: tab.key === activeKey,
hasCloseButton: tab.closeButton,
leftIcon: tab.leftIcon,
tabKey: tab.key,
reorderable,
onclick: () => {
this.internalActiveTab = tab.key;
onTabChange?.(tab.key);
},
onClose: () => onTabClose?.(tab.key),
onDragStart: (key) => {
this.draggedKey = key;
},
onDragEnd: () => {
this.draggedKey = undefined;
this.dropTargetKey = undefined;
this.dropPosition = undefined;
},
onDragOver: (key, position) => {
this.dropTargetKey = key;
this.dropPosition = position;
},
onDragLeave: () => {
this.dropTargetKey = undefined;
this.dropPosition = undefined;
},
onDrop: (targetKey) => {
if (
this.draggedKey &&
this.draggedKey !== targetKey &&
onTabReorder
) {
// Find the key of the tab to insert before
const targetIndex = tabs.findIndex(
(t) => t.key === targetKey,
);
let beforeKey: string | undefined;
if (this.dropPosition === 'before') {
beforeKey = targetKey;
} else {
// 'after' - insert before the next tab
beforeKey = tabs[targetIndex + 1]?.key;
}
onTabReorder(this.draggedKey, beforeKey);
}
this.draggedKey = undefined;
this.dropTargetKey = undefined;
this.dropPosition = undefined;
},
},
tab.title,
),
);
}),
),
m(
'.pf-tabs__content',
tabs.map((tab) =>
m(Gate, {key: tab.key, open: tab.key === activeKey}, tab.content),
),
),
);
}
}