|
|
|
@@ -42,12 +42,15 @@ type SortableTabsStripProps = {
|
|
|
|
|
activePillInsetClassName?: string;
|
|
|
|
|
activePillButtonClassName?: string;
|
|
|
|
|
inactiveTabsIconOnly?: boolean;
|
|
|
|
|
iconOnlyActiveTab?: boolean;
|
|
|
|
|
animateActivePill?: boolean;
|
|
|
|
|
activePillLowercase?: boolean;
|
|
|
|
|
className?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Keep in sync with `.pill-tabs__indicator--is-animated` in index.css.
|
|
|
|
|
const PILL_SWITCH_ANIMATION_MS = 320;
|
|
|
|
|
const PILL_NUDGE_PX = 4;
|
|
|
|
|
|
|
|
|
|
const restrictToXAxis: Modifier = ({ transform }) => ({
|
|
|
|
|
...transform,
|
|
|
|
|
y: 0,
|
|
|
|
@@ -95,7 +98,6 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
activePillInsetClassName,
|
|
|
|
|
activePillButtonClassName,
|
|
|
|
|
inactiveTabsIconOnly = false,
|
|
|
|
|
iconOnlyActiveTab = false,
|
|
|
|
|
animateActivePill,
|
|
|
|
|
activePillLowercase = true,
|
|
|
|
|
className,
|
|
|
|
@@ -116,11 +118,31 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
const usesIndicator = usesActivePillIndicator || useUnderlineIndicator;
|
|
|
|
|
const useIntrinsicPillSizing = isActivePillVariant && isScrollable;
|
|
|
|
|
const showPillTrackBackground = usesActivePillIndicator;
|
|
|
|
|
const shouldAnimateActivePill = animateActivePill ?? isAnimatedVariant;
|
|
|
|
|
const shouldAnimateActivePill = animateActivePill ?? usesActivePillIndicator;
|
|
|
|
|
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);
|
|
|
|
|
// Transitions stay off until the user actually switches tabs, so the initial
|
|
|
|
|
// measurement (and any container resize) repositions the pill instantly.
|
|
|
|
|
const [pillTransitionEnabled, setPillTransitionEnabled] = React.useState(false);
|
|
|
|
|
const [pressedId, setPressedId] = React.useState<string | null>(null);
|
|
|
|
|
const pillTransitionReadyRef = React.useRef(false);
|
|
|
|
|
const lastSwitchAtRef = React.useRef(0);
|
|
|
|
|
|
|
|
|
|
// Pressing a neighbouring tab leans the pill toward it before the selection
|
|
|
|
|
// commits, which is what makes the control feel physical rather than snappy.
|
|
|
|
|
const pillNudge = React.useMemo(() => {
|
|
|
|
|
if (!usesActivePillIndicator || !pressedId || !activeId || pressedId === activeId) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
const pressedIndex = items.findIndex((item) => item.id === pressedId);
|
|
|
|
|
const activeIndex = items.findIndex((item) => item.id === activeId);
|
|
|
|
|
if (pressedIndex < 0 || activeIndex < 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return pressedIndex > activeIndex ? PILL_NUDGE_PX : -PILL_NUDGE_PX;
|
|
|
|
|
}, [activeId, items, pressedId, usesActivePillIndicator]);
|
|
|
|
|
|
|
|
|
|
const sensors = useSensors(
|
|
|
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
|
|
@@ -236,7 +258,14 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const observer = new ResizeObserver(updateActivePillRect);
|
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
|
|
|
// Layout-driven repositioning should snap, not slide — except while a tab
|
|
|
|
|
// switch is still animating, where the active tab may legitimately resize.
|
|
|
|
|
if (performance.now() - lastSwitchAtRef.current > PILL_SWITCH_ANIMATION_MS) {
|
|
|
|
|
setPillTransitionEnabled(false);
|
|
|
|
|
}
|
|
|
|
|
updateActivePillRect();
|
|
|
|
|
});
|
|
|
|
|
observer.observe(element);
|
|
|
|
|
|
|
|
|
|
if (activeId) {
|
|
|
|
@@ -255,6 +284,24 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
updateActivePillRect();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
if (!usesIndicator) {
|
|
|
|
|
pillTransitionReadyRef.current = false;
|
|
|
|
|
setPillTransitionEnabled(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The first pass only records that a pill exists; animating it would slide
|
|
|
|
|
// the pill in from the track origin on mount.
|
|
|
|
|
if (!pillTransitionReadyRef.current) {
|
|
|
|
|
pillTransitionReadyRef.current = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastSwitchAtRef.current = performance.now();
|
|
|
|
|
setPillTransitionEnabled(true);
|
|
|
|
|
}, [activeId, usesIndicator]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isScrollable || !activeId) {
|
|
|
|
|
return;
|
|
|
|
@@ -327,7 +374,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
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',
|
|
|
|
|
showPillTrackBackground && 'rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] bg-[color-mix(in_srgb,var(--foreground)_4%,transparent)] p-0.5 gap-0.5',
|
|
|
|
|
isScrollable
|
|
|
|
|
? 'overflow-x-auto scrollbar-none'
|
|
|
|
|
: 'overflow-x-hidden',
|
|
|
|
@@ -340,15 +387,16 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
<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'
|
|
|
|
|
// Lifted card look: hairline edge plus a soft ambient shadow rather
|
|
|
|
|
// than a hard border, so the pill reads as raised above the track.
|
|
|
|
|
'border border-[color-mix(in_srgb,var(--foreground)_7%,transparent)]',
|
|
|
|
|
'shadow-[0_1px_2px_color-mix(in_srgb,var(--foreground)_10%,transparent),0_2px_6px_color-mix(in_srgb,var(--foreground)_6%,transparent)]',
|
|
|
|
|
shouldAnimateActivePill && pillTransitionEnabled && 'pill-tabs__indicator--is-animated'
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
transform: `translate3d(${pillRect.left}px, ${pillRect.top}px, 0)`,
|
|
|
|
|
transform: `translate3d(${pillRect.left + pillNudge}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}
|
|
|
|
@@ -366,7 +414,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
const isActive = item.id === activeId;
|
|
|
|
|
const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon);
|
|
|
|
|
const shouldShowLabel = !showInactiveIconOnly;
|
|
|
|
|
const shouldShowIcon = Boolean(item.icon) && (!iconOnlyActiveTab || isActive);
|
|
|
|
|
const shouldShowIcon = Boolean(item.icon);
|
|
|
|
|
const useIntrinsicActiveTab = inactiveTabsIconOnly && usesActivePillIndicator && isActive && !isScrollable && !useIntrinsicPillSizing;
|
|
|
|
|
const closable = item.closable !== false && Boolean(onClose);
|
|
|
|
|
const closeReplacesIcon = closable && Boolean(item.icon);
|
|
|
|
@@ -422,6 +470,13 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
|
|
|
|
aria-selected={isActive}
|
|
|
|
|
aria-label={showInactiveIconOnly ? (item.title ?? item.label) : undefined}
|
|
|
|
|
onClick={() => onSelect(item.id)}
|
|
|
|
|
onPointerDown={usesActivePillIndicator ? () => {
|
|
|
|
|
setPillTransitionEnabled(true);
|
|
|
|
|
setPressedId(item.id);
|
|
|
|
|
} : undefined}
|
|
|
|
|
onPointerUp={usesActivePillIndicator ? () => setPressedId(null) : undefined}
|
|
|
|
|
onPointerLeave={usesActivePillIndicator ? () => setPressedId(null) : undefined}
|
|
|
|
|
onPointerCancel={usesActivePillIndicator ? () => setPressedId(null) : undefined}
|
|
|
|
|
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'
|
|
|
|
|