feat(ui): spring the active pill in tab strips
Give the active-pill tab strips the lifted, physical feel of a spring capsule: the pill now travels with a slight overshoot instead of jumping, leans toward a neighbouring tab on press, and sits on a raised surface with a soft ambient shadow rather than a hard border. Transitions are opt-in per switch so first measurement and container resizes still snap into place, and reduced-motion disables them. Also drop the icons from the git commit/update/pr switcher, along with the now-unused iconOnlyActiveTab prop.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -618,9 +618,9 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
||||
const [gitLogDialogMode, setGitLogDialogMode] = React.useState<GitLogDialogMode | null>(null);
|
||||
|
||||
const actionTabItems = React.useMemo(() => [
|
||||
{ id: 'commit', label: t('gitView.tabs.commit'), icon: <Icon name="git-commit" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'branch', label: t('gitView.tabs.update'), icon: <Icon name="git-merge" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'pr', label: t('gitView.tabs.pr'), icon: <Icon name="git-pull-request" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'commit', label: t('gitView.tabs.commit') },
|
||||
{ id: 'branch', label: t('gitView.tabs.update') },
|
||||
{ id: 'pr', label: t('gitView.tabs.pr') },
|
||||
], [t]);
|
||||
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
@@ -374,7 +374,6 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
onSelect={onSelectActionTab}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
iconOnlyActiveTab={true}
|
||||
activePillButtonClassName="h-7"
|
||||
className="h-full"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user