snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
169
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
169
apps/web/core/components/navigation/use-responsive-tab-layout.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TNavigationItem } from "./tab-navigation-root";
|
||||
|
||||
export type TResponsiveTabLayout = {
|
||||
visibleItems: TNavigationItem[];
|
||||
overflowItems: TNavigationItem[];
|
||||
hasOverflow: boolean;
|
||||
itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;
|
||||
containerRef: (node: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
type UseResponsiveTabLayoutProps = {
|
||||
visibleNavigationItems: TNavigationItem[];
|
||||
hiddenNavigationItems: TNavigationItem[];
|
||||
isActive: (item: TNavigationItem) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for managing responsive tab layout
|
||||
* Calculates which tabs fit in the visible area and which overflow
|
||||
* Implements smart pinning to keep active tabs visible
|
||||
*
|
||||
* @param visibleNavigationItems - Items that are not user-hidden
|
||||
* @param hiddenNavigationItems - Items that user explicitly hid
|
||||
* @param isActive - Function to check if a tab is active
|
||||
* @returns Layout information and refs for rendering
|
||||
*/
|
||||
export const useResponsiveTabLayout = ({
|
||||
visibleNavigationItems,
|
||||
hiddenNavigationItems,
|
||||
isActive,
|
||||
}: UseResponsiveTabLayoutProps): TResponsiveTabLayout => {
|
||||
// Refs for measuring items
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
// State for responsive behavior
|
||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||
const [visibleCount, setVisibleCount] = useState<number>(visibleNavigationItems.length);
|
||||
|
||||
// Constants
|
||||
const gap = 4; // gap-1 = 4px
|
||||
const overflowButtonWidth = 40;
|
||||
|
||||
// Callback ref that sets up ResizeObserver when element is attached
|
||||
const containerRef = useCallback((node: HTMLDivElement | null) => {
|
||||
// Clean up previous observer if it exists
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
|
||||
// If node is null (unmounting), just clean up
|
||||
if (!node) {
|
||||
setContainerWidth(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial width immediately
|
||||
setContainerWidth(node.offsetWidth);
|
||||
|
||||
// Create and set up new ResizeObserver
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
resizeObserver.observe(node);
|
||||
}, []); // Empty deps - callback function remains stable
|
||||
|
||||
// Cleanup effect to disconnect observer on component unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate how many items can fit
|
||||
useEffect(() => {
|
||||
if (!containerWidth || itemRefs.current.length === 0) return;
|
||||
|
||||
let totalWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < itemRefs.current.length; i++) {
|
||||
const item = itemRefs.current[i];
|
||||
if (!item) continue;
|
||||
|
||||
const itemWidth = item.offsetWidth;
|
||||
const widthWithGap = itemWidth + (count > 0 ? gap : 0);
|
||||
|
||||
// If we still have items to show, reserve space for overflow button
|
||||
const remainingItems = visibleNavigationItems.length - (i + 1);
|
||||
const reservedSpace = remainingItems > 0 ? overflowButtonWidth + gap : 0;
|
||||
|
||||
if (totalWidth + widthWithGap + reservedSpace <= containerWidth) {
|
||||
totalWidth += widthWithGap;
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one item is visible if there's space
|
||||
if (count === 0 && visibleNavigationItems.length > 0 && containerWidth > overflowButtonWidth) {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
setVisibleCount(count);
|
||||
}, [containerWidth, visibleNavigationItems.length, gap, overflowButtonWidth]);
|
||||
|
||||
// Memoize active tab index to prevent unnecessary re-renders
|
||||
const activeTabIndex = useMemo(
|
||||
() => visibleNavigationItems.findIndex((item) => isActive(item)),
|
||||
[visibleNavigationItems, isActive]
|
||||
);
|
||||
|
||||
// Smart pinning logic: calculate visible and overflow items
|
||||
const { visibleItems, overflowItems, hasOverflow } = useMemo(() => {
|
||||
// Start with responsive calculation: which items fit in available space
|
||||
let visible = visibleNavigationItems.slice(0, visibleCount);
|
||||
let overflow = visibleNavigationItems.slice(visibleCount);
|
||||
|
||||
// If active tab would be in overflow, swap it with last visible item
|
||||
if (activeTabIndex !== -1 && activeTabIndex >= visibleCount && visibleCount > 0) {
|
||||
const activeItem = visibleNavigationItems[activeTabIndex];
|
||||
const replacedItem = visible[visibleCount - 1];
|
||||
|
||||
visible = [...visible.slice(0, visibleCount - 1), activeItem];
|
||||
// Add replaced item to overflow, maintain order
|
||||
overflow = [
|
||||
replacedItem,
|
||||
...visibleNavigationItems.slice(visibleCount, activeTabIndex),
|
||||
...visibleNavigationItems.slice(activeTabIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
// Combine space-overflowed items with user-hidden items
|
||||
// User-hidden items (in hiddenNavigationItems) will show "Eye" icon
|
||||
// Space-overflowed items (in overflow from visibleNavigationItems) will NOT show "Eye" icon
|
||||
const allOverflow = [...overflow, ...hiddenNavigationItems];
|
||||
|
||||
return {
|
||||
visibleItems: visible,
|
||||
overflowItems: allOverflow,
|
||||
hasOverflow: allOverflow.length > 0,
|
||||
};
|
||||
}, [visibleNavigationItems, hiddenNavigationItems, visibleCount, activeTabIndex]);
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
overflowItems,
|
||||
hasOverflow,
|
||||
itemRefs,
|
||||
containerRef,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user