mirror of
https://github.com/codex-team/codex.docs.git
synced 2025-07-18 20:59:42 +02:00
Merge branch 'main' of github.com:codex-team/codex.docs into v2
Conflicts: nodemon.json
This commit is contained in:
commit
d9bfb88a86
6 changed files with 462 additions and 13 deletions
|
@ -8,5 +8,5 @@
|
|||
"watch": [
|
||||
"**/*"
|
||||
],
|
||||
"ext": "js,twig,ts"
|
||||
"ext": "ts,js,twig"
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
</div>
|
||||
|
||||
<aside class="docs-sidebar__content docs-sidebar__content--invisible">
|
||||
<span class="docs-sidebar__search-wrapper">
|
||||
<input class="docs-sidebar__search" type="text" placeholder="Search" />
|
||||
</span>
|
||||
{% for firstLevelPage in menu %}
|
||||
<section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}">
|
||||
<a class="docs-sidebar__section-title-wrapper"
|
||||
|
|
364
src/frontend/js/classes/sidebar-filter.js
Normal file
364
src/frontend/js/classes/sidebar-filter.js
Normal file
|
@ -0,0 +1,364 @@
|
|||
/**
|
||||
* HEIGHT of the header in px
|
||||
*/
|
||||
const HEADER_HEIGHT = parseInt(window.getComputedStyle(
|
||||
document.documentElement).getPropertyValue('--layout-height-header'));
|
||||
|
||||
/**
|
||||
* Enum for the direction of the navigation during the filtering.
|
||||
*/
|
||||
const Direction = {
|
||||
Next: 1,
|
||||
Previous: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar Search module.
|
||||
*/
|
||||
export default class SidebarFilter {
|
||||
/**
|
||||
* CSS classes
|
||||
*
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
static get CSS() {
|
||||
return {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates base properties
|
||||
*/
|
||||
constructor() {
|
||||
/**
|
||||
* Stores refs to HTML elements needed for sidebar filter to work.
|
||||
*/
|
||||
this.sidebar = null;
|
||||
this.sections = [];
|
||||
this.sidebarContent = null;
|
||||
this.search = null;
|
||||
this.searchResults = [];
|
||||
this.selectedSearchResultIndex = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sidebar filter.
|
||||
*
|
||||
* @param {HTMLElement[]} sections - Array of sections.
|
||||
* @param {HTMLElement} sidebarContent - Sidebar content.
|
||||
* @param {HTMLElement} search - Search input.
|
||||
* @param {Function} setSectionCollapsed - Function to set section collapsed.
|
||||
*/
|
||||
init(sections, sidebarContent, search, setSectionCollapsed) {
|
||||
// Store refs to HTML elements.
|
||||
this.sections = sections;
|
||||
this.sidebarContent = sidebarContent;
|
||||
this.search = search;
|
||||
this.setSectionCollapsed = setSectionCollapsed;
|
||||
let shortcutText = 'Ctrl P';
|
||||
|
||||
// Search initialize with platform specific shortcut.
|
||||
if (window.navigator.userAgent.indexOf('Mac') !== -1) {
|
||||
shortcutText = '⌘ P';
|
||||
}
|
||||
this.search.parentElement.setAttribute('data-shortcut', shortcutText);
|
||||
|
||||
// Initialize search input.
|
||||
this.search.value = '';
|
||||
|
||||
// Add event listener for search input.
|
||||
this.search.addEventListener('input', e => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
this.filter(e.target.value);
|
||||
});
|
||||
// Add event listener for keyboard events.
|
||||
this.search.addEventListener('keydown', e => this.handleKeyboardEvent(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events while search input is focused.
|
||||
*
|
||||
* @param {Event} e - Event Object.
|
||||
*/
|
||||
handleKeyboardEvent(e) {
|
||||
// Return if search is not focused.
|
||||
if (this.search !== document.activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// handle enter key when item is focused.
|
||||
if (e.code === 'Enter' && this.selectedSearchResultIndex !== null) {
|
||||
// navigate to focused item.
|
||||
this.searchResults[this.selectedSearchResultIndex].element.click();
|
||||
// prevent default action.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
// handle up and down navigation.
|
||||
if (e.code === 'ArrowUp' || e.code === 'ArrowDown') {
|
||||
// check for search results.
|
||||
if (this.searchResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get current focused item.
|
||||
const prevSelectedSearchResultIndex = this.selectedSearchResultIndex;
|
||||
|
||||
// get next item to be focus.
|
||||
if (e.code === 'ArrowUp') {
|
||||
this.selectedSearchResultIndex = this.getNextIndex(
|
||||
Direction.Previous,
|
||||
this.selectedSearchResultIndex,
|
||||
this.searchResults.length - 1);
|
||||
} else if (e.code === 'ArrowDown') {
|
||||
this.selectedSearchResultIndex = this.getNextIndex(
|
||||
Direction.Next,
|
||||
this.selectedSearchResultIndex,
|
||||
this.searchResults.length - 1);
|
||||
}
|
||||
|
||||
// blur previous focused item.
|
||||
this.blurTitleOrItem(prevSelectedSearchResultIndex);
|
||||
// focus next item.
|
||||
this.focusTitleOrItem(this.selectedSearchResultIndex);
|
||||
|
||||
// prevent default action.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get index of next item to be focused.
|
||||
*
|
||||
* @param {number} direction - direction for navigation.
|
||||
* @param {number} titleOrItemIndex - Current title or item index.
|
||||
* @param {number} maxNumberOfTitlesOrItems - Max number of titles or items.
|
||||
* @returns {number} - Next section or item index.
|
||||
*/
|
||||
getNextIndex(direction, titleOrItemIndex, maxNumberOfTitlesOrItems) {
|
||||
let nextTitleOrItemIndex = titleOrItemIndex;
|
||||
|
||||
if (direction === Direction.Previous) {
|
||||
// if no item is focused, focus last item.
|
||||
if (titleOrItemIndex === null) {
|
||||
return maxNumberOfTitlesOrItems;
|
||||
}
|
||||
|
||||
// focus previous item.
|
||||
nextTitleOrItemIndex--;
|
||||
|
||||
// circular navigation.
|
||||
if (nextTitleOrItemIndex < 0) {
|
||||
nextTitleOrItemIndex = maxNumberOfTitlesOrItems;
|
||||
}
|
||||
|
||||
return nextTitleOrItemIndex;
|
||||
} else if (direction === Direction.Next) {
|
||||
// if no item is focused, focus first item.
|
||||
if (titleOrItemIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// focus next item.
|
||||
nextTitleOrItemIndex++;
|
||||
|
||||
// circular navigation.
|
||||
if (nextTitleOrItemIndex > maxNumberOfTitlesOrItems) {
|
||||
nextTitleOrItemIndex = 0;
|
||||
}
|
||||
|
||||
return nextTitleOrItemIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus title or item at given index.
|
||||
*
|
||||
* @param {number} titleOrItemIndex - Title or item index.
|
||||
*/
|
||||
focusTitleOrItem(titleOrItemIndex) {
|
||||
// check for valid index.
|
||||
if (titleOrItemIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { element, type } = this.searchResults[titleOrItemIndex];
|
||||
|
||||
if (!element || !type) {
|
||||
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.
|
||||
this.scrollToTitleOrItem(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blur title or item at given index.
|
||||
*
|
||||
* @param {number} titleOrItemIndex - Title or item index.
|
||||
*/
|
||||
blurTitleOrItem(titleOrItemIndex) {
|
||||
// check for valid index.
|
||||
if (titleOrItemIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { element, type } = this.searchResults[titleOrItemIndex];
|
||||
|
||||
if (!element || !type) {
|
||||
return;
|
||||
}
|
||||
|
||||
// blur title or item.
|
||||
if (type === 'title') {
|
||||
element.classList.remove(SidebarFilter.CSS.sectionTitleSelected);
|
||||
} else if (type === 'item') {
|
||||
element.classList.remove(SidebarFilter.CSS.sectionListItemSlelected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to title or item.
|
||||
*
|
||||
* @param {HTMLElement} titleOrItem - Title or item element.
|
||||
*/
|
||||
scrollToTitleOrItem(titleOrItem) {
|
||||
// check if it's visible.
|
||||
const rect = titleOrItem.getBoundingClientRect();
|
||||
let elemTop = rect.top;
|
||||
let elemBottom = rect.bottom;
|
||||
const halfOfViewport = window.innerHeight / 2;
|
||||
const scrollTop = this.sidebarContent.scrollTop;
|
||||
|
||||
// scroll top if item is not visible.
|
||||
if (elemTop < HEADER_HEIGHT) {
|
||||
// scroll half viewport up.
|
||||
const nextTop = scrollTop - halfOfViewport;
|
||||
|
||||
// check if element visible after scroll.
|
||||
elemTop = (elemTop + nextTop) < HEADER_HEIGHT ? elemTop : nextTop;
|
||||
this.sidebarContent.scroll({
|
||||
top: elemTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else if (elemBottom > window.innerHeight) {
|
||||
// scroll bottom if item is not visible.
|
||||
// scroll half viewport down.
|
||||
const nextDown = halfOfViewport + scrollTop;
|
||||
|
||||
// check if element visible after scroll.
|
||||
elemBottom = (elemBottom - nextDown) > window.innerHeight ? elemBottom : nextDown;
|
||||
this.sidebarContent.scroll({
|
||||
top: elemBottom,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains search text.
|
||||
*
|
||||
* @param {string} content - content to be searched.
|
||||
* @param {string} searchValue - Search value.
|
||||
* @returns {boolean} - true if content contains search value.
|
||||
*/
|
||||
isValueMatched(content, searchValue) {
|
||||
return content.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* filter sidebar items.
|
||||
*
|
||||
* @param {HTMLElement} section - Section element.
|
||||
* @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);
|
||||
|
||||
// check if section title matches.
|
||||
const isTitleMatch = this.isValueMatched(sectionTitle.textContent, searchValue);
|
||||
|
||||
const matchResults = [];
|
||||
// match with section items.
|
||||
let isSingleItemMatch = false;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
// show section if it's items are a match.
|
||||
section.classList.remove(SidebarFilter.CSS.sectionHidden);
|
||||
// add section title to search results.
|
||||
this.searchResults.push({
|
||||
element: sectionTitle,
|
||||
type: 'title',
|
||||
}, ...matchResults);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter sidebar sections.
|
||||
*
|
||||
* @param {string} searchValue - Search value.
|
||||
*/
|
||||
filter(searchValue) {
|
||||
// remove selection from previous search results.
|
||||
this.blurTitleOrItem(this.selectedSearchResultIndex);
|
||||
// empty selected index.
|
||||
this.selectedSearchResultIndex = null;
|
||||
// empty search results.
|
||||
this.searchResults = [];
|
||||
// match search value with sidebar sections.
|
||||
this.sections.forEach(section => {
|
||||
this.filterSection(section, searchValue);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { Storage } from '../utils/storage';
|
||||
import Shortcut from '@codexteam/shortcuts';
|
||||
|
||||
import SidebarFilter from '../classes/sidebar-filter';
|
||||
/**
|
||||
* Local storage key
|
||||
*/
|
||||
|
@ -38,6 +38,7 @@ export default class Sidebar {
|
|||
sidebarContent: 'docs-sidebar__content',
|
||||
sidebarContentVisible: 'docs-sidebar__content--visible',
|
||||
sidebarContentInvisible: 'docs-sidebar__content--invisible',
|
||||
sidebarSearch: 'docs-sidebar__search',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -54,6 +55,7 @@ export default class Sidebar {
|
|||
sidebarContent: null,
|
||||
toggler: null,
|
||||
slider: null,
|
||||
search: null,
|
||||
};
|
||||
this.sidebarStorage = new Storage(LOCAL_STORAGE_KEY);
|
||||
const storedState = this.sidebarStorage.get();
|
||||
|
@ -67,6 +69,8 @@ export default class Sidebar {
|
|||
|
||||
// Sidebar visibility
|
||||
this.isVisible = storedVisibility !== 'false';
|
||||
// Sidebar filter module
|
||||
this.filter = new SidebarFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -84,6 +88,11 @@ export default class Sidebar {
|
|||
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.ready();
|
||||
}
|
||||
|
||||
|
@ -211,6 +220,25 @@ export default class Sidebar {
|
|||
on: document.body,
|
||||
callback: () => this.handleSliderClick(),
|
||||
});
|
||||
|
||||
// Add event listener to focus search input on Ctrl+P or ⌘+P is pressed.
|
||||
// eslint-disable-next-line no-new
|
||||
new Shortcut({
|
||||
name: 'CMD+P',
|
||||
on: document.body,
|
||||
callback: (e) => {
|
||||
// If sidebar is not visible.
|
||||
if (!this.isVisible) {
|
||||
// make sidebar visible.
|
||||
this.handleSliderClick();
|
||||
}
|
||||
// focus search input.
|
||||
this.nodes.search.focus();
|
||||
// Stop propagation of event.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -77,10 +77,51 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__search {
|
||||
@apply --input;
|
||||
|
||||
appearance: none;
|
||||
background: url("../svg/search.svg") left 10px center no-repeat;
|
||||
background-color: var(--color-input-primary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 7.5px 35px;
|
||||
padding-right: 42px;
|
||||
line-height: 17px;
|
||||
font-size: 14px;
|
||||
|
||||
&-wrapper::after {
|
||||
color: var(--color-button-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 17px;
|
||||
content: attr(data-shortcut);
|
||||
margin-left: -45px;
|
||||
}
|
||||
|
||||
@media (--mobile) {
|
||||
display: none;
|
||||
|
||||
&-wrapper::after {
|
||||
content: "";
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__search::placeholder {
|
||||
color: var(--color-button-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__section {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
margin-top: 20px;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--animated {
|
||||
.docs-sidebar__section-list {
|
||||
|
@ -107,8 +148,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__section:not(:first-child) {
|
||||
margin-top: 19px;
|
||||
@media (--mobile) {
|
||||
&__section:nth-child(2) {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
|
@ -125,7 +168,7 @@
|
|||
line-height: 21px;
|
||||
height: 29px;
|
||||
|
||||
@media (--mobile){
|
||||
@media (--mobile) {
|
||||
font-size: 16px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
@ -141,6 +184,12 @@
|
|||
transition-duration: 0.1s;
|
||||
|
||||
@apply --squircle;
|
||||
|
||||
&--selected {
|
||||
border-radius: 8px;
|
||||
/* border using box-shadow which doesn't increase the height */
|
||||
box-shadow: 0 0 0 2px rgba(147, 166, 233, 0.5) inset;
|
||||
}
|
||||
}
|
||||
|
||||
&__section-title > span,
|
||||
|
@ -157,10 +206,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
&__section-list-item-wrapper {
|
||||
padding: 1px 0;
|
||||
display: block;
|
||||
&--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
|
@ -169,7 +220,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
&__section-title:not(&__section-title--active),
|
||||
&__section-list-item:not(&__section-list-item--active) {
|
||||
@media (--can-hover) {
|
||||
|
@ -181,12 +231,12 @@
|
|||
|
||||
&__section-title--active,
|
||||
&__section-list-item--active {
|
||||
background: linear-gradient(270deg, #129BFF 0%, #8A53FF 100%);
|
||||
background: linear-gradient(270deg, #129bff 0%, #8a53ff 100%);
|
||||
color: white;
|
||||
|
||||
@media (--can-hover) {
|
||||
.docs-sidebar__section-toggler:hover {
|
||||
background: rgba(0,0,0,0.3);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -223,7 +273,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
&__toggler {
|
||||
color: var(--color-text-second);
|
||||
padding: 20px 15px;
|
||||
|
@ -241,7 +290,9 @@
|
|||
&__slider {
|
||||
display: none;
|
||||
position: fixed;
|
||||
transform: translateX(calc(var(--layout-sidebar-width) + var(--hide-sidebar-toggler-offset)));
|
||||
transform: translateX(
|
||||
calc(var(--layout-sidebar-width) + var(--hide-sidebar-toggler-offset))
|
||||
);
|
||||
bottom: var(--hide-sidebar-toggler-offset);
|
||||
width: var(--hide-sidebar-toggler-size);
|
||||
height: var(--hide-sidebar-toggler-size);
|
||||
|
@ -272,7 +323,7 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
&-image{
|
||||
&-image {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
|
@ -287,5 +338,4 @@
|
|||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
4
src/frontend/svg/search.svg
Normal file
4
src/frontend/svg/search.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="6.5" cy="6.5" r="5.5" stroke="#717682" stroke-width="2"/>
|
||||
<rect x="10.4143" y="10" width="5" height="2" rx="1" transform="rotate(45 10.4143 10)" fill="#717682"/>
|
||||
</svg>
|
After Width: | Height: | Size: 277 B |
Loading…
Add table
Add a link
Reference in a new issue