Files
kandev/apps/web/components/task/topbar-action-overflow.tsx
T
João Salavisa a77df053a6 refactor(web): drop redundant VCS split button from task top bar (#804)
* refactor(web): drop redundant VCS split button from task top bar

All git operations (commit, push, pull, rebase, merge, create PR,
force push) are reachable from the Changes panel — its timeline
exposes stage/commit and the per-section action row provides
push/force-push/create-PR, while the panel header carries the
pull/rebase/merge dropdown. The session command palette mirrors
the same set for keyboard users. The expanded review dialog keeps
its own VcsSplitButton because the changes timeline is hidden
behind the modal there.

Also removes the now-orphaned 'required' overflow flag (the VCS
item was its only caller) and the e2e helper + spec that targeted
the removed primary-action button.

* test(e2e): drop task-topbar-overflow spec relying on removed VCS button

The 1100px viewport test forced overflow by counting on the VCS button's
width; with VcsSplitButton gone the scenario no longer holds. Overflow
priority logic is already covered by topbar-action-overflow.test.ts.

Co-authored-by: João Salavisa <jcfs@users.noreply.github.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: João Salavisa <jcfs@users.noreply.github.com>
2026-05-04 00:53:21 +01:00

254 lines
7.1 KiB
TypeScript

"use client";
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from "react";
import { IconDots } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/lib/utils";
export type TopbarOverflowItem = {
id: string;
label: string;
priority: number;
content: ReactNode;
};
type TopbarOverflowMetricItem = Pick<TopbarOverflowItem, "id" | "priority">;
type HiddenTopbarActionArgs = {
items: TopbarOverflowMetricItem[];
availableWidth: number;
itemWidths: ReadonlyMap<string, number>;
gap: number;
overflowTriggerWidth: number;
fallbackItemWidth?: number;
};
const DEFAULT_FALLBACK_ITEM_WIDTH = 88;
const DEFAULT_OVERFLOW_TRIGGER_WIDTH = 40;
function setsMatch(set: Set<string>, values: string[]) {
if (set.size !== values.length) return false;
return values.every((value) => set.has(value));
}
function itemWidth(
item: TopbarOverflowMetricItem,
widths: ReadonlyMap<string, number>,
fallback: number,
) {
return widths.get(item.id) ?? fallback;
}
function totalActionWidth({
items,
hiddenIds,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth,
}: {
items: TopbarOverflowMetricItem[];
hiddenIds: Set<string>;
itemWidths: ReadonlyMap<string, number>;
gap: number;
overflowTriggerWidth: number;
fallbackItemWidth: number;
}) {
const visibleItems = items.filter((item) => !hiddenIds.has(item.id));
const visibleWidth = visibleItems.reduce(
(total, item) => total + itemWidth(item, itemWidths, fallbackItemWidth),
0,
);
const controlCount = visibleItems.length + (hiddenIds.size > 0 ? 1 : 0);
const gapWidth = Math.max(0, controlCount - 1) * gap;
return visibleWidth + (hiddenIds.size > 0 ? overflowTriggerWidth : 0) + gapWidth;
}
export function getHiddenTopbarActionIds({
items,
availableWidth,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth = DEFAULT_FALLBACK_ITEM_WIDTH,
}: HiddenTopbarActionArgs): string[] {
const hiddenIds = new Set<string>();
const hideableItems = [...items].sort((a, b) => a.priority - b.priority);
while (
totalActionWidth({
items,
hiddenIds,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth,
}) > availableWidth
) {
const nextItem = hideableItems.find((item) => !hiddenIds.has(item.id));
if (!nextItem) break;
hiddenIds.add(nextItem.id);
}
return items.filter((item) => hiddenIds.has(item.id)).map((item) => item.id);
}
function readFlexGap(element: HTMLElement) {
const styles = window.getComputedStyle(element);
const gap = Number.parseFloat(styles.columnGap || styles.gap || "0");
return Number.isFinite(gap) ? gap : 0;
}
type TopbarActionOverflowProps = {
items: TopbarOverflowItem[];
className?: string;
};
function measureVisibleItems(
itemRefs: Map<string, HTMLDivElement>,
itemWidths: Map<string, number>,
) {
for (const [id, element] of itemRefs) {
const width = Math.ceil(element.getBoundingClientRect().width);
if (width > 0) itemWidths.set(id, width);
}
}
function useTopbarOverflowState(items: TopbarOverflowItem[]) {
const containerRef = useRef<HTMLDivElement | null>(null);
const overflowTriggerRef = useRef<HTMLButtonElement | null>(null);
const itemRefs = useRef(new Map<string, HTMLDivElement>());
const itemWidths = useRef(new Map<string, number>());
const [hiddenIds, setHiddenIds] = useState<Set<string>>(() => new Set());
const [measuredWidth, setMeasuredWidth] = useState(0);
const registerItem = useCallback(
(id: string) => (element: HTMLDivElement | null) => {
if (element) {
itemRefs.current.set(id, element);
} else {
itemRefs.current.delete(id);
}
},
[],
);
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
const updateWidth = () => setMeasuredWidth(element.clientWidth);
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container || container.clientWidth <= 0) return;
measureVisibleItems(itemRefs.current, itemWidths.current);
const overflowWidth =
overflowTriggerRef.current?.getBoundingClientRect().width || DEFAULT_OVERFLOW_TRIGGER_WIDTH;
const nextHiddenIds = getHiddenTopbarActionIds({
items,
availableWidth: container.clientWidth,
itemWidths: itemWidths.current,
gap: readFlexGap(container),
overflowTriggerWidth: overflowWidth,
});
setHiddenIds((current) =>
setsMatch(current, nextHiddenIds) ? current : new Set(nextHiddenIds),
);
}, [items, measuredWidth]);
return { containerRef, overflowTriggerRef, registerItem, hiddenIds };
}
function OverflowTrigger({ triggerRef }: { triggerRef: Ref<HTMLButtonElement> }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
size="sm"
variant="outline"
className="h-8 cursor-pointer px-2"
aria-label="More top bar actions"
data-testid="topbar-action-overflow-trigger"
>
<IconDots className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
</Tooltip>
);
}
function OverflowPopover({
items,
triggerRef,
}: {
items: TopbarOverflowItem[];
triggerRef: Ref<HTMLButtonElement>;
}) {
if (items.length === 0) return null;
return (
<Popover>
<OverflowTrigger triggerRef={triggerRef} />
<PopoverContent
align="end"
className="w-auto max-w-[min(520px,calc(100vw-1rem))] gap-0 rounded-md p-1.5"
>
<div className="flex max-w-[480px] flex-wrap items-center justify-end gap-2">
{items.map((item) => (
<div
key={item.id}
aria-label={item.label}
className="inline-flex shrink-0 items-center [&_button]:h-8 [&_button]:text-xs"
>
{item.content}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function TopbarActionOverflow({ items, className }: TopbarActionOverflowProps) {
const { containerRef, overflowTriggerRef, registerItem, hiddenIds } =
useTopbarOverflowState(items);
const hiddenItems = items.filter((item) => hiddenIds.has(item.id));
return (
<div
ref={containerRef}
className={cn(
"flex min-w-0 w-full items-center justify-end gap-2 overflow-hidden",
className,
)}
data-testid="topbar-action-overflow"
>
{items.map((item) =>
hiddenIds.has(item.id) ? null : (
<div key={item.id} ref={registerItem(item.id)} className="shrink-0">
{item.content}
</div>
),
)}
<OverflowPopover items={hiddenItems} triggerRef={overflowTriggerRef} />
</div>
);
}