diff --git a/package.json b/package.json index f0395845..fa3a380f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex.docs", "license": "Apache-2.0", - "version": "2.2.4", + "version": "2.3.0", "type": "module", "bin": { "codex.docs": "dist/backend/app.js" diff --git a/src/backend/build-static.ts b/src/backend/build-static.ts index ca0894aa..019c6397 100644 --- a/src/backend/build-static.ts +++ b/src/backend/build-static.ts @@ -105,8 +105,7 @@ export default async function buildStatic(): Promise { const parentIdOfRootPages = '0' as EntityId; const previousPage = await PagesFlatArray.getPageBefore(pageId); const nextPage = await PagesFlatArray.getPageAfter(pageId); - const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder, 2); - + const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder); const result = await renderTemplate('./views/pages/page.twig', { page, pageParent, diff --git a/src/backend/controllers/pages.ts b/src/backend/controllers/pages.ts index 89f133ba..73881d14 100644 --- a/src/backend/controllers/pages.ts +++ b/src/backend/controllers/pages.ts @@ -87,13 +87,45 @@ class Pages { } /** - * Group all pages by their parents - * If the pageId is passed, it excludes passed page from result pages + * Depth in parent chain: 0 for root pages, +1 per ancestor below root (for select indent). + */ + private static computePageDepth(page: Page, pagesMap: Map): number { + let depth = 0; + let cur: Page | undefined = page; + + while (cur?._parent && !isEqualIds(cur._parent, '0' as EntityId)) { + depth++; + cur = pagesMap.get(cur._parent.toString()); + } + + return depth; + } + + /** + * Ordered pages for the parent ` {% for firstLevelPage in menu %} -
- -
- - {{ firstLevelPage.title | striptags }} - - {% if firstLevelPage.children is not empty %} - - {% endif %} -
-
- {% if firstLevelPage.children is not empty %} - - {% endif %} -
+ {% include 'components/sidebar-section.twig' with { node: firstLevelPage, nested: false } %} {% endfor %} diff --git a/src/frontend/js/classes/sidebar-filter.js b/src/frontend/js/classes/sidebar-filter.js index 7087ccde..245d7d37 100644 --- a/src/frontend/js/classes/sidebar-filter.js +++ b/src/frontend/js/classes/sidebar-filter.js @@ -23,14 +23,12 @@ export default class SidebarFilter { */ static get CSS() { return { + section: 'docs-sidebar__section', sectionHidden: 'docs-sidebar__section--hidden', sectionTitle: 'docs-sidebar__section-title', sectionTitleSelected: 'docs-sidebar__section-title--selected', sectionTitleActive: 'docs-sidebar__section-title--active', sectionList: 'docs-sidebar__section-list', - sectionListItem: 'docs-sidebar__section-list-item', - sectionListItemWrapperHidden: 'docs-sidebar__section-list-item-wrapper--hidden', - sectionListItemSlelected: 'docs-sidebar__section-list-item--selected', sidebarSearchWrapper: 'docs-sidebar__search-wrapper', }; } @@ -43,7 +41,7 @@ export default class SidebarFilter { * Stores refs to HTML elements needed for sidebar filter to work. */ this.sidebar = null; - this.sections = []; + this.rootSections = []; this.sidebarContent = null; this.search = null; this.searchResults = []; @@ -53,14 +51,14 @@ export default class SidebarFilter { /** * Initialize sidebar filter. * - * @param {HTMLElement[]} sections - Array of sections. + * @param {HTMLElement[]} rootSections - Top-level sections (direct children of sidebar content). * @param {HTMLElement} sidebarContent - Sidebar content. * @param {HTMLElement} search - Search input. * @param {Function} setSectionCollapsed - Function to set section collapsed. */ - init(sections, sidebarContent, search, setSectionCollapsed) { + init(rootSections, sidebarContent, search, setSectionCollapsed) { // Store refs to HTML elements. - this.sections = sections; + this.rootSections = rootSections; this.sidebarContent = sidebarContent; this.search = search; this.setSectionCollapsed = setSectionCollapsed; @@ -98,8 +96,10 @@ export default class SidebarFilter { // handle enter key when item is focused. if (e.code === 'Enter' && this.selectedSearchResultIndex !== null) { - // navigate to focused item. - this.searchResults[this.selectedSearchResultIndex].element.click(); + const raw = this.searchResults[this.selectedSearchResultIndex].element; + const navigateEl = raw.closest('a') || raw; + + navigateEl.click(); // prevent default action. e.preventDefault(); e.stopPropagation(); @@ -202,11 +202,8 @@ export default class SidebarFilter { return; } - // focus title or item. if (type === 'title') { element.classList.add(SidebarFilter.CSS.sectionTitleSelected); - } else if (type === 'item') { - element.classList.add(SidebarFilter.CSS.sectionListItemSlelected); } // scroll to focused title or item. @@ -230,11 +227,8 @@ export default class SidebarFilter { return; } - // blur title or item. if (type === 'title') { element.classList.remove(SidebarFilter.CSS.sectionTitleSelected); - } else if (type === 'item') { - element.classList.remove(SidebarFilter.CSS.sectionListItemSlelected); } } @@ -294,54 +288,76 @@ export default class SidebarFilter { * @param {string} searchValue - Search value. */ filterSection(section, searchValue) { - // match with section title. const sectionTitle = section.querySelector('.' + SidebarFilter.CSS.sectionTitle); - const sectionList = section.querySelector('.' + SidebarFilter.CSS.sectionList); + const sectionList = section.querySelector(':scope > .' + SidebarFilter.CSS.sectionList); + + if (!sectionTitle) { + return false; + } + + const empty = !searchValue || !searchValue.trim(); + + if (empty) { + section.classList.remove(SidebarFilter.CSS.sectionHidden); + + if (sectionList) { + Array.from(sectionList.children).forEach((li) => { + const nestedSection = li.querySelector(':scope > .' + SidebarFilter.CSS.section); + + if (nestedSection) { + this.filterSection(nestedSection, searchValue); + } + }); + } + + return true; + } - // check if section title matches. const isTitleMatch = this.isValueMatched(sectionTitle.textContent, searchValue); + let hasMatch = isTitleMatch; - const matchResults = []; - // match with section items. - let isSingleItemMatch = false; + if (isTitleMatch) { + this.searchResults.push({ + element: sectionTitle, + type: 'title', + }); + } if (sectionList) { - const sectionListItems = sectionList.querySelectorAll('.' + SidebarFilter.CSS.sectionListItem); - - sectionListItems.forEach(item => { - if (this.isValueMatched(item.textContent, searchValue)) { - // remove hiden class from item. - item.parentElement.classList.remove(SidebarFilter.CSS.sectionListItemWrapperHidden); - // add item to search results. - matchResults.push({ - element: item, - type: 'item', - }); - isSingleItemMatch = true; - } else { - // hide item if it is not a match. - item.parentElement.classList.add(SidebarFilter.CSS.sectionListItemWrapperHidden); + for (const li of sectionList.children) { + const nestedSection = li.querySelector(':scope > .' + SidebarFilter.CSS.section); + + if (!nestedSection) { + continue; } - }); - } - if (!isTitleMatch && !isSingleItemMatch) { - // hide section if it's items are not a match. - section.classList.add(SidebarFilter.CSS.sectionHidden); - } else { - const parentSection = sectionTitle.closest('section'); - // if item is in collapsed section, expand it. - if (!parentSection.classList.contains(SidebarFilter.CSS.sectionTitleActive)) { - this.setSectionCollapsed(parentSection, false); + const childHasMatch = this.filterSection(nestedSection, searchValue); + + hasMatch = hasMatch || childHasMatch; } - // show section if it's items are a match. + } + + if (hasMatch) { section.classList.remove(SidebarFilter.CSS.sectionHidden); - // add section title to search results. - this.searchResults.push({ - element: sectionTitle, - type: 'title', - }, ...matchResults); + this.setSectionCollapsed(section, false); + + let el = section.parentElement; + + while (el && el !== this.sidebarContent) { + const ancSection = el.closest('.' + SidebarFilter.CSS.section); + + if (!ancSection) { + break; + } + + this.setSectionCollapsed(ancSection, false); + el = ancSection.parentElement; + } + } else { + section.classList.add(SidebarFilter.CSS.sectionHidden); } + + return hasMatch; } /** @@ -356,8 +372,7 @@ export default class SidebarFilter { this.selectedSearchResultIndex = null; // empty search results. this.searchResults = []; - // match search value with sidebar sections. - this.sections.forEach(section => { + this.rootSections.forEach(section => { this.filterSection(section, searchValue); }); } diff --git a/src/frontend/js/modules/sidebar.js b/src/frontend/js/modules/sidebar.js index e6dee56e..5f6bca6a 100644 --- a/src/frontend/js/modules/sidebar.js +++ b/src/frontend/js/modules/sidebar.js @@ -8,9 +8,9 @@ const LOCAL_STORAGE_KEY = 'docs_sidebar_state'; const SIDEBAR_VISIBILITY_KEY = 'docs_sidebar_visibility'; /** - * Section list item height in px + * Slack beyond scrollHeight + vertical borders for max-height (subpixels, hover radius). */ -const ITEM_HEIGHT = 31; +const SIDEBAR_LIST_MAX_HEIGHT_SLACK_PX = 4; /** * Sidebar module @@ -30,7 +30,6 @@ export default class Sidebar { sectionTitle: 'docs-sidebar__section-title', sectionTitleActive: 'docs-sidebar__section-title--active', sectionList: 'docs-sidebar__section-list', - sectionListItemActive: 'docs-sidebar__section-list-item--active', sidebarToggler: 'docs-sidebar__toggler', sidebarSlider: 'docs-sidebar__slider', sidebarCollapsed: 'docs-sidebar--collapsed', @@ -71,6 +70,8 @@ export default class Sidebar { this.isVisible = storedVisibility !== 'false'; // Sidebar filter module this.filter = new SidebarFilter(); + /** @type {number | null} */ + this._recalcListsRaf = null; } /** @@ -81,17 +82,25 @@ export default class Sidebar { */ init(settings, moduleEl) { this.nodes.sidebar = moduleEl; - this.nodes.sections = Array.from(moduleEl.querySelectorAll('.' + Sidebar.CSS.section)); - this.nodes.sections.forEach(section => this.initSection(section)); this.nodes.sidebarContent = moduleEl.querySelector('.' + Sidebar.CSS.sidebarContent); + this.nodes.sections = Array.from(moduleEl.querySelectorAll('.' + Sidebar.CSS.section)); + this.nodes.rootSections = Array.from( + this.nodes.sidebarContent.querySelectorAll(':scope > .' + Sidebar.CSS.section) + ); + this.nodes.sections.forEach((section) => this.initSection(section)); + this.scheduleRecalcSectionLists(); this.nodes.toggler = moduleEl.querySelector('.' + Sidebar.CSS.sidebarToggler); this.nodes.toggler.addEventListener('click', () => this.toggleSidebar()); this.nodes.slider = moduleEl.querySelector('.' + Sidebar.CSS.sidebarSlider); this.nodes.slider.addEventListener('click', () => this.handleSliderClick()); this.nodes.search = moduleEl.querySelector('.' + Sidebar.CSS.sidebarSearch); - this.filter.init(this.nodes.sections, this.nodes.sidebarContent, - this.nodes.search, this.setSectionCollapsed); + this.filter.init( + this.nodes.rootSections, + this.nodes.sidebarContent, + this.nodes.search, + this.setSectionCollapsed.bind(this) + ); this.ready(); } @@ -110,7 +119,7 @@ export default class Sidebar { return; } - togglerEl.addEventListener('click', e => this.handleSectionTogglerClick(id, section, e)); + togglerEl.addEventListener('click', (e) => this.handleSectionTogglerClick(id, section, e)); if (typeof this.sectionsState[id] === 'undefined') { this.sectionsState[id] = false; @@ -118,19 +127,55 @@ export default class Sidebar { if (this.sectionsState[id]) { this.setSectionCollapsed(section, true, false); } + } - /** - * Calculate and set sections list max height for smooth animation - */ - const sectionList = section.querySelector('.' + Sidebar.CSS.sectionList); + /** + * Recompute max-height for every expanded section list (nested opens change ancestor scrollHeight). + * Uses a two-phase measure so parents see full child height (ITEM_HEIGHT * li was wrong for deep trees). + */ + recalcSectionListsMaxHeight() { + this.nodes.sections.forEach((section) => { + if (section.classList.contains(Sidebar.CSS.sectionCollapsed)) { + return; + } + const list = section.querySelector(':scope > .' + Sidebar.CSS.sectionList); - if (!sectionList) { - return; - } + if (list) { + list.style.maxHeight = 'none'; + } + }); - const itemsCount = sectionList.children.length; + requestAnimationFrame(() => { + [...this.nodes.sections].reverse().forEach((section) => { + if (section.classList.contains(Sidebar.CSS.sectionCollapsed)) { + return; + } + const list = section.querySelector(':scope > .' + Sidebar.CSS.sectionList); - sectionList.style.maxHeight = `${itemsCount * ITEM_HEIGHT}px`; + if (!list) { + return; + } + const cs = globalThis.getComputedStyle(list); + const borderY = + (Number.parseFloat(cs.borderTopWidth) || 0) + + (Number.parseFloat(cs.borderBottomWidth) || 0); + + list.style.maxHeight = `${list.scrollHeight + borderY + SIDEBAR_LIST_MAX_HEIGHT_SLACK_PX}px`; + }); + }); + } + + /** + * Batches recalc after collapse/expand and filter-driven opens. + */ + scheduleRecalcSectionLists() { + if (this._recalcListsRaf !== null) { + return; + } + this._recalcListsRaf = requestAnimationFrame(() => { + this._recalcListsRaf = null; + this.recalcSectionListsMaxHeight(); + }); } /** @@ -156,7 +201,7 @@ export default class Sidebar { * @param {boolean} [animated] - true if state should change with animation */ setSectionCollapsed(sectionEl, collapsed, animated = true) { - const sectionList = sectionEl.querySelector('.' + Sidebar.CSS.sectionList); + const sectionList = sectionEl.querySelector(':scope > .' + Sidebar.CSS.sectionList); if (!sectionList) { return; @@ -164,13 +209,15 @@ export default class Sidebar { sectionEl.classList.toggle(Sidebar.CSS.sectionAnimated, animated); sectionEl.classList.toggle(Sidebar.CSS.sectionCollapsed, collapsed); + this.scheduleRecalcSectionLists(); + /** * Highlight section item as active if active child item is collapsed. */ - const activeSectionListItem = sectionList.querySelector('.' + Sidebar.CSS.sectionListItemActive); + const activeInSubtree = sectionList.querySelector('.' + Sidebar.CSS.sectionTitleActive); const sectionTitle = sectionEl.querySelector('.' + Sidebar.CSS.sectionTitle); - if (!activeSectionListItem) { + if (!activeInSubtree) { return; } if (collapsed && animated) { diff --git a/src/frontend/styles/components/sidebar.pcss b/src/frontend/styles/components/sidebar.pcss index 04f6f83a..8eac153f 100644 --- a/src/frontend/styles/components/sidebar.pcss +++ b/src/frontend/styles/components/sidebar.pcss @@ -114,10 +114,32 @@ } &__section { - overflow: hidden; + /* overflow only on the list — squircle mask on titles bleeds past the box and was clipped here */ + overflow: visible; flex-shrink: 0; margin-top: 20px; + &--nested { + margin-top: 2px; + } + + &--leaf { + .docs-sidebar__section-title { + font-size: 14px; + line-height: 21px; + height: 29px; + + @media (--mobile) { + font-size: 16px; + line-height: 21px; + } + } + + .docs-sidebar__section-title--active { + color: white; + } + } + &--hidden { display: none; } @@ -137,6 +159,7 @@ &--collapsed { .docs-sidebar__section-list { max-height: 0 !important; + padding-bottom: 0; } .docs-sidebar__section-toggler { @@ -156,25 +179,11 @@ &__section-title { font-size: 16px; line-height: 24px; - font-weight: 700; + font-weight: 400; + color: var(--color-text-main); z-index: 2; position: relative; height: 34px; - } - - &__section-list-item { - font-size: 14px; - line-height: 21px; - height: 29px; - - @media (--mobile) { - font-size: 16px; - line-height: 21px; - } - } - - &__section-title, - &__section-list-item { display: flex; align-items: center; justify-content: space-between; @@ -190,12 +199,16 @@ &--active, &:hover { - @apply --squircle; + /* plain radius: --squircle uses mask-box-image and gets clipped by overflow:hidden on ancestors */ + border-radius: 8px; + } + + &:has(.docs-sidebar__section-toggler) { + font-weight: 700; } } - &__section-title > span, - &__section-list-item > span { + &__section-title > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -208,22 +221,7 @@ } } - &__section-list-item-wrapper { - padding: 1px 0; - display: block; - &--hidden { - display: none !important; - } - } - - li:last-child { - .docs-sidebar__section-list-item-wrapper { - padding-bottom: 0; - } - } - - &__section-title:not(&__section-title--active), - &__section-list-item:not(&__section-list-item--active) { + &__section-title:not(&__section-title--active) { @media (--can-hover) { &:hover { background: var(--color-link-hover); @@ -231,8 +229,7 @@ } } - &__section-title--active, - &__section-list-item--active { + &__section-title--active { background: linear-gradient(270deg, #129bff 0%, #8a53ff 100%); color: white; @@ -246,9 +243,20 @@ &__section-list { list-style: none; padding: 0; + padding-bottom: 6px; margin: 0; z-index: 1; position: relative; + overflow: hidden; + + &--nested { + padding-left: 12px; + margin-top: 2px; + } + + & > li + li { + margin-top: 2px; + } } &__section-toggler {