import React, { useState, useRef, useEffect, useCallback } from 'react'; import styled, { css } from 'styled-components'; import type { JSX } from 'react'; import { Tab } from '@redocly/theme/markdoc/components/Tabs/Tab'; import { TabItemProps, TabsSize } from '@redocly/theme/markdoc/components/Tabs/Tabs'; import { Dropdown } from '@redocly/theme/components/Dropdown/Dropdown'; import { DropdownMenu } from '@redocly/theme/components/Dropdown/DropdownMenu'; import { DropdownMenuItem } from '@redocly/theme/components/Dropdown/DropdownMenuItem'; import { Button } from '@redocly/theme/components/Button/Button'; import { useTabs } from '@redocly/theme/core/hooks'; import { getTabId } from '@redocly/theme/core/utils'; type TabListProps = { childrenArray: React.ReactElement[]; size: TabsSize; activeTab: string; onTabChange: (tab: string) => void; containerRef: React.RefObject; onReadyChange?: (isReady: boolean) => void; }; type UseHighlightBarAnimationProps = { childrenArray: React.ReactElement[]; activeTab: string; tabsContainerRef: React.RefObject; visibleTabs: number[]; overflowTabs: number[]; }; /** * Calculates optimal dropdown position relative to viewport to ensure visibility. * Positions below the button by default, but moves above if insufficient space. * Adjusts horizontal position to prevent overflow off screen edges. */ const calculateDropdownPosition = ( buttonRect: DOMRect, dropdownRect: DOMRect, ): { top: number; left: number } => { const gap = 4; const margin = 16; const spaceBelow = window.innerHeight - buttonRect.bottom; const spaceAbove = buttonRect.top; // Position below button, or above if dropdown doesn't fit below const top = spaceBelow < dropdownRect.height + gap && spaceAbove > spaceBelow ? buttonRect.top - gap : buttonRect.bottom + gap; // Align with button left edge, adjust if overflows screen const idealLeft = buttonRect.left; const rightEdge = idealLeft + dropdownRect.width; const overflowsRight = rightEdge > window.innerWidth - margin; const left = overflowsRight ? window.innerWidth - dropdownRect.width - margin : Math.max(margin, idealLeft); return { top, left }; }; /** * Manages dropdown positioning and updates on scroll/resize events for TabList. */ const useDropdownPosition = ( hasOverflow: boolean, dropdownRef: React.RefObject, ) => { const [dropdownPosition, setDropdownPosition] = useState<{ top?: number; left?: number }>({}); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const updateDropdownPosition = useCallback(() => { if (!dropdownRef.current) return; const button = dropdownRef.current.querySelector('button'); const dropdownMenu = dropdownRef.current.querySelector('div:last-child'); if (!button || !dropdownMenu) return; const buttonRect = button.getBoundingClientRect(); const dropdownRect = (dropdownMenu as HTMLElement).getBoundingClientRect(); const position = calculateDropdownPosition(buttonRect, dropdownRect); setDropdownPosition(position); }, [dropdownRef]); // Track when dropdown menu appears and recalculate position useEffect(() => { if (!hasOverflow || !isDropdownOpen || !dropdownRef.current) return; const dropdownMenu = dropdownRef.current.querySelector('div:last-child') as HTMLElement; if (!dropdownMenu) return; // ResizeObserver tracks both initial render and size changes const resizeObserver = new ResizeObserver(() => { updateDropdownPosition(); }); resizeObserver.observe(dropdownMenu); return () => resizeObserver.disconnect(); }, [hasOverflow, isDropdownOpen, dropdownRef, updateDropdownPosition]); // Update position on scroll/resize useEffect(() => { if (!hasOverflow || !isDropdownOpen) return; window.addEventListener('scroll', updateDropdownPosition, true); window.addEventListener('resize', updateDropdownPosition); return () => { window.removeEventListener('scroll', updateDropdownPosition, true); window.removeEventListener('resize', updateDropdownPosition); }; }, [hasOverflow, isDropdownOpen, updateDropdownPosition]); return { dropdownPosition, isDropdownOpen, setIsDropdownOpen, setDropdownPosition, updateDropdownPosition, }; }; const renderTab = ( child: React.ReactElement, index: number, size: TabsSize, setTabRef: (element: HTMLButtonElement | null, index: number) => void, handleKeyboard: (event: React.KeyboardEvent, index: number) => void, onTabClick: (labelOrIndex: string | number) => void, ) => { const { label, icon } = child.props; const tabId = getTabId(label, index); return ( setTabRef(el, index)} onKeyDown={(event) => handleKeyboard(event, index)} onClick={() => { child.props.onClick?.(); onTabClick(label); }} /> ); }; export function TabList({ childrenArray, size, activeTab, onTabChange, containerRef, onReadyChange, }: TabListProps): JSX.Element { const dropdownRef = useRef(null); const totalTabs = childrenArray.length; const { overflowTabs, visibleTabs, handleKeyboard, onTabClick, setTabRef, isReady } = useTabs({ activeTab, onTabChange, containerRef, totalTabs, }); useEffect(() => { onReadyChange?.(isReady); }, [isReady, onReadyChange]); const { highlightStyle } = useHighlightBarAnimation({ activeTab, childrenArray, overflowTabs, tabsContainerRef: containerRef, visibleTabs, }); const hasOverflow = overflowTabs.length > 0; const isMoreActive = hasOverflow && overflowTabs.some((i) => childrenArray[i] && activeTab === childrenArray[i].props.label); // Show as selector when no visible tabs (all tabs in dropdown) const showAsSelector = visibleTabs.length === 0 && hasOverflow; const { dropdownPosition, setIsDropdownOpen, setDropdownPosition } = useDropdownPosition( hasOverflow, dropdownRef, ); return (
{childrenArray.map((child, index) => { // Show all tabs before ready (for measurement), then only visible ones const shouldRender = !isReady || visibleTabs.includes(index); if (!shouldRender) return null; return renderTab(child, index, size, setTabRef, handleKeyboard, onTabClick); })} {hasOverflow && ( { setIsDropdownOpen(true); }} > {showAsSelector ? {activeTab} : 'More'} } alignment="start" withArrow onClose={() => { setIsDropdownOpen(false); setDropdownPosition({}); }} > {overflowTabs.map((index) => { const child = childrenArray[index]; if (!child) return null; const { label } = child.props; const tabId = getTabId(label, index); return ( { child.props.onClick?.(); onTabClick(index); }} disabled={child.props.disable} > {label} ); })} )} ); } const useHighlightBarAnimation = (props: UseHighlightBarAnimationProps) => { const { childrenArray, activeTab, tabsContainerRef, visibleTabs, overflowTabs } = props; const [highlightStyle, setHighlightStyle] = React.useState<{ left: number; width: number }>({ left: 0, width: 0, }); useEffect(() => { const activeIndex = childrenArray.findIndex((child) => child.props.label === activeTab); const container = tabsContainerRef.current; if (!container || activeIndex === -1) { setHighlightStyle({ left: 0, width: 0 }); return; } // Remove active class from all tabs first container.querySelectorAll('[data-label]').forEach((el) => { el.classList.remove('active'); }); // Check if active tab is in overflow first if (overflowTabs.includes(activeIndex)) { const moreButton = container.querySelector('button'); if (!moreButton) return; const moreButtonRect = moreButton.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); setHighlightStyle({ left: moreButtonRect.left - containerRect.left, width: moreButtonRect.width, }); return; } // Active tab is visible, find its element const activeTabElement: HTMLElement | null = container.querySelector( `[data-label="${activeTab}"]`, ); if (!activeTabElement) return; const { offsetLeft, offsetWidth } = activeTabElement; if (visibleTabs.includes(activeIndex)) { activeTabElement.classList.add('active'); setHighlightStyle({ left: offsetLeft, width: offsetWidth }); return; } }, [activeTab, childrenArray, visibleTabs, overflowTabs, tabsContainerRef]); return { highlightStyle }; }; export const TabListContainer = styled.ul` position: relative; display: flex; gap: var(--md-tabs-gap); width: 100%; min-width: 0; &::before { content: ''; position: absolute; inset: 0; border: var(--md-tabs-border); border-width: var(--md-tabs-border-width); pointer-events: none; } && { padding: var(--md-tabs-padding); margin: 0; & > li { margin-bottom: 0; flex-shrink: 0; &.dropdown-tab { flex-shrink: 1; min-width: 0; max-width: 100%; } } } `; export const TabItem = styled.li<{ active?: boolean; size: TabsSize; tabIndex?: number }>` display: inline-flex; list-style: none; cursor: pointer; align-items: center; padding: var(--md-tabs-tab-wrapper-padding); z-index: var(--z-index-surface); ${({ active, size }) => active ? css` border: solid var(--md-tabs-active-tab-border-color); border-width: var(--md-tabs-${size}-active-tab-border-width); ` : css` border: solid var(--md-tabs-hover-tab-border-color); border-width: var(--md-tabs-${size}-hover-tab-border-width); &:hover { border: solid var(--md-tabs-hover-tab-border-color); border-width: var(--md-tabs-${size}-hover-tab-border-width); } `} div > div > ul { padding-left: var(--spacing-unit); } &:focus-visible { outline: none; position: relative; &::after { content: ''; position: absolute; top: -2px; right: -4px; bottom: -2px; left: -4px; border: 1px solid var(--button-border-color-focused); border-radius: 6px; pointer-events: none; } } `; const DropdownWrapper = styled.div.attrs<{ $top?: number; $left?: number }>((props) => ({ style: { ...(props.$top !== undefined && { '--dropdown-top': `${props.$top}px` }), ...(props.$left !== undefined && { '--dropdown-left': `${props.$left}px` }), }, }))<{ $top?: number; $left?: number }>` position: static; z-index: var(--z-index-raised); width: 100%; min-width: 0; `; const FixedPositionDropdown = styled(Dropdown)` position: static; width: 100%; min-width: 0; > div:first-child { width: 100%; min-width: 0; } > div:last-child { position: fixed; top: var(--dropdown-top, 0); left: var(--dropdown-left, 0); right: auto; bottom: auto; transform: none; padding-top: 0; max-width: min(400px, calc(100vw - 32px)); max-height: calc(100vh - var(--dropdown-top, 0) - 32px); overflow-y: auto; z-index: var(--z-index-raised); ul { li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } } `; const HighlightBar = styled.div<{ size: TabsSize }>` position: absolute; top: 0; bottom: 0; border: solid var(--md-tabs-active-tab-border-color); border-width: var(--md-tabs-${({ size }) => size}-active-tab-border-width); transition: left 300ms ease-in-out, width 300ms ease-in-out; z-index: 0; padding: var(--md-tabs-tab-wrapper-padding); & > div { width: 100%; height: 100%; background-color: var(--md-tabs-active-tab-bg-color); border-radius: var(--md-tabs-${({ size }) => size}-active-tab-border-radius); } `; const TabButtonText = styled.span` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; `; export const TabButtonLink = styled(Button)` color: var(--md-tabs-tab-text-color); font-family: var(--md-tabs-tab-font-family); font-style: var(--md-tabs-tab-font-style); background-color: var(--md-tabs-tab-bg-color); width: 100%; transition: background-color 300ms ease-in-out, color 300ms ease-in-out, padding 300ms ease-in-out, border-radius 300ms ease-in-out; ${({ size }) => size && css` padding: var(--md-tabs-${size}-tab-padding); font-size: var(--md-tabs-${size}-tab-font-size); font-weight: var(--md-tabs-${size}-tab-font-weight); line-height: var(--md-tabs-${size}-tab-line-height); border-radius: var(--md-tabs-${size}-tab-border-radius); `} &.active { color: var(--md-tabs-active-tab-text-color); font-family: var(--md-tabs-active-tab-font-family); font-style: var(--md-tabs-active-tab-font-style); font-size: var(--md-tabs-${({ size }) => size}-active-tab-font-size); font-weight: var(--md-tabs-${({ size }) => size}-active-tab-font-weight); line-height: var(--md-tabs-${({ size }) => size}-active-tab-line-height); background-color: var(--md-tabs-active-tab-bg-color); border-radius: var(--md-tabs-${({ size }) => size}-active-tab-border-radius); padding: var(--md-tabs-${({ size }) => size}-active-tab-padding); } &:hover { color: var(--md-tabs-hover-tab-text-color); font-family: var(--md-tabs-hover-tab-font-family); font-style: var(--md-tabs-hover-tab-font-style); font-size: var(--md-tabs-${({ size }) => size}-hover-tab-font-size); font-weight: var(--md-tabs-${({ size }) => size}-hover-tab-font-weight); line-height: var(--md-tabs-${({ size }) => size}-hover-tab-line-height); background-color: var(--md-tabs-hover-tab-bg-color); border-radius: var(--md-tabs-${({ size }) => size}-hover-tab-border-radius); padding: var(--md-tabs-${({ size }) => size}-hover-tab-padding); } ${({ disabled }) => disabled && css` color: var(--md-tabs-tab-text-disabled-color); cursor: not-allowed; &:hover { color: var(--md-tabs-tab-text-disabled-color); background-color: transparent; } `} svg { flex-shrink: 0; } `;