diff --git a/packages/documentation-framework/components/index.js b/packages/documentation-framework/components/index.js index 66c4808476..a2500ca050 100644 --- a/packages/documentation-framework/components/index.js +++ b/packages/documentation-framework/components/index.js @@ -10,6 +10,7 @@ export * from './sideNav/sideNav'; export * from './topNav/topNav'; export * from './link/link.jsx'; export * from './tableOfContents/tableOfContents'; +export * from './tableOfContents/tocPrototype'; export * from './inlineAlert/inlineAlert'; export * from './themeSelector/themeSelector'; export * from './feedbackButton/feedbackButton'; \ No newline at end of file diff --git a/packages/documentation-framework/components/tableOfContents/tableOfContents.css b/packages/documentation-framework/components/tableOfContents/tableOfContents.css index 8676e319e1..ef5bf4550d 100644 --- a/packages/documentation-framework/components/tableOfContents/tableOfContents.css +++ b/packages/documentation-framework/components/tableOfContents/tableOfContents.css @@ -1,3 +1,15 @@ +/** + * PROTOTYPE: Responsive TOC redesign styles + * See tableOfContents.js for breakpoint behavior notes. + */ + +/* —— Shared —— */ +.ws-toc-item .pf-m-link { + text-wrap: wrap; + text-align: left; +} + +/* —— XL+ / 2xl (≥1450px): sidebar JumpLinks (existing) —— */ .ws-toc { align-self: flex-start; position: sticky; @@ -16,32 +28,17 @@ } .ws-toc.pf-m-expanded { - box-shadow: var(--pf-t--global--box-shadow--sm--bottom) + box-shadow: var(--pf-t--global--box-shadow--sm--bottom); } .ws-toc .pf-v6-c-jump-links__toggle button { background-color: var(--pf-t--global--background--color--secondary--default); } -/* Mobile jumplinks */ -@media (max-width: 1449px) { - .ws-toc.pf-m-expanded .pf-v6-c-jump-links__main { - max-height: 65vh; - overflow-y:auto; - } - - .ws-toc .pf-v6-c-jump-links__header { - position: sticky; - top: 0; - z-index: 2; - } -} - .ws-toc .pf-v6-c-jump-links__main { scrollbar-width: none; } -/* Hide TOC scrollbar Chrome, Safari & Opera */ .ws-toc .pf-v6-c-jump-links__main::-webkit-scrollbar { display: none; } @@ -52,7 +49,6 @@ max-width: 320px; max-height: calc(100vh - 76px); overflow-y: auto; - /* Hide TOC scrollbar IE, Edge & Firefox */ -ms-overflow-style: none; scrollbar-width: none; padding: 0 0 0 var(--pf-t--global--spacer--2xl); @@ -66,15 +62,300 @@ .ws-toc.pf-m-expanded { box-shadow: none; } + + .ws-toc .pf-v6-c-jump-links__main { + margin-bottom: var(--jump-links-main-margin-bottom); + } } -.ws-toc-item .pf-m-link { - text-wrap: wrap; - text-align: left; +/* —— Sticky tabs: slot for Lg TOC menu toggle —— */ +.ws-sticky-nav-tabs { + display: flex; + align-items: center; + gap: var(--pf-t--global--spacer--sm); } -@media (min-width: 1450px) { - .ws-toc .pf-v6-c-jump-links__main { - margin-bottom: var(--jump-links-main-margin-bottom); +.ws-sticky-nav-tabs .pf-v6-c-tabs__list { + flex: 1 1 auto; + min-width: 0; +} + +.ws-toc-menu-slot { + display: none; + flex: 0 0 auto; + align-items: center; + margin-inline-start: auto; + /* No extra slot padding — tabs pf-m-page-insets (~24px) remains */ + padding-inline-end: 0; +} + +/* Show slot only at Lg (below 2xl sidebar breakpoint) */ +@media (min-width: 992px) and (max-width: 1449px) { + .ws-toc-menu-slot { + display: flex; + } +} + +/* —— Title row slot kept for layout hooks; unused while no-tabs uses fixed placement —— */ +.ws-toc-title-slot-item { + margin-inline-start: auto; + display: none; +} + +.ws-toc-title-slot { + display: none; +} + +/* —— Menu toggle (Lg plain / Md floating) —— */ +.ws-toc-menu-toggle { + flex-shrink: 0; + pointer-events: auto; +} + +.ws-toc-menu-toggle.pf-m-expanded, +.ws-toc-menu-toggle[aria-expanded='true'] { + background-color: var(--pf-t--global--background--color--action--plain--clicked); +} + +/* Floating toggle row (md with tabs — sticky below tabs, no content push) */ +.ws-toc-floating { + position: sticky; + top: calc(var(--ws-toc-sticky-offset, 0px) + var(--pf-t--global--spacer--sm)); + z-index: 200; + display: flex; + justify-content: flex-end; + align-self: stretch; + width: 100%; + /* Zero flow height so the toggle overlays content instead of pushing it down */ + height: 0; + margin-block-end: 0; + overflow: visible; + /* Avoid blocking clicks on page content behind the sticky row */ + pointer-events: none; +} + +/* + * No-tabs / overview: fixed on document.body with a constant right edge. + * top is set in JS to the page title (then below stuck toolbars on scroll). + */ +.ws-toc-floating-no-tabs { + --ws-toc-feedback-clearance: 2.75rem; + position: fixed; + right: var(--ws-toc-feedback-clearance); + left: auto; + width: auto; + margin: 0; + z-index: 300; +} + +.ws-toc-floating-no-tabs:not([style*='top']) { + visibility: hidden; +} + +.ws-toc-menu-toggle-floating { + background-color: var(--pf-t--global--background--color--floating--default, var(--pf-t--global--background--color--primary--default)); + box-shadow: var(--pf-t--global--box-shadow--md); + border-radius: var(--pf-t--global--border--radius--small); + /* Keep toggle at its natural MenuToggle size even when the sticky row has height: 0 */ + flex-shrink: 0; + transform: none; + min-width: var(--pf-t--global--spacer--2xl); + min-height: var(--pf-t--global--spacer--2xl); +} + +.ws-toc-menu-toggle-floating:hover { + background-color: var(--pf-t--global--background--color--floating--hover, var(--pf-t--global--background--color--primary--hover)); +} + +@media (max-width: 991px) { + .ws-toc-floating:not(.ws-toc-floating-no-tabs) { + --ws-toc-feedback-clearance: 2.75rem; + margin-inline-end: calc( + -1 * var(--pf-v6-c-page__main-section--PaddingInlineEnd, var(--pf-t--global--spacer--md)) + + var(--ws-toc-feedback-clearance) + ); + } + + .ws-toc-panel { + max-width: min(287px, calc(100vw - var(--ws-toc-feedback-clearance, 2.75rem) - var(--pf-t--global--spacer--lg))); + } +} + +/* —— Floating panel with jumplinks —— */ +.ws-toc-panel { + width: 287px; + max-width: min(287px, calc(100vw - var(--pf-t--global--spacer--2xl))); + max-height: min(347px, 65vh); + border-radius: var(--pf-t--global--border--radius--medium); + overflow: hidden; + pointer-events: auto; +} + +.ws-toc-panel .pf-v6-c-panel__main { + overflow-y: auto; + max-height: inherit; +} + +.ws-toc-panel-jumplinks { + width: 100%; + background: none; + box-shadow: none; + margin: 0; + padding: var(--pf-t--global--spacer--md); +} + +.ws-toc-panel-jumplinks .pf-v6-c-jump-links__main { + scrollbar-width: thin; +} + +.ws-toc-panel-jumplinks .pf-v6-c-jump-links__list { + padding-inline-start: 0; +} + +/* —— Pill overlay Drawer surface —— */ +.ws-toc-drawer.pf-v6-c-drawer, +.ws-toc-drawer { + /* Stay in normal page flow; do not create a nested scrollport */ + display: block; + height: auto; + min-height: 0; + /* PF drawer sets overflow-x:hidden which breaks position:sticky tabs */ + overflow: visible !important; +} + +/* + * Closed panel is collapsed out of flow (below), so we do not need + * overflow:hidden on __main (that broke sticky tabs / toolbars). + */ +.ws-toc-drawer > .pf-v6-c-drawer__main { + overflow: visible; + min-height: 0; +} + +.ws-toc-drawer .pf-v6-c-drawer__content { + overflow: visible; + flex-basis: 100%; + flex-shrink: 0; + min-width: 0; + min-height: 0; +} + +.ws-toc-drawer-content-body { + display: block; + min-height: 0; + height: auto; + padding: 0; +} + +/* Closed: keep panel out of layout so it never leaves a blank gutter */ +.ws-toc-drawer:not(.pf-m-expanded):not(.ws-toc-drawer-expanded) .ws-toc-drawer-panel.pf-v6-c-drawer__panel { + position: absolute; + inset-inline-end: 0; + width: 0; + min-width: 0; + max-width: 0; + flex-basis: 0 !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + overflow: hidden; + visibility: hidden; + pointer-events: none; +} + +/* + * Expanded: fixed within #ws-page-main (backdrop-filter containing block), + * inset by medium spacer on top, bottom, and inline-end. + */ +.ws-toc-drawer.pf-m-expanded .ws-toc-drawer-panel.pf-v6-c-drawer__panel, +.ws-toc-drawer-expanded .ws-toc-drawer-panel.pf-v6-c-drawer__panel { + --pf-v6-c-drawer--m-expanded__panel--inset: var(--pf-t--global--spacer--md); + --pf-v6-c-drawer__panel--MarginBlock: 0; + margin: 0 !important; + + position: fixed; + top: var(--pf-t--global--spacer--md); + inset-block-start: var(--pf-t--global--spacer--md); + bottom: var(--pf-t--global--spacer--md); + inset-block-end: var(--pf-t--global--spacer--md); + inset-inline-end: var(--pf-t--global--spacer--md); + height: auto; + max-height: none; + /* Let PF defaults drive size (50% / 450px at xl); width needed because we're fixed */ + width: var(--pf-v6-c-drawer__panel--FlexBasis); + min-width: var(--pf-v6-c-drawer__panel--MinWidth); + max-width: none; + /* Override PF slide transform so inset metrics control placement */ + transform: none !important; + z-index: 1000; + overflow: hidden; + display: flex; + flex-direction: column; + visibility: visible; +} + +/* <768px: overlay the full page-main area (md inset on all sides) */ +@media (max-width: 767px) { + .ws-toc-drawer.pf-m-expanded .ws-toc-drawer-panel.pf-v6-c-drawer__panel, + .ws-toc-drawer-expanded .ws-toc-drawer-panel.pf-v6-c-drawer__panel { + inset-inline-start: var(--pf-t--global--spacer--md); + width: auto; + min-width: 0; + max-width: none; + flex-basis: auto !important; } -} \ No newline at end of file +} + +.ws-toc-drawer-panel-body { + padding-block-start: 0; + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; +} + +.ws-toc-drawer-jumplinks { + width: 100%; + background: none; + box-shadow: none; + margin: 0; + padding: var(--pf-t--global--spacer--md); +} + +.ws-toc-drawer-jumplinks .pf-v6-c-jump-links__main { + scrollbar-width: thin; +} + +.ws-toc-drawer-jumplinks .pf-v6-c-jump-links__list { + padding-inline-start: 0; +} + +/* Keep floating TOC toggle under the open drawer */ +.ws-toc-menu-toggle-under-drawer { + z-index: 1 !important; + opacity: 0; + pointer-events: none; +} + +.ws-toc-floating-no-tabs.ws-toc-menu-toggle-under-drawer { + z-index: 1 !important; +} + +/* —— Prototype surface switcher (Panel | Drawer) —— */ +.ws-toc-surface-switcher { + position: fixed; + inset-block-end: var(--pf-t--global--spacer--md); + inset-inline-start: var(--pf-t--global--spacer--md); + z-index: 400; + display: flex; + flex-direction: column; + gap: var(--pf-t--global--spacer--xs); + padding: var(--pf-t--global--spacer--sm); + background-color: var(--pf-t--global--background--color--floating--default, var(--pf-t--global--background--color--primary--default)); + box-shadow: var(--pf-t--global--box-shadow--md); + border-radius: var(--pf-t--global--border--radius--small); +} + +.ws-toc-surface-switcher-label { + font-size: var(--pf-t--global--font--size--body--sm); + color: var(--pf-t--global--text--color--subtle); +} diff --git a/packages/documentation-framework/components/tableOfContents/tableOfContents.js b/packages/documentation-framework/components/tableOfContents/tableOfContents.js index 6b125a4601..e78c338cfd 100644 --- a/packages/documentation-framework/components/tableOfContents/tableOfContents.js +++ b/packages/documentation-framework/components/tableOfContents/tableOfContents.js @@ -1,34 +1,240 @@ +/** + * PROTOTYPE: Responsive TOC redesign + * Branch: prototype/responsive-toc + * + * Breakpoints (aligned with Figma + PF tokens): + * - XL+ / 2xl (≥1450px): expanded sidebar JumpLinks (unchanged) + * - Lg (992–1449px): plain icon MenuToggle in sticky tabs → Panel or pill Drawer + * - Md and below (<992px): floating sticky MenuToggle → Panel or pill Drawer + * - With tabs: floats below sticky tabs + * - Without tabs (overview pages): fixed to viewport right (aligned with feedback); + * top tracks title, then pins below sticky toolbars — no horizontal jump on scroll + * + * Surface comparison (localStorage ws-toc-surface): Panel (default) | Drawer (pill overlay) + * + * Not production-ready — for design-system feedback only. + */ import React from 'react'; -import { JumpLinks, JumpLinksItem, JumpLinksList } from '@patternfly/react-core'; +import { createPortal } from 'react-dom'; +import { + JumpLinks, + JumpLinksItem, + JumpLinksList, + MenuToggle, + Panel, + PanelMain, + Popper, + Tooltip, + Icon +} from '@patternfly/react-core'; +import ListIcon from '@patternfly/react-icons/dist/esm/icons/list-icon'; import { trackEvent } from '../../helpers'; +import { TOC_SURFACE, useTocPrototype } from './tocPrototype'; + +const TOC_SIDEBAR_MIN = 1450; // PF 2xl — full sidebar jumplinks +const TOC_LG_MIN = 992; // PF lg (62rem) — toggle in sticky tabs + +const getMenuSlot = () => + typeof document !== 'undefined' ? document.getElementById('ws-toc-menu-slot') : null; + +const getStickyToolbarHeight = () => { + if (typeof document === 'undefined') { + return 0; + } + const toolbars = document.querySelectorAll('.pf-v6-c-toolbar.pf-m-sticky'); + let height = 0; + toolbars.forEach((toolbar) => { + height += toolbar.offsetHeight; + }); + return height; +}; + +const getTocMode = (width, hasTabsSlot) => { + if (width >= TOC_SIDEBAR_MIN) { + return 'sidebar'; + } + if (width >= TOC_LG_MIN && hasTabsSlot) { + return 'tabs'; + } + return 'floating'; +}; export const TableOfContents = ({ items }) => { - // Used to recalculate JumpLinks offset if screen size changes - const [width, setWidth] = React.useState(window.innerWidth); - // Used to calculate where TOC is positioned in smaller viewports + const { surface, isDrawerOpen, setIsDrawerOpen, setDrawerContent } = useTocPrototype(); + const isDrawerSurface = surface === TOC_SURFACE.DRAWER; + + const [width, setWidth] = React.useState(() => + typeof window !== 'undefined' ? window.innerWidth : TOC_SIDEBAR_MIN + ); const [stickyNavHeight, setStickyNavHeight] = React.useState(0); + const [stickyToolbarHeight, setStickyToolbarHeight] = React.useState(0); + const [menuSlotEl, setMenuSlotEl] = React.useState(getMenuSlot); + // Viewport top (px) for no-tabs fixed toggle — only vertical tracking, right stays constant + const [noTabsFixedTop, setNoTabsFixedTop] = React.useState(null); + const [isPanelOpen, setIsPanelOpen] = React.useState(false); + const toggleRef = React.useRef(null); + const noTabsFloatingRef = React.useRef(null); + + const hasTabsSlot = Boolean(menuSlotEl); + const mode = getTocMode(width, hasTabsSlot); + + const isOpen = isDrawerSurface ? isDrawerOpen : isPanelOpen; + const setIsOpen = isDrawerSurface ? setIsDrawerOpen : setIsPanelOpen; + + // Sticky offset: tabs (when present) + sticky toolbars (overview galleries, etc.) + const stickyOffset = stickyNavHeight + stickyToolbarHeight; + + const syncLayout = React.useCallback(() => { + const stickyNav = document.getElementById('ws-sticky-nav-tabs'); + setStickyNavHeight(stickyNav ? stickyNav.offsetHeight : 0); + setStickyToolbarHeight(getStickyToolbarHeight()); + setMenuSlotEl(getMenuSlot()); + }, []); + + /** + * No-tabs pages: fixed toggle portaled to document.body (avoids transformed + * ancestors breaking position:fixed). Constant `right`; `top` tracks the title + * while visible, then pins below stuck sticky toolbars. + */ + const syncNoTabsFixedTop = React.useCallback(() => { + if (hasTabsSlot) { + setNoTabsFixedTop(null); + return; + } + const scrollEl = document.getElementById('ws-page-main'); + const titleEl = document.getElementById('ws-page-title'); + if (!scrollEl) { + return; + } + + const mainTop = scrollEl.getBoundingClientRect().top; + const spacer = 8; + const toggleH = noTabsFloatingRef.current?.offsetHeight || 37; + + // While the title is visible, top-align with the title + if (titleEl) { + const titleRect = titleEl.getBoundingClientRect(); + if (titleRect.bottom > mainTop + spacer) { + setNoTabsFixedTop(Math.max(mainTop + spacer, titleRect.top + (titleRect.height - toggleH) / 2)); + return; + } + } + + let stuckToolbarHeight = 0; + document.querySelectorAll('.pf-v6-c-toolbar.pf-m-sticky').forEach((toolbar) => { + const rect = toolbar.getBoundingClientRect(); + if (rect.top <= mainTop + 1) { + stuckToolbarHeight += toolbar.offsetHeight; + } + }); + setStickyToolbarHeight(stuckToolbarHeight); + setNoTabsFixedTop(mainTop + stuckToolbarHeight + spacer); + }, [hasTabsSlot]); React.useEffect(() => { - if (document.getElementById('ws-sticky-nav-tabs')) { - setStickyNavHeight(document.getElementById('ws-sticky-nav-tabs').offsetHeight); + syncLayout(); + }, [syncLayout, mode, width]); + + React.useEffect(() => { + syncNoTabsFixedTop(); + const scrollEl = document.getElementById('ws-page-main'); + if (!scrollEl) { + return undefined; } - }, []); + const onScroll = () => { + syncNoTabsFixedTop(); + }; + scrollEl.addEventListener('scroll', onScroll, { passive: true }); - const updateWidth = () => { - const { innerWidth } = window; - innerWidth !== width && setWidth(innerWidth); - }; + const observer = + typeof MutationObserver !== 'undefined' + ? new MutationObserver(() => { + syncLayout(); + syncNoTabsFixedTop(); + }) + : null; + observer?.observe(scrollEl, { childList: true, subtree: true }); + + return () => { + scrollEl.removeEventListener('scroll', onScroll); + observer?.disconnect(); + }; + }, [syncLayout, syncNoTabsFixedTop]); + + React.useEffect(() => { + const onResize = () => { + const nextWidth = window.innerWidth; + setWidth((prev) => { + if (prev !== nextWidth) { + setIsOpen(false); + } + return nextWidth; + }); + syncLayout(); + syncNoTabsFixedTop(); + }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [syncLayout, syncNoTabsFixedTop, setIsOpen]); + + React.useLayoutEffect(() => { + if (!hasTabsSlot && mode === 'floating') { + syncNoTabsFixedTop(); + } + }, [hasTabsSlot, mode, syncNoTabsFixedTop]); + + React.useEffect(() => { + if (mode === 'sidebar') { + setIsPanelOpen(false); + setIsDrawerOpen(false); + } + }, [mode, setIsDrawerOpen]); + + // Close panel when switching away from panel surface + React.useEffect(() => { + if (isDrawerSurface) { + setIsPanelOpen(false); + } + }, [isDrawerSurface]); + + // Escape closes drawer surface (panel handled by Popper) + React.useEffect(() => { + if (!isDrawerSurface || !isDrawerOpen) { + return undefined; + } + const onKeyDown = (event) => { + if (event.key === 'Escape') { + setIsDrawerOpen(false); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isDrawerSurface, isDrawerOpen, setIsDrawerOpen]); const getOffset = () => { - if (width >= 1450) { + if (width >= TOC_SIDEBAR_MIN) { return 88 + stickyNavHeight; - } else if (width >= 768) { - return 142 + stickyNavHeight; - } else { - return 190 + stickyNavHeight; } + if (width >= 768) { + return 142 + stickyOffset; + } + return 190 + stickyOffset; }; - + + const closeSurface = () => setIsOpen(false); + + const handleJumpClick = (id) => { + trackEvent('jump_link_click', 'click_event', id.toUpperCase()); + closeSurface(); + }; + + const updateWidth = () => { + const { innerWidth } = window; + if (innerWidth !== width) { + setWidth(innerWidth); + } + }; + let jumpLinksItems = []; let wasSublistRendered = false; @@ -45,7 +251,7 @@ export const TableOfContents = ({ items }) => { className="ws-toc-item" onKeyDown={updateWidth} onMouseDown={updateWidth} - onClick={() => trackEvent('jump_link_click', 'click_event', curItem.id.toUpperCase())} + onClick={() => handleJumpClick(curItem.id)} > {curItem.text} @@ -56,30 +262,29 @@ export const TableOfContents = ({ items }) => { }; const renderJumpLinksItems = () => { + jumpLinksItems = []; + wasSublistRendered = false; items.forEach((item, index) => { - let nextItem = items[index + 1]; - // Don't render empty for an array of sublist items + const nextItem = items[index + 1]; if (wasSublistRendered) { wasSublistRendered = false; return; } if (!Array.isArray(nextItem) && Array.isArray(item)) { - { - item.map((curItem) => - jumpLinksItems.push( - trackEvent('jump_link_click', 'click_event', curItem.id.toUpperCase())} - > - {curItem.text} - - ) - ); - } + item.forEach((curItem) => + jumpLinksItems.push( + handleJumpClick(curItem.id)} + > + {curItem.text} + + ) + ); } else { jumpLinksItems.push( { className="ws-toc-item" onKeyDown={updateWidth} onMouseDown={updateWidth} - onClick={() => trackEvent('jump_link_click', 'click_event', item.id.toUpperCase())} + onClick={() => handleJumpClick(item.id)} > {Array.isArray(nextItem) ? renderSublist(item, nextItem) : item.text} @@ -98,19 +303,171 @@ export const TableOfContents = ({ items }) => { return jumpLinksItems; }; - return ( + const jumpLinksChildren = renderJumpLinksItems(); + const offset = getOffset(); + + const drawerJumpLinks = ( - {renderJumpLinksItems()} + {jumpLinksChildren} ); + + // Register jumplinks into the page-level pill Drawer when using drawer surface + React.useEffect(() => { + if (mode === 'sidebar' || !isDrawerSurface) { + setDrawerContent(null); + return undefined; + } + setDrawerContent(drawerJumpLinks); + return () => setDrawerContent(null); + // drawerJumpLinks is recreated each render; depend on inputs that change its content + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, isDrawerSurface, items, offset, setDrawerContent]); + + // — XL+ / 2xl: existing sidebar JumpLinks — + if (mode === 'sidebar') { + return ( + + {jumpLinksChildren} + + ); + } + + // — Lg / Md: MenuToggle opens Panel (Popper) or page-level pill Drawer — + const toggle = ( + setIsOpen((open) => !open)} + aria-label="Table of contents" + aria-haspopup="dialog" + icon={ + + + } + /> + ); + + const panel = ( + + + + {jumpLinksChildren} + + + + ); + + const menu = isDrawerSurface ? ( + <> + + {toggle} + + ) : ( + <> + + document.getElementById('ws-page-main') || document.body} + onDocumentClick={(event, triggerElement, popperElement) => { + if ( + isPanelOpen && + !triggerElement?.contains(event.target) && + !popperElement?.contains(event.target) + ) { + setIsPanelOpen(false); + } + }} + onDocumentKeyDown={(event) => { + if (event.key === 'Escape') { + setIsPanelOpen(false); + } + }} + /> + + ); + + if (mode === 'tabs') { + if (!menuSlotEl) { + return null; + } + return createPortal(menu, menuSlotEl); + } + + // No-tabs: portal to body so position:fixed is viewport-relative (not trapped by page transforms) + if (!hasTabsSlot) { + if (typeof document === 'undefined') { + return null; + } + return createPortal( +
+ {menu} +
, + document.body + ); + } + + // Floating below sticky tabs (md) + return ( +
+ {menu} +
+ ); }; diff --git a/packages/documentation-framework/components/tableOfContents/tocPrototype.js b/packages/documentation-framework/components/tableOfContents/tocPrototype.js new file mode 100644 index 0000000000..2dae4fde94 --- /dev/null +++ b/packages/documentation-framework/components/tableOfContents/tocPrototype.js @@ -0,0 +1,141 @@ +/** + * PROTOTYPE: TOC surface comparison (Panel vs pill overlay Drawer) + * Persists surface choice in localStorage for design/dev review. + */ +import React from 'react'; +import { + Drawer, + DrawerContent, + DrawerContentBody, + DrawerPanelContent, + DrawerHead, + DrawerActions, + DrawerCloseButton, + DrawerPanelBody, + ToggleGroup, + ToggleGroupItem +} from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; + +export const TOC_SURFACE = { + PANEL: 'panel', + DRAWER: 'drawer' +}; + +const STORAGE_KEY = 'ws-toc-surface'; + +const TocPrototypeContext = React.createContext({ + surface: TOC_SURFACE.PANEL, + setSurface: () => {}, + isDrawerOpen: false, + setIsDrawerOpen: () => {}, + drawerContent: null, + setDrawerContent: () => {} +}); + +const readStoredSurface = () => { + if (typeof window === 'undefined') { + return TOC_SURFACE.PANEL; + } + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === TOC_SURFACE.DRAWER || stored === TOC_SURFACE.PANEL) { + return stored; + } + } catch { + // ignore + } + return TOC_SURFACE.PANEL; +}; + +export const TocPrototypeProvider = ({ children }) => { + const [surface, setSurfaceState] = React.useState(readStoredSurface); + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + const [drawerContent, setDrawerContent] = React.useState(null); + + const setSurface = React.useCallback((next) => { + setSurfaceState(next); + setIsDrawerOpen(false); + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // ignore + } + }, []); + + const value = React.useMemo( + () => ({ + surface, + setSurface, + isDrawerOpen, + setIsDrawerOpen, + drawerContent, + setDrawerContent + }), + [surface, setSurface, isDrawerOpen, drawerContent] + ); + + return {children}; +}; + +export const useTocPrototype = () => React.useContext(TocPrototypeContext); + +/** Wraps page content in a pill overlay Drawer for the Drawer surface option. */ +export const TocDrawerShell = ({ children }) => { + const { surface, isDrawerOpen, setIsDrawerOpen, drawerContent } = useTocPrototype(); + const isDrawerSurface = surface === TOC_SURFACE.DRAWER; + const drawerExpanded = isDrawerSurface && isDrawerOpen; + + const panelContent = ( + + + Table of contents + + setIsDrawerOpen(false)} /> + + + + {drawerContent} + + + ); + + return ( + {}} + > + + {children} + + + ); +}; + +/** Fixed switcher so reviewers can compare Panel vs Drawer without code changes. */ +export const TocSurfaceSwitcher = () => { + const { surface, setSurface } = useTocPrototype(); + + return ( +
+ TOC surface + + setSurface(TOC_SURFACE.PANEL)} + /> + setSurface(TOC_SURFACE.DRAWER)} + /> + +
+ ); +}; diff --git a/packages/documentation-framework/templates/mdx.css b/packages/documentation-framework/templates/mdx.css index 2f40c92233..31c5bb67a5 100644 --- a/packages/documentation-framework/templates/mdx.css +++ b/packages/documentation-framework/templates/mdx.css @@ -26,8 +26,16 @@ p.pf-v6-c-content--p.ws-p { z-index: var(--pf-t--global--z-index--2xl); } +/* PROTOTYPE: allow content to shrink so Md TOC floating behavior can be reviewed */ .ws-example-page-wrapper { - min-width: 825px; + min-width: 0; max-width: 1200px; flex-grow: 1; + width: 100%; +} + +@media (min-width: 1450px) { + .ws-example-page-wrapper { + min-width: 825px; + } } diff --git a/packages/documentation-framework/templates/mdx.js b/packages/documentation-framework/templates/mdx.js index ecb1d3cde8..0c193e36a1 100644 --- a/packages/documentation-framework/templates/mdx.js +++ b/packages/documentation-framework/templates/mdx.js @@ -19,6 +19,11 @@ import { css } from '@patternfly/react-styles'; import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon'; import { Router, useLocation } from '@reach/router'; import { CSSVariables, PropsTable, TableOfContents, Link, AutoLinkHeader, InlineAlert, FeedbackButton } from '../components'; +import { + TocPrototypeProvider, + TocDrawerShell, + TocSurfaceSwitcher +} from '../components/tableOfContents/tocPrototype'; import { capitalize, getTitle, slugger, trackEvent } from '../helpers'; import './mdx.css'; import { convertToReactComponent } from '@patternfly/ast-helpers'; @@ -30,7 +35,8 @@ const StickyTabs = React.memo(({ sourceKeys, tabNames, activeSource, path }) => return ( -
+ {/* PROTOTYPE: flex row hosts source tabs + Lg TOC menu slot (#ws-toc-menu-slot) */} +
    {sourceKeys.map((source, index) => (
  • ))}
+
); @@ -288,70 +295,77 @@ export const MDXTemplate = ({ title, sources = [], path, id, componentsData }) = const showTabs = (!isSinglePage && !hideTabName) || isComponent || isUtility || isPattern; return ( - - - - - - - - {title} - - - - - {isDeprecated && ( - - - - - - )} - {isDemo && ( - - - - - - )} - {isBeta && ( - - - - - - )} - - - - {isComponent && summary && } - - - {showTabs && ( - - )} - - {isSinglePage && } - {!isSinglePage && ( - - {sources - .map((source, index) => { - source.index = index; - return source; - }) - .map(MDXChildTemplate)} - + + + + + + + + + {title} + + + + + {isDeprecated && ( + + + + + + )} + {isDemo && ( + + + + + + )} + {isBeta && ( + + + + + + )} + + + {/* PROTOTYPE: title-row slot (unused while no-tabs uses fixed placement) */} + +
+ + + {isComponent && summary && } + + + {showTabs && ( + )} - - - {hasFeedbackButton && } - - + + {isSinglePage && } + {!isSinglePage && ( + + {sources + .map((source, index) => { + source.index = index; + return source; + }) + .map(MDXChildTemplate)} + + )} + + + {hasFeedbackButton && } + + + + ); };