1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-07-18 20:59:42 +02:00

Add search to sidebar (#215)

* remove package json

* twig file modified

* search bar style added

* the background content added

* add the switching b/w the shortcut logo

* shortcut for search added

* add the arrowup and arrowdown short cut

* sidebar search added

* keyup and keydown replace with input

* the sidebar search selected added

* unusal things

* the enter evenlister added with search refactring

* comments added

* the scroll added if element is not visble

* metakey added

* event listner using shortcut added

* the integration for input box completed

* nodemon config updated

* replace the shortcuts with event listener

* bugfix: up height of header added

* feat:integrate sidebar toggle with search shortcut

* syntax improved

* event listener updated

* border adjusted

* search adjusted

* sidebar search navigation adjusted

* new search module added

* new module integrated

* boxshadow added as border

* sidebar search class added

* sidebar search=>filter

* comments added

* filter for section added

* the expand feature added during navigation

* remove the space

* header height variable added

* shortcut logic updated

* enum for direction added

* common search function added

* expand every match

* updated styles

* updated styles

* margin remove in mobile view with bold removed

* clean css added

Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
This commit is contained in:
Umang G. Patel 2022-10-13 08:30:23 +05:30 committed by GitHub
parent 5a7f1c843b
commit 698c09c489
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 463 additions and 13 deletions

View file

@ -8,5 +8,5 @@
"watch": [ "watch": [
"**/*" "**/*"
], ],
"ext": "js,twig" "ext": "ts,js,twig"
} }

View file

@ -291,6 +291,7 @@ class Pages {
await alias.destroy(); await alias.destroy();
} }
const removedPage = page.destroy(); const removedPage = page.destroy();
await PagesFlatArray.regenerate(); await PagesFlatArray.regenerate();
return removedPage; return removedPage;

View file

@ -6,6 +6,9 @@
</div> </div>
<aside class="docs-sidebar__content docs-sidebar__content--invisible"> <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 %} {% for firstLevelPage in menu %}
<section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}"> <section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}">
<a class="docs-sidebar__section-title-wrapper" <a class="docs-sidebar__section-title-wrapper"

View 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);
});
}
}

View file

@ -1,6 +1,6 @@
import { Storage } from '../utils/storage'; import { Storage } from '../utils/storage';
import Shortcut from '@codexteam/shortcuts'; import Shortcut from '@codexteam/shortcuts';
import SidebarFilter from '../classes/sidebar-filter';
/** /**
* Local storage key * Local storage key
*/ */
@ -38,6 +38,7 @@ export default class Sidebar {
sidebarContent: 'docs-sidebar__content', sidebarContent: 'docs-sidebar__content',
sidebarContentVisible: 'docs-sidebar__content--visible', sidebarContentVisible: 'docs-sidebar__content--visible',
sidebarContentInvisible: 'docs-sidebar__content--invisible', sidebarContentInvisible: 'docs-sidebar__content--invisible',
sidebarSearch: 'docs-sidebar__search',
}; };
} }
@ -54,6 +55,7 @@ export default class Sidebar {
sidebarContent: null, sidebarContent: null,
toggler: null, toggler: null,
slider: null, slider: null,
search: null,
}; };
this.sidebarStorage = new Storage(LOCAL_STORAGE_KEY); this.sidebarStorage = new Storage(LOCAL_STORAGE_KEY);
const storedState = this.sidebarStorage.get(); const storedState = this.sidebarStorage.get();
@ -67,6 +69,8 @@ export default class Sidebar {
// Sidebar visibility // Sidebar visibility
this.isVisible = storedVisibility !== 'false'; 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.toggler.addEventListener('click', () => this.toggleSidebar());
this.nodes.slider = moduleEl.querySelector('.' + Sidebar.CSS.sidebarSlider); this.nodes.slider = moduleEl.querySelector('.' + Sidebar.CSS.sidebarSlider);
this.nodes.slider.addEventListener('click', () => this.handleSliderClick()); 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(); this.ready();
} }
@ -211,6 +220,25 @@ export default class Sidebar {
on: document.body, on: document.body,
callback: () => this.handleSliderClick(), 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();
},
});
} }
/** /**

View file

@ -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 { &__section {
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
margin-top: 20px;
&--hidden {
display: none;
}
&--animated { &--animated {
.docs-sidebar__section-list { .docs-sidebar__section-list {
@ -107,8 +148,10 @@
} }
} }
&__section:not(:first-child) { @media (--mobile) {
margin-top: 19px; &__section:nth-child(2) {
margin-top: 0px;
}
} }
&__section-title { &__section-title {
@ -141,6 +184,12 @@
transition-duration: 0.1s; transition-duration: 0.1s;
@apply --squircle; @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, &__section-title > span,
@ -157,10 +206,12 @@
} }
} }
&__section-list-item-wrapper { &__section-list-item-wrapper {
padding: 1px 0; padding: 1px 0;
display: block; display: block;
&--hidden {
display: none !important;
}
} }
li:last-child { li:last-child {
@ -169,7 +220,6 @@
} }
} }
&__section-title:not(&__section-title--active), &__section-title:not(&__section-title--active),
&__section-list-item:not(&__section-list-item--active) { &__section-list-item:not(&__section-list-item--active) {
@media (--can-hover) { @media (--can-hover) {
@ -181,7 +231,7 @@
&__section-title--active, &__section-title--active,
&__section-list-item--active { &__section-list-item--active {
background: linear-gradient(270deg, #129BFF 0%, #8A53FF 100%); background: linear-gradient(270deg, #129bff 0%, #8a53ff 100%);
color: white; color: white;
@media (--can-hover) { @media (--can-hover) {
@ -223,7 +273,6 @@
} }
} }
&__toggler { &__toggler {
color: var(--color-text-second); color: var(--color-text-second);
padding: 20px 15px; padding: 20px 15px;
@ -241,7 +290,9 @@
&__slider { &__slider {
display: none; display: none;
position: fixed; 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); bottom: var(--hide-sidebar-toggler-offset);
width: var(--hide-sidebar-toggler-size); width: var(--hide-sidebar-toggler-size);
height: var(--hide-sidebar-toggler-size); height: var(--hide-sidebar-toggler-size);
@ -287,5 +338,4 @@
padding: 8px; padding: 8px;
} }
} }
} }

View 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