Files
openchamber/packages/ui/src/components/ui/sortable-tabs-strip.tsx
T
Roberto Bertó 76eef473aa feat: close sortable tabs with middle-click (#1304)
Aligns tab behavior with browsers and editors: pressing the middle mouse
button on any closable tab in SortableTabsStrip closes it without going
through the visible close icon.

- onAuxClick handler triggers onClose when event.button === 1.
- onMouseDown preventDefault on middle button suppresses the browser's
  autoscroll affordance.
- Both handlers no-op when the tab is not closable, keeping non-editor
  tab strips (skills catalog, settings sections, etc.) untouched.
2026-05-18 18:02:27 +03:00

574 lines
22 KiB
TypeScript

import React from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { Icon } from "@/components/icon/Icon";
export type SortableTabsStripItem = {
id: string;
label: string;
icon?: React.ReactNode;
title?: string;
closable?: boolean;
closeLabel?: string;
};
type SortableTabsStripProps = {
items: SortableTabsStripItem[];
activeId: string | null;
onSelect: (id: string) => void;
onClose?: (id: string) => void;
onReorder?: (activeId: string, overId: string) => void;
layoutMode?: 'scrollable' | 'fit';
variant?: 'default' | 'active-pill' | 'animated';
activePillInsetClassName?: string;
activePillButtonClassName?: string;
inactiveTabsIconOnly?: boolean;
iconOnlyActiveTab?: boolean;
animateActivePill?: boolean;
activePillLowercase?: boolean;
className?: string;
};
const restrictToXAxis: Modifier = ({ transform }) => ({
...transform,
y: 0,
});
const SortableTabWrapper: React.FC<{ id: string; children: React.ReactNode; className?: string }> = ({ id, children, className }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
data-sortable-tab-id={id}
style={{
transform: DndCSS.Transform.toString(transform),
transition,
}}
className={cn('h-full rounded-md', className, isDragging && 'opacity-50')}
{...attributes}
{...listeners}
>
{children}
</div>
);
};
const StaticTabWrapper: React.FC<{ id: string; children: React.ReactNode; className?: string }> = ({ id, children, className }) => (
<div className={cn('h-full', className)} data-sortable-tab-id={id}>{children}</div>
);
export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
items,
activeId,
onSelect,
onClose,
onReorder,
layoutMode = 'scrollable',
variant = 'default',
activePillInsetClassName,
activePillButtonClassName,
inactiveTabsIconOnly = false,
iconOnlyActiveTab = false,
animateActivePill,
activePillLowercase = true,
className,
}) => {
const { t } = useI18n();
const isMobile = useUIStore((state) => state.isMobile);
const { isTablet } = useDeviceInfo();
const alwaysShowCloseControls = isMobile || isTablet;
const scrollRef = React.useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false });
const itemIDs = React.useMemo(() => items.map((item) => item.id), [items]);
const isScrollable = layoutMode === 'scrollable';
const isDefaultVariant = variant === 'default';
const isActivePillVariant = variant === 'active-pill';
const isAnimatedVariant = variant === 'animated';
const usesActivePillIndicator = isActivePillVariant || isAnimatedVariant;
const useUnderlineIndicator = isDefaultVariant;
const usesIndicator = usesActivePillIndicator || useUnderlineIndicator;
const useIntrinsicPillSizing = isActivePillVariant && isScrollable;
const showPillTrackBackground = usesActivePillIndicator;
const shouldAnimateActivePill = animateActivePill ?? isAnimatedVariant;
const reorderEnabled = typeof onReorder === 'function';
const Wrapper = reorderEnabled ? SortableTabWrapper : StaticTabWrapper;
const tabRefs = React.useRef<Map<string, HTMLElement>>(new Map());
const [pillRect, setPillRect] = React.useState<{ left: number; top: number; width: number; height: number } | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const isSamePillRect = React.useCallback((
a: { left: number; top: number; width: number; height: number } | null,
b: { left: number; top: number; width: number; height: number } | null,
) => {
if (!a || !b) {
return a === b;
}
return Math.abs(a.left - b.left) < 0.5
&& Math.abs(a.top - b.top) < 0.5
&& Math.abs(a.width - b.width) < 0.5
&& Math.abs(a.height - b.height) < 0.5;
}, []);
const setTabRef = React.useCallback((id: string, element: HTMLElement | null) => {
if (element) {
tabRefs.current.set(id, element);
return;
}
tabRefs.current.delete(id);
}, []);
const updateActivePillRect = React.useCallback(() => {
if (!usesIndicator || !activeId) {
setPillRect((prev) => (prev === null ? prev : null));
return;
}
const container = scrollRef.current;
const activeTab = tabRefs.current.get(activeId);
if (!container || !activeTab) {
setPillRect((prev) => (prev === null ? prev : null));
return;
}
// Walk offsetParent chain to compute position relative to the scroll container.
// Unlike getBoundingClientRect, offsetLeft/offsetTop are unaffected by CSS
// transforms (e.g. dropdown entry scale animation), preventing pill mis-positioning
// on first render.
let left = 0;
let top = 0;
let el: HTMLElement | null = activeTab;
while (el && el !== container) {
left += el.offsetLeft;
top += el.offsetTop;
el = el.offsetParent as HTMLElement | null;
}
const nextRect = {
left,
top,
width: activeTab.offsetWidth,
height: activeTab.offsetHeight,
};
setPillRect((prev) => (isSamePillRect(prev, nextRect) ? prev : nextRect));
}, [activeId, isSamePillRect, usesIndicator]);
const updateOverflow = React.useCallback(() => {
if (!isScrollable) {
setOverflow({ left: false, right: false });
return;
}
const element = scrollRef.current;
if (!element) {
setOverflow({ left: false, right: false });
return;
}
setOverflow({
left: element.scrollLeft > 2,
right: element.scrollLeft + element.clientWidth < element.scrollWidth - 2,
});
}, [isScrollable]);
React.useEffect(() => {
if (!isScrollable) {
setOverflow({ left: false, right: false });
return;
}
const element = scrollRef.current;
if (!element) {
return;
}
updateOverflow();
element.addEventListener('scroll', updateOverflow, { passive: true });
const observer = new ResizeObserver(updateOverflow);
observer.observe(element);
return () => {
element.removeEventListener('scroll', updateOverflow);
observer.disconnect();
};
}, [isScrollable, items.length, updateOverflow]);
React.useEffect(() => {
if (!usesIndicator) {
setPillRect(null);
return;
}
updateActivePillRect();
const element = scrollRef.current;
if (!element) {
return;
}
const observer = new ResizeObserver(updateActivePillRect);
observer.observe(element);
if (activeId) {
const activeTab = tabRefs.current.get(activeId);
if (activeTab) {
observer.observe(activeTab);
}
}
return () => {
observer.disconnect();
};
}, [activeId, items.length, updateActivePillRect, usesIndicator]);
React.useLayoutEffect(() => {
updateActivePillRect();
});
React.useEffect(() => {
if (!isScrollable || !activeId) {
return;
}
const element = scrollRef.current;
if (!element) {
return;
}
const frame = window.requestAnimationFrame(() => {
const escapedID = typeof window.CSS?.escape === 'function'
? window.CSS.escape(activeId)
: activeId.replace(/"/g, '\\"');
const target = element.querySelector<HTMLElement>(`[data-sortable-tab-id="${escapedID}"]`);
if (!target) {
return;
}
target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
updateOverflow();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [activeId, isScrollable, items.length, updateOverflow]);
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
if (!onReorder) {
return;
}
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
onReorder(String(active.id), String(over.id));
}, [onReorder]);
const list = (
<div className={cn('relative flex h-full min-w-0 flex-1', className)}>
{isScrollable && overflow.left ? (
<div
className={cn(
'pointer-events-none absolute inset-y-0 left-0 z-20 bg-gradient-to-r to-transparent',
usesActivePillIndicator
? 'w-8 from-[var(--surface-background)]'
: 'w-6 from-background'
)}
/>
) : null}
{isScrollable && overflow.right ? (
<div
className={cn(
'pointer-events-none absolute inset-y-0 right-0 z-20 bg-gradient-to-l to-transparent',
usesActivePillIndicator
? 'w-8 from-[var(--surface-background)]'
: 'w-6 from-background'
)}
/>
) : null}
<div
ref={scrollRef}
className={cn(
'relative flex h-full min-w-0 flex-1',
usesActivePillIndicator ? 'items-center overflow-x-hidden overflow-y-hidden' : 'items-stretch',
usesActivePillIndicator && '@container/pill-tabs',
usesActivePillIndicator && 'pill-tabs__track',
usesActivePillIndicator && (activePillInsetClassName ?? 'gap-0.5 py-0.5'),
useUnderlineIndicator && 'items-center overflow-y-hidden',
showPillTrackBackground && 'rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] bg-[color-mix(in_srgb,var(--foreground)_2%,transparent)] p-0.5 gap-0.5',
isScrollable
? 'overflow-x-auto scrollbar-none'
: 'overflow-x-hidden',
)}
style={isScrollable ? { scrollbarWidth: 'none', msOverflowStyle: 'none' } : undefined}
role="tablist"
aria-label={t('sortableTabsStrip.aria.tabs')}
>
{usesActivePillIndicator && pillRect ? (
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-0 rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] bg-[var(--surface-elevated)]',
'border border-border/60'
)}
style={{
transform: `translate3d(${pillRect.left}px, ${pillRect.top}px, 0)`,
width: `${pillRect.width}px`,
height: `${pillRect.height}px`,
transition: shouldAnimateActivePill
? 'transform 300ms cubic-bezier(0.65, 0, 0.35, 1), width 300ms cubic-bezier(0.65, 0, 0.35, 1), height 300ms cubic-bezier(0.65, 0, 0.35, 1)'
: undefined,
}}
/>
) : null}
{useUnderlineIndicator && pillRect ? (
<div
className="pointer-events-none absolute left-0 -bottom-px z-10 h-[3px] rounded-t-[2px] bg-[var(--primary-base)]"
style={{
transform: `translate3d(${pillRect.left}px, 0, 0)`,
width: `${pillRect.width}px`,
}}
aria-hidden
/>
) : null}
{items.map((item) => {
const isActive = item.id === activeId;
const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon);
const shouldShowLabel = !showInactiveIconOnly;
const shouldShowIcon = Boolean(item.icon) && (!iconOnlyActiveTab || isActive);
const useIntrinsicActiveTab = inactiveTabsIconOnly && usesActivePillIndicator && isActive && !isScrollable && !useIntrinsicPillSizing;
const closable = item.closable !== false && Boolean(onClose);
const closeReplacesIcon = closable && Boolean(item.icon);
const wrapperClassName = (isScrollable || useIntrinsicPillSizing)
? undefined
: usesActivePillIndicator
? (useIntrinsicActiveTab
? 'flex-none basis-auto'
: (isMobile ? 'flex-1 basis-0 min-w-0' : 'flex-1 basis-0 min-w-fit'))
: 'min-w-0 flex-1 basis-0';
const handleAuxClick = closable
? (event: React.MouseEvent<HTMLDivElement>) => {
// Middle-click (button === 1) closes the tab. Matches browser tab behavior.
if (event.button !== 1) {
return;
}
event.preventDefault();
event.stopPropagation();
onClose?.(item.id);
}
: undefined;
const handleMouseDown = closable
? (event: React.MouseEvent<HTMLDivElement>) => {
// Prevent the browser's middle-click autoscroll affordance.
if (event.button === 1) {
event.preventDefault();
}
}
: undefined;
return (
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
<div
ref={(element) => setTabRef(item.id, element)}
onAuxClick={handleAuxClick}
onMouseDown={handleMouseDown}
className={cn(
'group flex h-full min-w-0 flex-nowrap items-center',
(isScrollable || useIntrinsicPillSizing)
? 'shrink-0'
: usesActivePillIndicator
? 'w-full'
: 'w-full min-w-0',
usesActivePillIndicator
? 'relative z-10 bg-transparent'
: isActive
? 'relative z-10 bg-transparent text-foreground'
: 'relative z-10 bg-transparent text-muted-foreground hover:text-foreground'
)}
>
<button
type="button"
role="tab"
aria-selected={isActive}
aria-label={showInactiveIconOnly ? (item.title ?? item.label) : undefined}
onClick={() => onSelect(item.id)}
className={cn(
usesActivePillIndicator
? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 min-w-0 flex-nowrap items-center justify-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] text-sm font-medium transition-colors duration-150 !min-h-0'
: 'flex h-full min-w-0 flex-nowrap items-center typography-micro',
usesActivePillIndicator && activePillLowercase ? 'lowercase' : null,
usesActivePillIndicator && (showInactiveIconOnly ? 'gap-0' : 'gap-1.5'),
usesActivePillIndicator
? useIntrinsicPillSizing
? 'shrink-0 whitespace-nowrap px-3 text-center'
: isScrollable
? 'max-w-56 shrink-0 px-3 text-center'
: (showInactiveIconOnly
? 'px-2 !min-w-0 text-center'
: useIntrinsicActiveTab
? 'shrink-0 whitespace-nowrap px-3 text-center'
: 'px-3 text-center')
: isScrollable
? 'max-w-56 justify-start truncate px-3 text-left'
: 'w-full justify-center truncate px-3 text-center',
usesActivePillIndicator
? (activePillButtonClassName ?? (isActivePillVariant ? (isMobile ? 'h-[38px]' : 'h-[31px]') : 'h-7'))
: null,
usesActivePillIndicator
? isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
: null,
usesActivePillIndicator
? 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
: null
)}
title={item.title ?? item.label}
>
{usesActivePillIndicator ? (
<>
{shouldShowIcon ? (
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && (alwaysShowCloseControls ? 'opacity-0' : 'group-hover:opacity-0'))}>{item.icon}</span>
{closeReplacesIcon ? (
<span
role="button"
tabIndex={-1}
className={cn('absolute inset-0 z-20 flex !min-h-0 !min-w-0 items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onClose?.(item.id);
}}
aria-label={item.closeLabel ?? `Close ${item.label} tab`}
title={item.closeLabel ?? `Close ${item.label} tab`}
>
<Icon name="close" className="h-3.5 w-3.5" />
</span>
) : null}
</span>
) : null}
{shouldShowLabel ? <span className="animated-tabs__label truncate">{item.label}</span> : null}
</>
) : (
<span className={cn('flex min-w-0 flex-nowrap items-center gap-1.5', !isScrollable && 'justify-center')}>
{shouldShowIcon ? (
<span
className={cn(
'relative flex h-4 w-4 shrink-0 items-center justify-center transition-colors duration-200 ease-out',
isActive ? 'text-[var(--primary-base)]' : 'text-muted-foreground'
)}
>
<span className={cn('flex items-center justify-center transition-opacity', closeReplacesIcon && (alwaysShowCloseControls ? 'opacity-0' : 'group-hover:opacity-0'))}>{item.icon}</span>
{closeReplacesIcon ? (
<span
role="button"
tabIndex={-1}
className={cn('absolute inset-0 z-20 flex !min-h-0 !min-w-0 items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onClose?.(item.id);
}}
aria-label={item.closeLabel ?? `Close ${item.label} tab`}
title={item.closeLabel ?? `Close ${item.label} tab`}
>
<Icon name="close" className="h-3.5 w-3.5" />
</span>
) : null}
</span>
) : null}
<span className="truncate leading-[1.2]">{item.label}</span>
</span>
)}
</button>
{closable && !closeReplacesIcon ? (
<button
type="button"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onClose?.(item.id);
}}
className={cn(
'relative z-20 inline-flex !min-h-0 !min-w-0 items-center justify-center transition-opacity',
usesActivePillIndicator
? '-ml-2.5 mr-1 h-[88%] w-5 self-center !aspect-auto rounded-md'
: 'aspect-square h-[65%] min-h-4 max-h-5 rounded-sm mr-1',
usesActivePillIndicator
? (isActive
? 'text-muted-foreground hover:bg-transparent hover:text-foreground'
: 'text-muted-foreground opacity-0 hover:bg-transparent hover:text-foreground group-hover:opacity-100')
: (isActive
? 'text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground'
: 'text-muted-foreground opacity-0 hover:bg-interactive-hover/80 hover:text-foreground group-hover:opacity-100')
)}
aria-label={item.closeLabel ?? `Close ${item.label} tab`}
title={item.closeLabel ?? `Close ${item.label} tab`}
>
<Icon name="close" className="h-3 w-3" />
</button>
) : null}
</div>
</Wrapper>
);
})}
</div>
</div>
);
if (!reorderEnabled) {
return list;
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToXAxis]}
>
<SortableContext items={itemIDs} strategy={horizontalListSortingStrategy}>
{list}
</SortableContext>
<DragOverlay dropAnimation={null} />
</DndContext>
);
};