mirror of
https://github.com/codex-team/codex.docs.git
synced 2025-08-07 14:35:26 +02:00
sidebar search=>filter
This commit is contained in:
parent
8a865383fa
commit
0619af1bd0
3 changed files with 275 additions and 266 deletions
270
src/frontend/js/classes/sidebar-filter.js
Normal file
270
src/frontend/js/classes/sidebar-filter.js
Normal file
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* HEIGHT of the header in px
|
||||
*/
|
||||
const HEADER_HEIGHT = 56;
|
||||
|
||||
/**
|
||||
* 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',
|
||||
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',
|
||||
sidebarSearchWrapperMac: 'docs-sidebar__search-wrapper-mac',
|
||||
sidebarSearchWrapperOther: 'docs-sidebar__search-wrapper-other',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
init(sections, sidebarContent, search)
|
||||
{
|
||||
this.sections = sections;
|
||||
this.sidebarContent = sidebarContent;
|
||||
this.search = search;
|
||||
let className = SidebarFilter.CSS.sidebarSearchWrapperOther;
|
||||
|
||||
// Search initialize with platform specific shortcut.
|
||||
if (window.navigator.userAgent.indexOf('Mac') != -1) {
|
||||
className = SidebarFilter.CSS.sidebarSearchWrapperMac;
|
||||
}
|
||||
this.search.parentElement.classList.add(className);
|
||||
|
||||
// Add event listener for search input.
|
||||
this.search.addEventListener('input', e => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
this.filterSections(e.target.value);
|
||||
});
|
||||
// Initialize the search results.
|
||||
this.filterSections('');
|
||||
|
||||
// Add event listener for keyboard events.
|
||||
this.search.addEventListener('keydown', e => this.handleKeyboardEvent(e));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle keyboard events when search input is focused.
|
||||
*
|
||||
* @param {Event} e - Event Object.
|
||||
* @returns {void}
|
||||
*/
|
||||
handleKeyboardEvent(e) {
|
||||
// Return if search is not focused.
|
||||
if (this.search !== document.activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if enter is pressed and item is focused, then click on focused item.
|
||||
if (e.code === 'Enter' && this.selectedSearchResultIndex !== null) {
|
||||
// goto focused item.
|
||||
this.searchResults[this.selectedSearchResultIndex].element.click();
|
||||
// prevent default action.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.selectedSearchResultIndex = this.getNextSectionOrItemIndex(e.code,
|
||||
this.selectedSearchResultIndex,
|
||||
this.searchResults.length - 1);
|
||||
|
||||
this.blurSectionOrItem(prevSelectedSearchResultIndex);
|
||||
|
||||
this.focusSectionOrItem(this.selectedSearchResultIndex);
|
||||
|
||||
// prevent default action.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
getNextSectionOrItemIndex(code, sectionOrItemIndex, maxNumberOfSectionsOrItems) {
|
||||
let nextSectionOrItemIndex = sectionOrItemIndex;
|
||||
|
||||
if (code === 'ArrowUp') {
|
||||
if (sectionOrItemIndex === null) {
|
||||
return maxNumberOfSectionsOrItems;
|
||||
}
|
||||
|
||||
nextSectionOrItemIndex--;
|
||||
|
||||
if (nextSectionOrItemIndex < 0) {
|
||||
nextSectionOrItemIndex = maxNumberOfSectionsOrItems;
|
||||
}
|
||||
|
||||
return nextSectionOrItemIndex;
|
||||
}
|
||||
else if (code === 'ArrowDown') {
|
||||
if (sectionOrItemIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
nextSectionOrItemIndex++;
|
||||
|
||||
if (nextSectionOrItemIndex > maxNumberOfSectionsOrItems) {
|
||||
nextSectionOrItemIndex = 0;
|
||||
}
|
||||
|
||||
return nextSectionOrItemIndex;
|
||||
}
|
||||
}
|
||||
|
||||
focusSectionOrItem(sectionOrItemIndex) {
|
||||
if (sectionOrItemIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { element, type } = this.searchResults[sectionOrItemIndex];
|
||||
|
||||
if (!element || !type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'title') {
|
||||
element.classList.add(SidebarFilter.CSS.sectionTitleSelected);
|
||||
} else if (type === 'item') {
|
||||
element.classList.add(SidebarFilter.CSS.sectionListItemSlelected);
|
||||
}
|
||||
|
||||
this.scrollToSectionOrItem(element);
|
||||
}
|
||||
|
||||
blurSectionOrItem(sectionOrItemIndex) {
|
||||
if (sectionOrItemIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { element, type } = this.searchResults[sectionOrItemIndex];
|
||||
|
||||
if (!element || !type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'title') {
|
||||
element.classList.remove(SidebarFilter.CSS.sectionTitleSelected);
|
||||
} else if (type === 'item') {
|
||||
element.classList.remove(SidebarFilter.CSS.sectionListItemSlelected);
|
||||
}
|
||||
}
|
||||
|
||||
scrollToSectionOrItem(sectionOrItem) {
|
||||
// check if it's visible.
|
||||
const rect = sectionOrItem.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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
filterSections(searchValue) {
|
||||
// remove selection from previous search results.
|
||||
this.blurSectionOrItem(this.selectedSearchResultIndex);
|
||||
// empty selected index.
|
||||
this.selectedSearchResultIndex = null;
|
||||
// empty search results.
|
||||
this.searchResults = [];
|
||||
// match search value with sidebar sections.
|
||||
this.sections.forEach(section => {
|
||||
// match with section title.
|
||||
const sectionTitle = section.querySelector('.' + SidebarFilter.CSS.sectionTitle);
|
||||
const isTitleMatch = sectionTitle.innerText.trim().toLowerCase()
|
||||
.indexOf(searchValue.toLowerCase()) !== -1;
|
||||
const matchResults = [];
|
||||
// match with section items.
|
||||
const sectionList = section.querySelector('.' + SidebarFilter.CSS.sectionList);
|
||||
let isSingleItemMatch = false;
|
||||
|
||||
if (sectionList) {
|
||||
const sectionListItems = sectionList.querySelectorAll('.' + SidebarFilter.CSS.sectionListItem);
|
||||
|
||||
sectionListItems.forEach(item => {
|
||||
if (item.innerText.trim().toLowerCase()
|
||||
.indexOf(searchValue.toLowerCase()) !== -1) {
|
||||
// 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 and it's items are not a match.
|
||||
section.classList.add(SidebarFilter.CSS.sectionHidden);
|
||||
} else {
|
||||
// show section and 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,262 +0,0 @@
|
|||
/**
|
||||
* HEIGHT of the header in px
|
||||
*/
|
||||
const HEADER_HEIGHT = 56;
|
||||
|
||||
/**
|
||||
* Sidebar module
|
||||
*/
|
||||
export default class SidebarSearch {
|
||||
/**
|
||||
* 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',
|
||||
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',
|
||||
sidebarSearchWrapperMac: 'docs-sidebar__search-wrapper-mac',
|
||||
sidebarSearchWrapperOther: 'docs-sidebar__search-wrapper-other',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates base properties
|
||||
*/
|
||||
constructor() {
|
||||
/**
|
||||
* Stores refs to HTML elements needed for correct sidebar work
|
||||
*/
|
||||
this.sidebar = null;
|
||||
this.sections = [];
|
||||
this.sidebarContent = null;
|
||||
this.search = null;
|
||||
this.searchResults = [];
|
||||
this.selectedSearchResultIndex = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} sections
|
||||
* @param {*} sidebarContent
|
||||
* @param {*} search
|
||||
*/
|
||||
init(sections, sidebarContent, search)
|
||||
{
|
||||
this.sections = sections;
|
||||
this.sidebarContent = sidebarContent;
|
||||
this.search = search;
|
||||
let className = SidebarSearch.CSS.sidebarSearchWrapperOther;
|
||||
|
||||
// Search initialize with platform specific shortcut.
|
||||
if (window.navigator.userAgent.indexOf('Mac') != -1) {
|
||||
className = SidebarSearch.CSS.sidebarSearchWrapperMac;
|
||||
}
|
||||
this.search.parentElement.classList.add(className);
|
||||
|
||||
// Add event listener for search input.
|
||||
this.search.addEventListener('input', e => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
this.filterSections(e.target.value);
|
||||
});
|
||||
// Initialize the search results.
|
||||
this.filterSections('');
|
||||
|
||||
// Add event listener for keyboard events.
|
||||
this.search.addEventListener('keydown', e => this.handleKeyboardEventOnSearch(e));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle keyboard events when search input is focused.
|
||||
*
|
||||
* @param {Event} e - Event Object.
|
||||
* @returns {void}
|
||||
*/
|
||||
handleKeyboardEventOnSearch(e) {
|
||||
// Return if search is not focused.
|
||||
if (this.search !== document.activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if enter is pressed and item is focused, then click on focused item.
|
||||
if (e.code === 'Enter' && this.selectedSearchResultIndex !== null) {
|
||||
// goto focused item.
|
||||
this.searchResults[this.selectedSearchResultIndex].element.click();
|
||||
// prevent default action.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
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 index.
|
||||
if (this.selectedSearchResultIndex === null) {
|
||||
// if no item is focused and up press, focus last item.
|
||||
if (e.code === 'ArrowUp') {
|
||||
this.selectedSearchResultIndex = this.searchResults.length - 1;
|
||||
} else if (e.code === 'ArrowDown') {
|
||||
// if no item is focused and down press, focus first item.
|
||||
this.selectedSearchResultIndex = 0;
|
||||
}
|
||||
} else {
|
||||
// if item is focused and up press, focus previous item.
|
||||
if (e.code === 'ArrowUp') {
|
||||
this.selectedSearchResultIndex--;
|
||||
if (this.selectedSearchResultIndex < 0) {
|
||||
this.selectedSearchResultIndex = this.searchResults.length - 1;
|
||||
}
|
||||
} else if (e.code === 'ArrowDown') {
|
||||
// if item is focused and down press, focus next item.
|
||||
this.selectedSearchResultIndex++;
|
||||
if (this.selectedSearchResultIndex >= this.searchResults.length) {
|
||||
this.selectedSearchResultIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove focus from previous item.
|
||||
if (prevSelectedSearchResultIndex !== null) {
|
||||
const { element: preElement, type: preType } = this.searchResults[prevSelectedSearchResultIndex];
|
||||
|
||||
if (preElement) {
|
||||
// remove focus from previous item or title.
|
||||
if (preType === 'title') {
|
||||
preElement.classList.remove(SidebarSearch.CSS.sectionTitleSelected);
|
||||
} else if (preType === 'item') {
|
||||
preElement.classList.remove(SidebarSearch.CSS.sectionListItemSlelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { element, type } = this.searchResults[this.selectedSearchResultIndex];
|
||||
|
||||
if (element) {
|
||||
// add focus to next item or title.
|
||||
if (type === 'title') {
|
||||
element.classList.add(SidebarSearch.CSS.sectionTitleSelected);
|
||||
} else if (type === 'item') {
|
||||
element.classList.add(SidebarSearch.CSS.sectionListItemSlelected);
|
||||
}
|
||||
|
||||
// check if it's visible.
|
||||
const rect = element.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',
|
||||
});
|
||||
}
|
||||
}
|
||||
// prevent default action.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
filterSections(searchValue) {
|
||||
// remove selection from previous search results.
|
||||
if (this.selectedSearchResultIndex) {
|
||||
const { element, type } = this.searchResults[this.selectedSearchResultIndex];
|
||||
|
||||
if (element) {
|
||||
// remove focus from previous item or title.
|
||||
if (type === 'title') {
|
||||
element.classList.remove(SidebarSearch.CSS.sectionTitleSelected);
|
||||
} else if (type === 'item') {
|
||||
element.classList.remove(SidebarSearch.CSS.sectionListItemSlelected);
|
||||
}
|
||||
// empty selected index.
|
||||
this.selectedSearchResultIndex = null;
|
||||
}
|
||||
}
|
||||
// empty search results.
|
||||
this.searchResults = [];
|
||||
// match search value with sidebar sections.
|
||||
this.sections.forEach(section => {
|
||||
// match with section title.
|
||||
const sectionTitle = section.querySelector('.' + SidebarSearch.CSS.sectionTitle);
|
||||
let isTitleMatch = true;
|
||||
const matchResults = [];
|
||||
|
||||
if (sectionTitle.innerText.trim().toLowerCase()
|
||||
.indexOf(searchValue.toLowerCase()) === -1) {
|
||||
isTitleMatch = false;
|
||||
}
|
||||
|
||||
// match with section items.
|
||||
const sectionList = section.querySelector('.' + SidebarSearch.CSS.sectionList);
|
||||
let isItemMatch = false;
|
||||
|
||||
if (sectionList) {
|
||||
const sectionListItems = sectionList.querySelectorAll('.' + SidebarSearch.CSS.sectionListItem);
|
||||
|
||||
sectionListItems.forEach(item => {
|
||||
if (item.innerText.trim().toLowerCase()
|
||||
.indexOf(searchValue.toLowerCase()) !== -1) {
|
||||
// remove hiden class from item.
|
||||
item.parentElement.classList.remove(SidebarSearch.CSS.sectionListItemWrapperHidden);
|
||||
// add item to search results.
|
||||
matchResults.push({
|
||||
element: item,
|
||||
type: 'item',
|
||||
});
|
||||
isItemMatch = true;
|
||||
} else {
|
||||
// hide item if it is not a match.
|
||||
item.parentElement.classList.add(SidebarSearch.CSS.sectionListItemWrapperHidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!isTitleMatch && !isItemMatch) {
|
||||
// hide section and it's items are not a match.
|
||||
section.classList.add(SidebarSearch.CSS.sectionHidden);
|
||||
} else {
|
||||
// show section and it's items are a match.
|
||||
section.classList.remove(SidebarSearch.CSS.sectionHidden);
|
||||
// add section title to search results.
|
||||
this.searchResults.push({
|
||||
element: sectionTitle,
|
||||
type: 'title',
|
||||
}, ...matchResults);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { Storage } from '../utils/storage';
|
||||
import Shortcut from '@codexteam/shortcuts';
|
||||
import SidebarSearch from '../classes/sidebar-search';
|
||||
import SidebarFilter from '../classes/sidebar-filter';
|
||||
/**
|
||||
* Local storage key
|
||||
*/
|
||||
|
@ -69,8 +69,8 @@ export default class Sidebar {
|
|||
|
||||
// Sidebar visibility
|
||||
this.isVisible = storedVisibility !== 'false';
|
||||
// Sidebar search module
|
||||
this.search = new SidebarSearch();
|
||||
// Sidebar filter module
|
||||
this.filter = new SidebarFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -90,7 +90,7 @@ export default class Sidebar {
|
|||
this.nodes.slider.addEventListener('click', () => this.handleSliderClick());
|
||||
|
||||
this.nodes.search = moduleEl.querySelector('.' + Sidebar.CSS.sidebarSearch);
|
||||
this.search.init(this.nodes.sections, this.nodes.sidebarContent, this.nodes.search);
|
||||
this.filter.init(this.nodes.sections, this.nodes.sidebarContent, this.nodes.search);
|
||||
|
||||
this.ready();
|
||||
}
|
||||
|
@ -231,6 +231,7 @@ export default class Sidebar {
|
|||
// make sidebar visible.
|
||||
this.handleSliderClick();
|
||||
}
|
||||
// focus search input.
|
||||
this.nodes.search.focus();
|
||||
// Stop propagation of event.
|
||||
e.stopPropagation();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue