import type { ComponentType, ReactNode } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { getSaveChangesLabel, getSavingLabel } from '../utils/i18n'; import Button from './Button'; import Toast from './Toast'; import type { IconProps } from './Icons'; import classNames from '../utils/classNames'; import type { NoticeState } from '../types/api'; export type ShellPageName = string; export type ShellTabKey = string; export type ShellPage = { name: ShellPageName; title: string; }; export type ShellTab = { name: ShellTabKey; title: string; icon?: ComponentType; render: () => ReactNode; }; export type AdminShellProps = { title: string; logoUrl?: string; pages: ShellPage[]; activePage: ShellPageName; onSelectPage: ( page: ShellPageName ) => void; tabs: Array ReactNode }>; activeTab: ShellTabKey; onSelectTab: ( tab: ShellTabKey ) => void; onSave: () => void; isSaving: boolean; isLoading: boolean; isDirty: boolean; notice: NoticeState | null; onDismissNotice: () => void; loadErrorMessage: string | null; onRetryLoad: () => void; children: ReactNode; }; const AdminShell = ( props: AdminShellProps ) => { const { title, logoUrl, pages, activePage, onSelectPage, tabs, activeTab, onSelectTab, onSave, isSaving, isLoading, isDirty, notice, onDismissNotice, loadErrorMessage, onRetryLoad, children, } = props; const showGlobalSave = ( activePage === 'settings' && activeTab !== 'redirects' ) || activePage === 'notify'; const tabRefs = useRef>>( {} ); const overflowMenuRef = useRef( null ); const tabsListRef = useRef( null ); const [ overflowTabs, setOverflowTabs ] = useState( [] ); const [ isOverflowMenuOpen, setIsOverflowMenuOpen ] = useState( false ); const [ isMeasuringTabs, setIsMeasuringTabs ] = useState( true ); const visibleTabs = useMemo( () => { if ( isMeasuringTabs ) { return tabs; } return tabs.filter( ( tab ) => ! overflowTabs.includes( tab.name ) ); }, [ tabs, overflowTabs, isMeasuringTabs ] ); const overflowList = useMemo( () => { if ( isMeasuringTabs ) { return [] as typeof tabs; } return tabs.filter( ( tab ) => overflowTabs.includes( tab.name ) ); }, [ tabs, overflowTabs, isMeasuringTabs ] ); const hasOverflow = ! isMeasuringTabs && overflowList.length > 0; useLayoutEffect( () => { if ( ! isMeasuringTabs ) { return; } const frame = requestAnimationFrame( () => { const buttons = tabs .map( ( tab ) => tabRefs.current[ tab.name ] ) .filter( Boolean ) as HTMLButtonElement[]; if ( buttons.length === 0 ) { setOverflowTabs( [] ); setIsMeasuringTabs( false ); return; } const lis = buttons.map( ( b ) => b.parentElement as HTMLLIElement ); const container = tabsListRef.current; if ( ! container || lis.length === 0 ) { setOverflowTabs( [] ); setIsMeasuringTabs( false ); return; } const firstRowTop = lis[ 0 ].offsetTop; // Find the first item that wraps to the next line const firstWrapIndex = lis.findIndex( ( li ) => li.offsetTop > firstRowTop + 5, ); if ( firstWrapIndex === -1 ) { setOverflowTabs( [] ); setIsMeasuringTabs( false ); return; } // We have overflow. Now check if we need to hide one more item to fit the "..." button. // The "..." button is roughly 48px wide (icon + padding). // We also need to account for the gap (margin-right) of the last item. const OVERFLOW_BUTTON_WIDTH = 48; const ITEM_MARGIN_RIGHT = 8; // me-2 is 0.5rem = 8px const containerRect = container.getBoundingClientRect(); const lastVisibleLi = lis[ firstWrapIndex - 1 ]; const lastLiRect = lastVisibleLi.getBoundingClientRect(); // Calculate space remaining on the right of the last visible item // containerRect.right is the edge of the container // lastLiRect.right is the edge of the item // We subtract ITEM_MARGIN_RIGHT because the next item (the "..." button) will start after the margin. const spaceAvailable = containerRect.right - lastLiRect.right - ITEM_MARGIN_RIGHT; let cutOffIndex = firstWrapIndex; // If there isn't enough space for the "..." button, we need to hide the last visible item as well if ( spaceAvailable < OVERFLOW_BUTTON_WIDTH ) { cutOffIndex = Math.max( 0, firstWrapIndex - 1 ); } const nextOverflow = tabs .slice( cutOffIndex ) .map( ( tab ) => tab.name ); setOverflowTabs( nextOverflow ); if ( nextOverflow.length === 0 ) { setIsOverflowMenuOpen( false ); } setIsMeasuringTabs( false ); } ); return () => cancelAnimationFrame( frame ); }, [ isMeasuringTabs, tabs, tabRefs ] ); useEffect( () => { const handleResize = () => setIsMeasuringTabs( true ); window.addEventListener( 'resize', handleResize ); return () => window.removeEventListener( 'resize', handleResize ); }, [] ); useEffect( () => { setIsMeasuringTabs( true ); }, [ tabs ] ); useEffect( () => { if ( ! isOverflowMenuOpen ) { return () => {}; } const handleClickOutside = ( event: MouseEvent ) => { if ( overflowMenuRef.current?.contains( event.target as Node ) ) { return; } setIsOverflowMenuOpen( false ); }; document.addEventListener( 'mousedown', handleClickOutside ); return () => document.removeEventListener( 'mousedown', handleClickOutside ); }, [ isOverflowMenuOpen ] ); return (
{ logoUrl ? ( { ) : (
AS
) }
{ logoUrl ? null : (

Airygen SEO

) }

{ logoUrl ? ( { title } ) : ( title ) }

{ pages.length > 0 ? ( ) : null } { loadErrorMessage ? (

{ __( 'We could not load the settings. Please try again.', 'airygen-seo' ) }

{ loadErrorMessage }

) : null } { tabs.length > 0 ? ( <>
{ tabs.map( ( tab ) => { const isActive = tab.name === activeTab; const Icon = tab.icon; return ( ); } ) }
{ showGlobalSave ? (
) : null }
) : null }
{ children }
{ notice ? (
) : null }
); }; export default AdminShell;