diff --git a/client/src/assets/icons/filter.svg b/client/src/assets/icons/filter.svg new file mode 100644 index 000000000000..4a49f3cdac98 --- /dev/null +++ b/client/src/assets/icons/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/document/index.scss b/client/src/document/index.scss index 7aafff9586d7..78a895a8dcde 100644 --- a/client/src/document/index.scss +++ b/client/src/document/index.scss @@ -651,8 +651,7 @@ kbd { .sidebar { mask-image: linear-gradient( to bottom, - rgba(0, 0, 0, 0) 0%, - rgb(0, 0, 0) 3rem calc(100% - 3rem), + rgb(0, 0, 0) 0% calc(100% - 3rem), rgba(0, 0, 0, 0) 100% ); } diff --git a/client/src/document/organisms/sidebar/SidebarFilterer.ts b/client/src/document/organisms/sidebar/SidebarFilterer.ts new file mode 100644 index 000000000000..c728d1d7ef20 --- /dev/null +++ b/client/src/document/organisms/sidebar/SidebarFilterer.ts @@ -0,0 +1,255 @@ +import { splitQuery } from "../../../utils"; + +export class SidebarFilterer { + allHeadings: HTMLElement[]; + allParents: HTMLDetailsElement[]; + items: Array<{ + haystack: string; + link: HTMLAnchorElement; + container: HTMLElement; + heading: HTMLElement | undefined; + parents: HTMLDetailsElement[]; + }>; + toc: HTMLElement | null; + + constructor(root: HTMLElement) { + this.allHeadings = Array.from( + root.querySelectorAll("li strong") + ); + this.allParents = Array.from( + root.querySelectorAll("details") + ); + + const links = Array.from( + root.querySelectorAll("a[href]") + ); + + this.items = links.map((link) => ({ + haystack: (link.textContent ?? "").toLowerCase(), + link, + container: this.getContainerOf(link), + heading: this.getHeadingOf(link), + parents: this.getParentsOf(link), + })); + + this.toc = + root.closest(".sidebar")?.querySelector(".in-nav-toc") ?? + null; + } + + applyFilter(query: string) { + if (query) { + this.toggleTOC(false); + return this.showOnlyMatchingItems(query); + } else { + this.toggleTOC(true); + this.showAllItems(); + return undefined; + } + } + + private toggleTOC(show: boolean) { + if (this.toc) { + this.toggleElement(this.toc, show); + } + } + + private toggleElement(el: HTMLElement, show: boolean) { + el.style.display = show ? "" : "none"; + } + + showAllItems() { + this.items.forEach(({ link }) => this.resetLink(link)); + this.allHeadings.forEach((heading) => this.resetHeading(heading)); + this.allParents.forEach((parent) => this.resetParent(parent)); + } + + private resetLink(link: HTMLAnchorElement) { + this.resetHighlighting(link); + const container = this.getContainerOf(link); + this.toggleElement(container, true); + } + + private getContainerOf(el: HTMLElement) { + return el.closest("li") || el; + } + + private resetHeading(heading: HTMLElement) { + const container = this.getContainerOf(heading); + this.toggleElement(container, true); + } + + private resetParent(parent: HTMLDetailsElement) { + const container = this.getContainerOf(parent); + this.toggleElement(container, true); + if (parent.dataset.wasOpen) { + parent.open = JSON.parse(parent.dataset.wasOpen); + delete parent.dataset.wasOpen; + } + } + + private resetHighlighting(link: HTMLAnchorElement) { + const nodes = Array.from(link.querySelectorAll("span, mark")); + const parents = new Set(); + nodes.forEach((node) => { + const parent = node.parentElement; + node.replaceWith(document.createTextNode(node.textContent ?? "")); + if (parent) { + parents.add(parent); + } + }); + parents.forEach((parent) => parent.normalize()); + } + + showOnlyMatchingItems(query: string) { + this.allHeadings.forEach((heading) => this.hideHeading(heading)); + this.allParents.forEach((parent) => this.collapseParent(parent)); + + // Show/hide items (+ show parents). + const terms = splitQuery(query); + let matchCount = 0; + this.items.forEach(({ haystack, link, container, heading, parents }) => { + this.resetHighlighting(link); + const isMatch = terms.every((needle) => haystack.includes(needle)); + + this.toggleElement(container, isMatch); + + if (isMatch) { + matchCount++; + this.highlightMatches(link, terms); + if (heading) { + this.showHeading(heading); + } + for (const parent of parents) { + this.expandParent(parent); + } + } + }); + + return matchCount; + } + + private hideHeading(heading: HTMLElement) { + const container = this.getContainerOf(heading); + this.toggleElement(container, false); + } + + private collapseParent(parent: HTMLDetailsElement) { + const container = this.getContainerOf(parent); + this.toggleElement(container, false); + parent.dataset.wasOpen = parent.dataset.wasOpen ?? String(parent.open); + parent.open = false; + } + + private highlightMatches(el: HTMLElement, terms: string[]) { + const nodes = this.getTextNodesOf(el); + + nodes.forEach((node) => { + const haystack = node.textContent?.toLowerCase(); + if (!haystack) { + return; + } + + const ranges = new Map(); + terms.forEach((needle) => { + const index = haystack.indexOf(needle); + if (index !== -1) { + ranges.set(index, index + needle.length); + } + }); + const sortedRanges = Array.from(ranges.entries()).sort( + ([x1, y1], [x2, y2]) => x1 - x2 || y1 - y2 + ); + + const span = this.replaceChildNode(node, "span"); + span.className = "highlight-container"; + + let rest = span.childNodes[0] as Node & Text; + let cursor = 0; + + for (const [rangeBegin, rangeEnd] of sortedRanges) { + if (rangeBegin < cursor) { + // Just ignore conflicting range. + continue; + } + + // Split. + const match = rest.splitText(rangeBegin - cursor); + const newRest = match.splitText(rangeEnd - rangeBegin); + + // Convert text node to HTML element. + this.replaceChildNode(match, "mark"); + + rest = newRest as Element & Text; + cursor = rangeEnd; + } + }); + } + + private getTextNodesOf(node: Node): (Node & Text)[] { + const parents = [node]; + const nodes: (Node & Text)[] = []; + + for (const parent of parents) { + for (const childNode of parent.childNodes) { + if (childNode.nodeType === Node.TEXT_NODE) { + nodes.push(childNode as Node & Text); + } else if (childNode.hasChildNodes()) { + parents.push(childNode); + } + } + } + + return nodes; + } + + private replaceChildNode(node: ChildNode, tagName: string) { + const text = node.textContent; + const newNode = document.createElement(tagName); + newNode.innerText = text ?? ""; + node.replaceWith(newNode); + return newNode; + } + + private showHeading(heading: HTMLElement) { + const container = heading && this.getContainerOf(heading); + if (container) { + this.toggleElement(container, true); + } + } + + private getHeadingOf(el: HTMLElement) { + return this.findFirstElementBefore(el, this.allHeadings); + } + + private findFirstElementBefore(el: HTMLElement, candidates: HTMLElement[]) { + return candidates + .slice() + .reverse() + .find( + (candidate) => + candidate.compareDocumentPosition(el) & + Node.DOCUMENT_POSITION_FOLLOWING + ); + } + + private expandParent(parent: HTMLDetailsElement) { + const container = this.getContainerOf(parent); + this.toggleElement(container, true); + parent.open = true; + } + + private getParentsOf(el: HTMLElement) { + const parents: HTMLDetailsElement[] = []; + let parent = el.parentElement?.closest("details"); + + while (parent) { + if (parent instanceof HTMLDetailsElement) { + parents.push(parent); + } + parent = parent.parentElement?.closest("details"); + } + + return parents; + } +} diff --git a/client/src/document/organisms/sidebar/filter.scss b/client/src/document/organisms/sidebar/filter.scss new file mode 100644 index 000000000000..671788827e3e --- /dev/null +++ b/client/src/document/organisms/sidebar/filter.scss @@ -0,0 +1,144 @@ +@use "../../../ui/vars.scss" as *; + +.sidebar-filter-container { + background: linear-gradient( + to bottom, + var(--background-primary) 0% calc(100% - 2rem), + rgb(0, 0, 0, 0) 100% + ); + display: flex; + flex-direction: column; + font-size: var(--type-smaller-font-size); + padding-bottom: 2rem; + padding-right: 0.5rem; + padding-top: 0.5rem; + + @media screen and (max-width: $screen-md) { + padding-bottom: unset; + } + + .sidebar-filter { + align-items: center; + display: flex; + margin-bottom: 0.5rem; + + &.has-input { + .sidebar-filter-label .icon { + background-color: var(--category-color); + } + } + } + + .sidebar-filter-label { + left: 0.5rem; /* Moves icon inside field. */ + position: relative; + width: 0; /* Avoid moving field right. */ + + .icon { + background-size: 1rem; + height: 1rem; + mask-size: 1rem; + width: 1rem; + } + } + + .sidebar-filter-input-field { + -webkit-appearance: none; /* stylelint-disable-line property-no-vendor-prefix */ + background-color: var(--background-primary); + border: 1px solid var(--border-primary); + border-radius: 1rem; + color: var(--text-primary); + height: var(--form-elem-height); + padding-left: 1.75rem; + + &:focus { + border-color: var(--category-color); + box-shadow: 0 0 0 3px var(--blend-color), 0 0 0 3px var(--category-color); + outline: 0 none; + } + + &[value=""] { + width: 5rem; + } + + &:not([value=""]) ~ .sidebar-filter-label .icon { + background-color: var(--category-color) !important; + } + + &:focus, + &.is-active { + padding-right: 7rem; + width: 100%; + + ~ .sidebar-filter-count { + display: block; + } + + ~ .button.clear-sidebar-filter-button { + display: block; + } + } + + ~ .sidebar-filter-count { + background: var(--mark-color); + border-radius: 1rem; + display: none; + font-size: var(--type-tiny-font-size); + padding: 0 0.25rem; + position: absolute; + right: 2.5rem; + + @media screen and (max-width: $screen-md) { + right: 3rem; + } + } + + ~ .button.clear-sidebar-filter-button { + display: none; + position: absolute; + right: 0.75rem; + + &:hover { + background: transparent; + } + + @media screen and (max-width: $screen-md) { + left: calc(100% - 3rem); + } + } + } + + .button { + --button-color: var(--icon-secondary); + --button-height: 1.5rem; + --button-padding: 0; + width: 1.5rem; + } + + .icon { + background-color: var(--icon-secondary); + margin-right: unset; + position: unset; + position: relative; + z-index: unset; + } + + .sidebar-filter-footer { + background: var(--background-primary); + border: 1px solid var(--background-primary); + + .glean-thumbs { + font-size: var(--type-tiny-font-size); + height: 1.5rem; + margin-bottom: 0.5rem; + margin-left: 0.6rem; + } + } +} + +.sidebar { + mark { + background-color: var(--mark-color); + color: unset; + } +} diff --git a/client/src/document/organisms/sidebar/filter.tsx b/client/src/document/organisms/sidebar/filter.tsx new file mode 100644 index 000000000000..49d509a68130 --- /dev/null +++ b/client/src/document/organisms/sidebar/filter.tsx @@ -0,0 +1,159 @@ +import { MutableRefObject, useEffect, useRef, useState } from "react"; +import { SidebarFilterer } from "./SidebarFilterer"; +import { Button } from "../../../ui/atoms/button"; +import { GleanThumbs } from "../../../ui/atoms/thumbs"; + +import "./filter.scss"; +import { useGleanClick } from "../../../telemetry/glean-context"; +import { SIDEBAR_FILTER_FOCUS } from "../../../telemetry/constants"; + +export function SidebarFilter() { + const [isActive, setActive] = useState(false); + const { query, setQuery, matchCount } = useSidebarFilter(); + const gleanClick = useGleanClick(); + + useEffect(() => { + if (isActive) { + gleanClick(SIDEBAR_FILTER_FOCUS); + } + }, [gleanClick, isActive]); + + return ( +
+
+ + setActive(true)} + onChange={(event) => setQuery(event.target.value)} + /> + {matchCount !== undefined && ( + + {matchCount === 0 + ? "No matches" + : `${matchCount} ${matchCount === 1 ? "match" : "matches"}`} + + )} + +
+ {isActive && ( +
+
+ +
+
+ )} +
+ ); +} + +function useSidebarFilter() { + const [query, setQuery] = useState(""); + const [matchCount, setMatchCount] = useState(undefined); + const filtererRef = useRef(null); + const quicklinksRef = useRef(null); + const sidebarInnerNavRef = useRef(null); // Scrolls on mobile. + const { saveScrollPosition, restoreScrollPosition } = + usePersistedScrollPosition(quicklinksRef, sidebarInnerNavRef); + + useEffect(() => { + const quicklinks = document.getElementById("sidebar-quicklinks"); + // Scrolls on desktop. + quicklinksRef.current = quicklinks; + // Scrolls on mobile. + sidebarInnerNavRef.current = + quicklinks?.querySelector(".sidebar-inner-nav") ?? null; + }, []); + + useEffect(() => { + const quicklinks = quicklinksRef.current; + if (!quicklinks) { + return; + } + + // Filter sidebar. + let filterer = filtererRef.current; + if (!filterer) { + const root = quicklinks.querySelector(".sidebar-body"); + + if (!root) { + return; + } + + filterer = new SidebarFilterer(root); + filtererRef.current = filterer; + } + + const trimmedQuery = query.trim(); + + // Save scroll position. + if (trimmedQuery) { + saveScrollPosition(); + } + + const items = filterer.applyFilter(trimmedQuery); + setMatchCount(items); + + // Restore scroll position. + if (!trimmedQuery) { + restoreScrollPosition(); + } + }, [query, saveScrollPosition, restoreScrollPosition]); + + return { + query, + setQuery, + matchCount, + }; +} + +function usePersistedScrollPosition( + ...refs: Array> +) { + return { + saveScrollPosition() { + refs.forEach((ref) => { + const el = ref.current; + if ( + el && + typeof el.dataset.lastScrollTop === "undefined" && + el.scrollTop > 0 + ) { + el.dataset.lastScrollTop = String(el.scrollTop); + el.scrollTop = 0; + } + }); + }, + restoreScrollPosition() { + refs.forEach((ref) => { + const el = ref.current; + if (el && typeof el.dataset.lastScrollTop === "string") { + el.scrollTop = Number(el.dataset.lastScrollTop); + delete el.dataset.lastScrollTop; + } + }); + }, + }; +} diff --git a/client/src/document/organisms/sidebar/index.scss b/client/src/document/organisms/sidebar/index.scss index dfee075f85e7..94c9c5ec9858 100644 --- a/client/src/document/organisms/sidebar/index.scss +++ b/client/src/document/organisms/sidebar/index.scss @@ -77,6 +77,30 @@ } } + .sidebar-actions { + height: 0; + padding-bottom: 4rem; + position: sticky; + top: 0; + z-index: var(--z-index-main-header); + + ~ .sidebar-inner-nav { + margin-top: 2.5rem; /* Reduce to 0.5rem once SidebarFilter feedback is removed. */ + } + + @media screen and (max-width: $screen-md) { + height: unset; + margin-top: unset; + padding-bottom: unset; + position: unset; + top: unset; + + ~ .sidebar-inner-nav { + margin-top: unset; + } + } + } + /* This is for sidebars where there is no sub-heading * for example: https://developer-mozilla.org/en-US/docs/Web/HTML/Element/progress */ diff --git a/client/src/document/organisms/sidebar/index.tsx b/client/src/document/organisms/sidebar/index.tsx index 574feb7b7066..c908134b2ea1 100644 --- a/client/src/document/organisms/sidebar/index.tsx +++ b/client/src/document/organisms/sidebar/index.tsx @@ -8,6 +8,7 @@ import "./index.scss"; import { TOC } from "../toc"; import { PLACEMENT_ENABLED } from "../../../env"; import { SidePlacement } from "../../../ui/organisms/placement"; +import { SidebarFilter } from "./filter"; export function SidebarContainer({ doc, @@ -64,6 +65,9 @@ export function SidebarContainer({ aria-label="Collapse sidebar" />