mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: cleanup useKebabMenu code (#24042)
Refactored the tab overflow hook by renaming `useTabOverflowKebabMenu` to `useKebabMenu` and removing the configurable `alwaysVisibleTabsCount` parameter. - Renamed `useTabOverflowKebabMenu` to `useKebabMenu` and moved it to a new file - Removed the `alwaysVisibleTabsCount` parameter and hardcoded it to 1 tab as `ALWAYS_VISIBLE_TABS_COUNT` - Removed the `utils/index.ts` export file for the Tabs component - Updated the import in `AgentRow.tsx` to use the new hook name and removed the `alwaysVisibleTabsCount` prop - Refactored the internal logic to use a more functional approach with `reduce` instead of imperative loops - Added better performance optimizations to prevent unnecessary re-renders
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export { useTabOverflowKebabMenu } from "./useTabOverflowKebabMenu";
|
||||
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type TabValue = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type UseKebabMenuOptions<T extends TabValue> = {
|
||||
tabs: readonly T[];
|
||||
enabled: boolean;
|
||||
isActive: boolean;
|
||||
overflowTriggerWidth?: number;
|
||||
};
|
||||
|
||||
type UseKebabMenuResult<T extends TabValue> = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
visibleTabs: T[];
|
||||
overflowTabs: T[];
|
||||
getTabMeasureProps: (tabValue: string) => Record<string, string>;
|
||||
};
|
||||
|
||||
const ALWAYS_VISIBLE_TABS_COUNT = 1;
|
||||
const DATA_ATTR_TAB_VALUE = "data-tab-overflow-item-value";
|
||||
|
||||
/**
|
||||
* Splits tabs into visible and overflow groups based on container width.
|
||||
*
|
||||
* Tabs must render with `getTabMeasureProps()` so this hook can measure
|
||||
* trigger widths from the DOM.
|
||||
*/
|
||||
export const useKebabMenu = <T extends TabValue>({
|
||||
tabs,
|
||||
enabled,
|
||||
isActive,
|
||||
overflowTriggerWidth = 44,
|
||||
}: UseKebabMenuOptions<T>): UseKebabMenuResult<T> => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tabsRef = useRef<readonly T[]>(tabs);
|
||||
tabsRef.current = tabs;
|
||||
const previousTabsRef = useRef<readonly T[]>(tabs);
|
||||
const availableWidthRef = useRef<number | null>(null);
|
||||
// Width cache prevents oscillation when overflow tabs are not mounted.
|
||||
const tabWidthByValueRef = useRef<Record<string, number>>({});
|
||||
const [overflowTabValues, setTabValues] = useState<string[]>([]);
|
||||
|
||||
const recalculateOverflow = useCallback(
|
||||
(availableWidth: number) => {
|
||||
if (!enabled || !isActive) {
|
||||
// Keep this update idempotent to avoid render loops.
|
||||
setTabValues((currentValues) => {
|
||||
if (currentValues.length === 0) {
|
||||
return currentValues;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const currentTabs = tabsRef.current;
|
||||
|
||||
const tabWidthByValue = measureTabWidths({
|
||||
tabs: currentTabs,
|
||||
container,
|
||||
previousTabWidthByValue: tabWidthByValueRef.current,
|
||||
});
|
||||
tabWidthByValueRef.current = tabWidthByValue;
|
||||
|
||||
const nextOverflowValues = calculateTabValues({
|
||||
tabs: currentTabs,
|
||||
availableWidth,
|
||||
tabWidthByValue,
|
||||
overflowTriggerWidth,
|
||||
});
|
||||
|
||||
setTabValues((currentValues) => {
|
||||
// Avoid state updates when the computed overflow did not change.
|
||||
if (areStringArraysEqual(currentValues, nextOverflowValues)) {
|
||||
return currentValues;
|
||||
}
|
||||
return nextOverflowValues;
|
||||
});
|
||||
},
|
||||
[enabled, isActive, overflowTriggerWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousTabsRef.current === tabs) {
|
||||
// No change in tabs, no need to recalculate.
|
||||
return;
|
||||
}
|
||||
previousTabsRef.current = tabs;
|
||||
if (availableWidthRef.current === null) {
|
||||
// First mount, no width available yet.
|
||||
return;
|
||||
}
|
||||
recalculateOverflow(availableWidthRef.current);
|
||||
}, [recalculateOverflow, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recompute whenever ResizeObserver reports a container width change.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
availableWidthRef.current = entry.contentRect.width;
|
||||
recalculateOverflow(entry.contentRect.width);
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [recalculateOverflow]);
|
||||
|
||||
const overflowTabValuesSet = new Set(overflowTabValues);
|
||||
const { visibleTabs, overflowTabs } = tabs.reduce<{
|
||||
visibleTabs: T[];
|
||||
overflowTabs: T[];
|
||||
}>(
|
||||
(tabGroups, tab) => {
|
||||
if (overflowTabValuesSet.has(tab.value)) {
|
||||
tabGroups.overflowTabs.push(tab);
|
||||
} else {
|
||||
tabGroups.visibleTabs.push(tab);
|
||||
}
|
||||
return tabGroups;
|
||||
},
|
||||
{ visibleTabs: [], overflowTabs: [] },
|
||||
);
|
||||
|
||||
const getTabMeasureProps = (tabValue: string) => {
|
||||
return { [DATA_ATTR_TAB_VALUE]: tabValue };
|
||||
};
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
visibleTabs,
|
||||
overflowTabs,
|
||||
getTabMeasureProps,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateTabValues = <T extends TabValue>({
|
||||
tabs,
|
||||
availableWidth,
|
||||
tabWidthByValue,
|
||||
overflowTriggerWidth,
|
||||
}: {
|
||||
tabs: readonly T[];
|
||||
availableWidth: number;
|
||||
tabWidthByValue: Readonly<Record<string, number>>;
|
||||
overflowTriggerWidth: number;
|
||||
}): string[] => {
|
||||
const tabWidthByValueMap = new Map<string, number>();
|
||||
for (const tab of tabs) {
|
||||
tabWidthByValueMap.set(tab.value, tabWidthByValue[tab.value] ?? 0);
|
||||
}
|
||||
|
||||
const firstOptionalTabIndex = Math.min(
|
||||
ALWAYS_VISIBLE_TABS_COUNT,
|
||||
tabs.length,
|
||||
);
|
||||
if (firstOptionalTabIndex >= tabs.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const alwaysVisibleTabs = tabs.slice(0, firstOptionalTabIndex);
|
||||
const optionalTabs = tabs.slice(firstOptionalTabIndex);
|
||||
const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => {
|
||||
return total + (tabWidthByValueMap.get(tab.value) ?? 0);
|
||||
}, 0);
|
||||
const firstTabIndex = findFirstTabIndex({
|
||||
optionalTabs,
|
||||
optionalTabWidths: optionalTabs.map((tab) => {
|
||||
return tabWidthByValueMap.get(tab.value) ?? 0;
|
||||
}),
|
||||
startingUsedWidth: alwaysVisibleWidth,
|
||||
availableWidth,
|
||||
overflowTriggerWidth,
|
||||
});
|
||||
|
||||
if (firstTabIndex === -1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return optionalTabs
|
||||
.slice(firstTabIndex)
|
||||
.map((overflowTab) => overflowTab.value);
|
||||
};
|
||||
|
||||
const measureTabWidths = <T extends TabValue>({
|
||||
tabs,
|
||||
container,
|
||||
previousTabWidthByValue,
|
||||
}: {
|
||||
tabs: readonly T[];
|
||||
container: HTMLDivElement;
|
||||
previousTabWidthByValue: Readonly<Record<string, number>>;
|
||||
}): Record<string, number> => {
|
||||
const nextTabWidthByValue = { ...previousTabWidthByValue };
|
||||
for (const tab of tabs) {
|
||||
const tabElement = container.querySelector<HTMLElement>(
|
||||
`[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`,
|
||||
);
|
||||
if (tabElement) {
|
||||
nextTabWidthByValue[tab.value] = tabElement.offsetWidth;
|
||||
}
|
||||
}
|
||||
return nextTabWidthByValue;
|
||||
};
|
||||
|
||||
const findFirstTabIndex = ({
|
||||
optionalTabs,
|
||||
optionalTabWidths,
|
||||
startingUsedWidth,
|
||||
availableWidth,
|
||||
overflowTriggerWidth,
|
||||
}: {
|
||||
optionalTabs: readonly TabValue[];
|
||||
optionalTabWidths: readonly number[];
|
||||
startingUsedWidth: number;
|
||||
availableWidth: number;
|
||||
overflowTriggerWidth: number;
|
||||
}): number => {
|
||||
const result = optionalTabs.reduce(
|
||||
(acc, _tab, index) => {
|
||||
if (acc.firstTabIndex !== -1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const tabWidth = optionalTabWidths[index] ?? 0;
|
||||
const hasMoreTabs = index < optionalTabs.length - 1;
|
||||
// Reserve kebab trigger width whenever additional tabs remain.
|
||||
const widthNeeded =
|
||||
acc.usedWidth + tabWidth + (hasMoreTabs ? overflowTriggerWidth : 0);
|
||||
|
||||
if (widthNeeded <= availableWidth) {
|
||||
return {
|
||||
usedWidth: acc.usedWidth + tabWidth,
|
||||
firstTabIndex: -1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
usedWidth: acc.usedWidth,
|
||||
firstTabIndex: index,
|
||||
};
|
||||
},
|
||||
{ usedWidth: startingUsedWidth, firstTabIndex: -1 },
|
||||
);
|
||||
|
||||
return result.firstTabIndex;
|
||||
};
|
||||
|
||||
const areStringArraysEqual = (
|
||||
left: readonly string[],
|
||||
right: readonly string[],
|
||||
): boolean => {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value, index) => value === right[index])
|
||||
);
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type TabLike = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type UseTabOverflowKebabMenuOptions<TTab extends TabLike> = {
|
||||
tabs: readonly TTab[];
|
||||
enabled: boolean;
|
||||
isActive: boolean;
|
||||
alwaysVisibleTabsCount?: number;
|
||||
overflowTriggerWidthPx?: number;
|
||||
};
|
||||
|
||||
type UseTabOverflowKebabMenuResult<TTab extends TabLike> = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
visibleTabs: TTab[];
|
||||
overflowTabs: TTab[];
|
||||
getTabMeasureProps: (tabValue: string) => Record<string, string>;
|
||||
};
|
||||
|
||||
const DATA_ATTR_TAB_VALUE = "data-tab-overflow-item-value";
|
||||
|
||||
export const useTabOverflowKebabMenu = <TTab extends TabLike>({
|
||||
tabs,
|
||||
enabled,
|
||||
isActive,
|
||||
alwaysVisibleTabsCount = 1,
|
||||
overflowTriggerWidthPx = 44,
|
||||
}: UseTabOverflowKebabMenuOptions<TTab>): UseTabOverflowKebabMenuResult<TTab> => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tabWidthByValueRef = useRef<Record<string, number>>({});
|
||||
const tabsRef = useRef(tabs);
|
||||
const [overflowTabValues, setOverflowTabValues] = useState<string[]>([]);
|
||||
tabsRef.current = tabs;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isActive) {
|
||||
setOverflowTabValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recalculateOverflow = () => {
|
||||
const currentTabs = tabsRef.current;
|
||||
for (const tab of currentTabs) {
|
||||
const tabElement = container.querySelector<HTMLElement>(
|
||||
`[${DATA_ATTR_TAB_VALUE}="${tab.value}"]`,
|
||||
);
|
||||
if (tabElement) {
|
||||
tabWidthByValueRef.current[tab.value] = tabElement.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
const alwaysVisibleTabs = currentTabs.slice(0, alwaysVisibleTabsCount);
|
||||
const optionalTabs = currentTabs.slice(alwaysVisibleTabsCount);
|
||||
if (optionalTabs.length === 0) {
|
||||
setOverflowTabValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const alwaysVisibleWidth = alwaysVisibleTabs.reduce((total, tab) => {
|
||||
return total + (tabWidthByValueRef.current[tab.value] ?? 0);
|
||||
}, 0);
|
||||
|
||||
const availableWidth = container.clientWidth;
|
||||
let usedWidth = alwaysVisibleWidth;
|
||||
const nextOverflowValues: string[] = [];
|
||||
|
||||
for (let i = 0; i < optionalTabs.length; i++) {
|
||||
const tab = optionalTabs[i];
|
||||
const tabWidth = tabWidthByValueRef.current[tab.value] ?? 0;
|
||||
const hasMoreTabsAfterCurrent = i < optionalTabs.length - 1;
|
||||
const widthNeeded =
|
||||
usedWidth +
|
||||
tabWidth +
|
||||
(hasMoreTabsAfterCurrent ? overflowTriggerWidthPx : 0);
|
||||
|
||||
if (widthNeeded <= availableWidth) {
|
||||
usedWidth += tabWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextOverflowValues.push(
|
||||
...optionalTabs.slice(i).map((overflowTab) => overflowTab.value),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
setOverflowTabValues((currentValues) => {
|
||||
if (
|
||||
currentValues.length === nextOverflowValues.length &&
|
||||
currentValues.every(
|
||||
(value, index) => value === nextOverflowValues[index],
|
||||
)
|
||||
) {
|
||||
return currentValues;
|
||||
}
|
||||
return nextOverflowValues;
|
||||
});
|
||||
};
|
||||
|
||||
recalculateOverflow();
|
||||
const observer = new ResizeObserver(recalculateOverflow);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [alwaysVisibleTabsCount, enabled, isActive, overflowTriggerWidthPx]);
|
||||
|
||||
const overflowTabValuesSet = useMemo(
|
||||
() => new Set(overflowTabValues),
|
||||
[overflowTabValues],
|
||||
);
|
||||
|
||||
const visibleTabs = useMemo(
|
||||
() => tabs.filter((tab) => !overflowTabValuesSet.has(tab.value)),
|
||||
[tabs, overflowTabValuesSet],
|
||||
);
|
||||
const overflowTabs = useMemo(
|
||||
() => tabs.filter((tab) => overflowTabValuesSet.has(tab.value)),
|
||||
[tabs, overflowTabValuesSet],
|
||||
);
|
||||
|
||||
const getTabMeasureProps = (tabValue: string) => {
|
||||
return { [DATA_ATTR_TAB_VALUE]: tabValue };
|
||||
};
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
visibleTabs,
|
||||
overflowTabs,
|
||||
getTabMeasureProps,
|
||||
};
|
||||
};
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "#/components/Tabs/Tabs";
|
||||
import { useTabOverflowKebabMenu } from "#/components/Tabs/utils";
|
||||
import { useKebabMenu } from "#/components/Tabs/utils/useKebabMenu";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
@@ -256,11 +256,10 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
visibleTabs: visibleLogTabs,
|
||||
overflowTabs: overflowLogTabs,
|
||||
getTabMeasureProps,
|
||||
} = useTabOverflowKebabMenu({
|
||||
} = useKebabMenu({
|
||||
tabs: logTabs,
|
||||
enabled: true,
|
||||
isActive: showLogs,
|
||||
alwaysVisibleTabsCount: 1,
|
||||
});
|
||||
const overflowLogTabValuesSet = new Set(
|
||||
overflowLogTabs.map((tab) => tab.value),
|
||||
|
||||
Reference in New Issue
Block a user