Merge of Insiders features tied to 'Biquinho Vermelho' funding goal

This commit is contained in:
squidfunk
2021-07-21 17:08:10 +02:00
parent ffd62bca3c
commit d68fe9102a
43 changed files with 889 additions and 224 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,10 +39,10 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block styles %} {% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.3754935a.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/main.fe914879.min.css' | url }}">
{% if config.theme.palette %} {% if config.theme.palette %}
{% set palette = config.theme.palette %} {% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.f1a3b89f.min.css' | url }}"> <link rel="stylesheet" href="{{ 'assets/stylesheets/palette.ba0d045b.min.css' | url }}">
{% if palette.primary %} {% if palette.primary %}
{% import "partials/palette.html" as map %} {% import "partials/palette.html" as map %}
{% set primary = map.primary( {% set primary = map.primary(
@@ -196,7 +196,7 @@
"base": base_url, "base": base_url,
"features": features, "features": features,
"translations": {}, "translations": {},
"search": "assets/javascripts/workers/search.477d984a.min.js" | url, "search": "assets/javascripts/workers/search.53c85856.min.js" | url,
"version": config.extra.version or None "version": config.extra.version or None
} -%} } -%}
{%- set translations = app.translations -%} {%- set translations = app.translations -%}
@@ -223,7 +223,7 @@
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/bundle.ddd52ceb.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.716f8af4.min.js' | url }}"></script>
{% for path in config["extra_javascript"] %} {% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script> <script src="{{ path | url }}"></script>
{% endfor %} {% endfor %}

View File

@@ -35,5 +35,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{ 'overrides/assets/javascripts/bundle.89b9c269.min.js' | url }}"></script> <script src="{{ 'overrides/assets/javascripts/bundle.1d33a92e.min.js' | url }}"></script>
{% endblock %} {% endblock %}

View File

@@ -12,6 +12,8 @@
"meta.source": "Quellcode", "meta.source": "Quellcode",
"search.config.lang": "de", "search.config.lang": "de",
"search.placeholder": "Suche", "search.placeholder": "Suche",
"search.share": "Teilen",
"search.reset": "Zurücksetzen",
"search.result.initializer": "Suche wird initialisiert", "search.result.initializer": "Suche wird initialisiert",
"search.result.placeholder": "Suchbegriff eingeben", "search.result.placeholder": "Suchbegriff eingeben",
"search.result.none": "Keine Suchergebnisse", "search.result.none": "Keine Suchergebnisse",
@@ -20,6 +22,7 @@
"search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite", "search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite",
"search.result.more.other": "# weitere Suchergebnisse auf dieser Seite", "search.result.more.other": "# weitere Suchergebnisse auf dieser Seite",
"search.result.term.missing": "Es fehlt", "search.result.term.missing": "Es fehlt",
"search.title": "Suche",
"select.language.title": "Sprache wechseln", "select.language.title": "Sprache wechseln",
"select.version.title": "Version auswählen", "select.version.title": "Version auswählen",
"skip.link.title": "Zum Inhalt", "skip.link.title": "Zum Inhalt",

View File

@@ -18,6 +18,7 @@
"search.config.pipeline": "trimmer, stopWordFilter", "search.config.pipeline": "trimmer, stopWordFilter",
"search.config.separator": "[\s\-]+", "search.config.separator": "[\s\-]+",
"search.placeholder": "Search", "search.placeholder": "Search",
"search.share": "Share",
"search.reset": "Clear", "search.reset": "Clear",
"search.result.initializer": "Initializing search", "search.result.initializer": "Initializing search",
"search.result.placeholder": "Type to start searching", "search.result.placeholder": "Type to start searching",
@@ -27,6 +28,7 @@
"search.result.more.one": "1 more on this page", "search.result.more.one": "1 more on this page",
"search.result.more.other": "# more on this page", "search.result.more.other": "# more on this page",
"search.result.term.missing": "Missing", "search.result.term.missing": "Missing",
"search.title": "Search",
"select.language.title": "Select language", "select.language.title": "Select language",
"select.version.title": "Select version", "select.version.title": "Select version",
"skip.link.title": "Skip to content", "skip.link.title": "Skip to content",

View File

@@ -6,14 +6,24 @@
<label class="md-search__overlay" for="__search"></label> <label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search"> <div class="md-search__inner" role="search">
<form class="md-search__form" name="search"> <form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="{{ lang.t('search.placeholder') }}" placeholder="{{ lang.t('search.placeholder') }}" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" data-md-state="active" required> <input type="text" class="md-search__input" name="query" aria-label="{{ lang.t('search.placeholder') }}" placeholder="{{ lang.t('search.placeholder') }}" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
<label class="md-search__icon md-icon" for="__search"> <label class="md-search__icon md-icon" for="__search">
{% include ".icons/material/magnify.svg" %} {% include ".icons/material/magnify.svg" %}
{% include ".icons/material/arrow-left.svg" %} {% include ".icons/material/arrow-left.svg" %}
</label> </label>
<button type="reset" class="md-search__icon md-icon" aria-label="{{ lang.t('search.reset') }}" tabindex="-1"> <nav class="md-search__options" aria-label="{{ lang.t('search.title') }}">
{% include ".icons/material/close.svg" %} {% if "search.share" in features %}
</button> <a href="javascript:void(0)" class="md-search__icon md-icon" aria-label="{{ lang.t('search.share') }}" data-clipboard data-clipboard-text="" data-md-component="search-share" tabindex="-1">
{% include ".icons/material/share-variant.svg" %}
</a>
{% endif %}
<button type="reset" class="md-search__icon md-icon" aria-label="{{ lang.t('search.reset') }}" tabindex="-1">
{% include ".icons/material/close.svg" %}
</button>
</nav>
{% if "search.suggest" in features %}
<div class="md-search__suggest" data-md-component="search-suggest"></div>
{% endif %}
</form> </form>
<div class="md-search__output"> <div class="md-search__output">
<div class="md-search__scrollwrap" data-md-scrollfix> <div class="md-search__scrollwrap" data-md-scrollfix>

View File

@@ -36,6 +36,9 @@ export type Flag =
| "navigation.sections" /* Sections navigation */ | "navigation.sections" /* Sections navigation */
| "navigation.tabs" /* Tabs navigation */ | "navigation.tabs" /* Tabs navigation */
| "navigation.top" /* Back-to-top button */ | "navigation.top" /* Back-to-top button */
| "search.highlight" /* Search highlighting */
| "search.share" /* Search sharing */
| "search.suggest" /* Search suggestions */
| "toc.integrate" /* Integrated table of contents */ | "toc.integrate" /* Integrated table of contents */
/* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */

View File

@@ -55,6 +55,7 @@ import {
mountHeaderTitle, mountHeaderTitle,
mountPalette, mountPalette,
mountSearch, mountSearch,
mountSearchHiglight,
mountSidebar, mountSidebar,
mountSource, mountSource,
mountTableOfContents, mountTableOfContents,
@@ -195,6 +196,13 @@ const content$ = defer(() => merge(
...getComponentElements("content") ...getComponentElements("content")
.map(el => mountContent(el, { target$, viewport$, print$ })), .map(el => mountContent(el, { target$, viewport$, print$ })),
/* Search highlighting */
...getComponentElements("content")
.map(el => feature("search.highlight")
? mountSearchHiglight(el, { index$, location$ })
: NEVER
),
/* Header title */ /* Header title */
...getComponentElements("header-title") ...getComponentElements("header-title")
.map(el => mountHeaderTitle(el, { viewport$, header$ })), .map(el => mountHeaderTitle(el, { viewport$, header$ })),

View File

@@ -42,6 +42,8 @@ export type ComponentType =
| "search" /* Search */ | "search" /* Search */
| "search-query" /* Search input */ | "search-query" /* Search input */
| "search-result" /* Search results */ | "search-result" /* Search results */
| "search-share" /* Search sharing */
| "search-suggest" /* Search suggestions */
| "sidebar" /* Sidebar */ | "sidebar" /* Sidebar */
| "skip" /* Skip link */ | "skip" /* Skip link */
| "source" /* Repository information */ | "source" /* Repository information */
@@ -83,6 +85,8 @@ interface ComponentTypeMap {
"search": HTMLElement /* Search */ "search": HTMLElement /* Search */
"search-query": HTMLInputElement /* Search input */ "search-query": HTMLInputElement /* Search input */
"search-result": HTMLElement /* Search results */ "search-result": HTMLElement /* Search results */
"search-share": HTMLAnchorElement /* Search sharing */
"search-suggest": HTMLElement /* Search suggestions */
"sidebar": HTMLElement /* Sidebar */ "sidebar": HTMLElement /* Sidebar */
"skip": HTMLAnchorElement /* Skip link */ "skip": HTMLAnchorElement /* Skip link */
"source": HTMLAnchorElement /* Repository information */ "source": HTMLAnchorElement /* Repository information */

View File

@@ -21,7 +21,7 @@
*/ */
import { NEVER, Observable, ObservableInput, merge } from "rxjs" import { NEVER, Observable, ObservableInput, merge } from "rxjs"
import { filter, sample, take } from "rxjs/operators" import { filter, mergeWith, sample, take } from "rxjs/operators"
import { configuration } from "~/_" import { configuration } from "~/_"
import { import {
@@ -34,14 +34,21 @@ import {
} from "~/browser" } from "~/browser"
import { import {
SearchIndex, SearchIndex,
SearchResult,
isSearchQueryMessage, isSearchQueryMessage,
isSearchReadyMessage, isSearchReadyMessage,
setupSearchWorker setupSearchWorker
} from "~/integrations" } from "~/integrations"
import { Component, getComponentElement } from "../../_" import {
Component,
getComponentElement,
getComponentElements
} from "../../_"
import { SearchQuery, mountSearchQuery } from "../query" import { SearchQuery, mountSearchQuery } from "../query"
import { SearchResult, mountSearchResult } from "../result" import { mountSearchResult } from "../result"
import { SearchShare, mountSearchShare } from "../share"
import { SearchSuggest, mountSearchSuggest } from "../suggest"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Types * Types
@@ -53,6 +60,8 @@ import { SearchResult, mountSearchResult } from "../result"
export type Search = export type Search =
| SearchQuery | SearchQuery
| SearchResult | SearchResult
| SearchShare
| SearchSuggest
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Helper types * Helper types
@@ -88,7 +97,7 @@ export function mountSearch(
try { try {
const worker = setupSearchWorker(config.search, index$) const worker = setupSearchWorker(config.search, index$)
/* Retrieve nested components */ /* Retrieve query and result components */
const query = getComponentElement("search-query", el) const query = getComponentElement("search-query", el)
const result = getComponentElement("search-result", el) const result = getComponentElement("search-result", el)
@@ -97,8 +106,12 @@ export function mountSearch(
tx$ tx$
.pipe( .pipe(
filter(isSearchQueryMessage), filter(isSearchQueryMessage),
sample(rx$.pipe(filter(isSearchReadyMessage))), sample(rx$
take(1) .pipe(
filter(isSearchReadyMessage),
take(1)
)
)
) )
.subscribe(tx$.next.bind(tx$)) .subscribe(tx$.next.bind(tx$))
@@ -111,10 +124,28 @@ export function mountSearch(
const active = getActiveElement() const active = getActiveElement()
switch (key.type) { switch (key.type) {
/* Enter: prevent form submission */ /* Enter: go to first (best) result */
case "Enter": case "Enter":
if (active === query) if (active === query) {
const anchors = new Map<HTMLAnchorElement, number>()
for (const anchor of getElements<HTMLAnchorElement>(
":first-child [href]", result
)) {
const article = anchor.firstElementChild!
anchors.set(anchor, parseFloat(
article.getAttribute("data-md-score")!
))
}
/* Go to result with highest score, if any */
if (anchors.size) {
const [[best]] = [...anchors].sort(([, a], [, b]) => b - a)
best.click()
}
/* Otherwise omit form submission */
key.claim() key.claim()
}
break break
/* Escape or Tab: close search */ /* Escape or Tab: close search */
@@ -173,11 +204,21 @@ export function mountSearch(
}) })
/* Create and return component */ /* Create and return component */
const query$ = mountSearchQuery(query, worker) const query$ = mountSearchQuery(query, worker)
return merge( const result$ = mountSearchResult(result, worker, { query$ })
query$, return merge(query$, result$)
mountSearchResult(result, worker, { query$ }) .pipe(
) mergeWith(
/* Search sharing */
...getComponentElements("search-share", el)
.map(child => mountSearchShare(child, { query$ })),
/* Search suggestions */
...getComponentElements("search-suggest", el)
.map(child => mountSearchSuggest(child, worker, { keyboard$ }))
)
)
/* Gracefully handle broken search */ /* Gracefully handle broken search */
} catch (err) { } catch (err) {

View File

@@ -0,0 +1,5 @@
{
"rules": {
"no-null/no-null": "off"
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2016-2021 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
ObservableInput,
combineLatest
} from "rxjs"
import { filter, map, startWith } from "rxjs/operators"
import { getLocation } from "~/browser"
import {
SearchIndex,
setupSearchHighlighter
} from "~/integrations"
import { h } from "~/utilities"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search highlighting
*/
export interface SearchHighlight {
nodes: Map<ChildNode, string> /* Map of replacements */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
index$: ObservableInput<SearchIndex> /* Search index observable */
location$: Observable<URL> /* Location observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search highlighting
*
* @param el - Content element
* @param options - Options
*
* @returns Search highlighting component observable
*/
export function mountSearchHiglight(
el: HTMLElement, { index$, location$ }: MountOptions
): Observable<Component<SearchHighlight>> {
return combineLatest([
index$,
location$
.pipe(
startWith(getLocation()),
filter(url => url.searchParams.has("h"))
)
])
.pipe(
map(([index, url]) => setupSearchHighlighter(index.config)(
url.searchParams.get("h")!
)),
map(fn => {
const nodes = new Map<ChildNode, string>()
/* Traverse text nodes and collect matches */
const it = document.createNodeIterator(el, NodeFilter.SHOW_TEXT)
for (let node = it.nextNode(); node; node = it.nextNode()) {
if (node.parentElement?.offsetHeight) {
const original = node.textContent!
const replaced = fn(original)
if (replaced.length > original.length)
nodes.set(node as ChildNode, replaced)
}
}
/* Replace original nodes with matches */
for (const [node, text] of nodes) {
const { childNodes } = h("span", null, text)
node.replaceWith(...Array.from(childNodes))
}
/* Return component */
return { ref: el, nodes }
})
)
}

View File

@@ -21,5 +21,8 @@
*/ */
export * from "./_" export * from "./_"
export * from "./highlight"
export * from "./query" export * from "./query"
export * from "./result" export * from "./result"
export * from "./share"
export * from "./suggest"

View File

@@ -31,8 +31,10 @@ import {
delay, delay,
distinctUntilChanged, distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
filter,
finalize, finalize,
map, map,
take,
takeLast, takeLast,
takeUntil, takeUntil,
tap tap
@@ -43,6 +45,7 @@ import {
setSearchQueryPlaceholder setSearchQueryPlaceholder
} from "~/actions" } from "~/actions"
import { import {
getLocation,
setElementFocus, setElementFocus,
setToggle, setToggle,
watchElementFocus watchElementFocus
@@ -51,7 +54,8 @@ import {
SearchMessageType, SearchMessageType,
SearchQueryMessage, SearchQueryMessage,
SearchWorker, SearchWorker,
defaultTransform defaultTransform,
isSearchReadyMessage
} from "~/integrations" } from "~/integrations"
import { Component } from "../../_" import { Component } from "../../_"
@@ -79,11 +83,12 @@ export interface SearchQuery {
* is delayed by `1ms` so the input's empty state is allowed to propagate. * is delayed by `1ms` so the input's empty state is allowed to propagate.
* *
* @param el - Search query element * @param el - Search query element
* @param worker - Search worker
* *
* @returns Search query observable * @returns Search query observable
*/ */
export function watchSearchQuery( export function watchSearchQuery(
el: HTMLInputElement el: HTMLInputElement, { rx$ }: SearchWorker
): Observable<SearchQuery> { ): Observable<SearchQuery> {
const fn = __search?.transform || defaultTransform const fn = __search?.transform || defaultTransform
@@ -98,6 +103,21 @@ export function watchSearchQuery(
distinctUntilChanged() distinctUntilChanged()
) )
/* Intercept deep links */
const location = getLocation()
if (location.searchParams.has("q")) {
setToggle("search", true)
rx$
.pipe(
filter(isSearchReadyMessage),
take(1)
)
.subscribe(() => {
el.value = location.searchParams.get("q")!
setElementFocus(el)
})
}
/* Combine into single observable */ /* Combine into single observable */
return combineLatest([value$, focus$]) return combineLatest([value$, focus$])
.pipe( .pipe(
@@ -114,7 +134,7 @@ export function watchSearchQuery(
* @returns Search query component observable * @returns Search query component observable
*/ */
export function mountSearchQuery( export function mountSearchQuery(
el: HTMLInputElement, { tx$ }: SearchWorker el: HTMLInputElement, { tx$, rx$ }: SearchWorker
): Observable<Component<SearchQuery, HTMLInputElement>> { ): Observable<Component<SearchQuery, HTMLInputElement>> {
const internal$ = new Subject<SearchQuery>() const internal$ = new Subject<SearchQuery>()
@@ -151,7 +171,7 @@ export function mountSearchQuery(
.subscribe(() => setElementFocus(el)) .subscribe(() => setElementFocus(el))
/* Create and return component */ /* Create and return component */
return watchSearchQuery(el) return watchSearchQuery(el, { tx$, rx$ })
.pipe( .pipe(
tap(internal$), tap(internal$),
finalize(() => internal$.complete()), finalize(() => internal$.complete()),

View File

@@ -33,7 +33,6 @@ import {
finalize, finalize,
map, map,
observeOn, observeOn,
startWith,
switchMap, switchMap,
take, take,
tap, tap,
@@ -52,27 +51,16 @@ import {
watchElementThreshold watchElementThreshold
} from "~/browser" } from "~/browser"
import { import {
SearchResult as SearchResultData, SearchResult,
SearchWorker, SearchWorker,
isSearchReadyMessage, isSearchReadyMessage,
isSearchResultMessage isSearchResultMessage
} from "~/integrations" } from "~/integrations"
import { renderSearchResult } from "~/templates" import { renderSearchResultItem } from "~/templates"
import { Component } from "../../_" import { Component } from "../../_"
import { SearchQuery } from "../query" import { SearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search result
*/
export interface SearchResult {
data: SearchResultData[] /* Search result data */
}
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Helper types * Helper types
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
@@ -129,9 +117,9 @@ export function mountSearchResult(
observeOn(animationFrameScheduler), observeOn(animationFrameScheduler),
withLatestFrom(query$) withLatestFrom(query$)
) )
.subscribe(([{ data }, { value }]) => { .subscribe(([{ items }, { value }]) => {
if (value) if (value)
setSearchResultMeta(meta, data.length) setSearchResultMeta(meta, items.length)
else else
resetSearchResultMeta(meta) resetSearchResultMeta(meta)
}) })
@@ -141,9 +129,9 @@ export function mountSearchResult(
.pipe( .pipe(
observeOn(animationFrameScheduler), observeOn(animationFrameScheduler),
tap(() => resetSearchResultList(list)), tap(() => resetSearchResultList(list)),
switchMap(({ data }) => merge( switchMap(({ items }) => merge(
of(...data.slice(0, 10)), of(...items.slice(0, 10)),
of(...data.slice(10)) of(...items.slice(10))
.pipe( .pipe(
bufferCount(4), bufferCount(4),
zipWith(boundary$), zipWith(boundary$),
@@ -152,15 +140,14 @@ export function mountSearchResult(
)) ))
) )
.subscribe(result => { .subscribe(result => {
addToSearchResultList(list, renderSearchResult(result)) addToSearchResultList(list, renderSearchResultItem(result))
}) })
/* Filter search result list */ /* Filter search result message */
const result$ = rx$ const result$ = rx$
.pipe( .pipe(
filter(isSearchResultMessage), filter(isSearchResultMessage),
map(({ data }) => ({ data })), map(({ data }) => data)
startWith({ data: [] })
) )
/* Create and return component */ /* Create and return component */

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2016-2021 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
fromEvent
} from "rxjs"
import {
finalize,
map,
tap
} from "rxjs/operators"
import { getLocation } from "~/browser"
import { Component } from "../../_"
import { SearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search sharing
*/
export interface SearchShare {
url: URL /* Deep link for sharing */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
query$: Observable<SearchQuery> /* Search query observable */
}
/**
* Mount options
*/
interface MountOptions {
query$: Observable<SearchQuery> /* Search query observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search sharing
*
* @param _el - Search sharing element
* @param options - Options
*
* @returns Search sharing observable
*/
export function watchSearchShare(
_el: HTMLElement, { query$ }: WatchOptions
): Observable<SearchShare> {
return query$
.pipe(
map(({ value }) => {
const url = getLocation()
url.hash = ""
url.searchParams.delete("h")
url.searchParams.set("q", value)
return { url }
})
)
}
/**
* Mount search sharing
*
* @param el - Search sharing element
* @param options - Options
*
* @returns Search sharing component observable
*/
export function mountSearchShare(
el: HTMLAnchorElement, options: MountOptions
): Observable<Component<SearchShare>> {
const internal$ = new Subject<SearchShare>()
internal$.subscribe(({ url }) => {
el.setAttribute("data-clipboard-text", el.href)
el.href = `${url}`
})
/* Prevent following of link */
fromEvent(el, "click")
.subscribe(ev => ev.preventDefault())
/* Create and return component */
return watchSearchShare(el, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright (c) 2016-2021 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import {
Observable,
Subject,
asyncScheduler,
fromEvent
} from "rxjs"
import {
combineLatestWith,
distinctUntilChanged,
filter,
finalize,
map,
observeOn,
tap
} from "rxjs/operators"
import { Keyboard } from "~/browser"
import {
SearchResult,
SearchWorker,
isSearchResultMessage
} from "~/integrations"
import { Component, getComponentElement } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search suggestions
*/
export interface SearchSuggest {}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
keyboard$: Observable<Keyboard> /* Keyboard observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search suggestions
*
* This function will perform a lazy rendering of the search results, depending
* on the vertical offset of the search result container.
*
* @param el - Search result list element
* @param worker - Search worker
* @param options - Options
*
* @returns Search result list component observable
*/
export function mountSearchSuggest(
el: HTMLElement, { rx$ }: SearchWorker, { keyboard$ }: MountOptions
): Observable<Component<SearchSuggest>> {
const internal$ = new Subject<SearchResult>()
/* Retrieve query component and track all changes */
const query = getComponentElement("search-query")
const query$ = fromEvent(query, "keydown")
.pipe(
observeOn(asyncScheduler),
map(() => query.value),
distinctUntilChanged(),
)
/* Update search suggestions */
internal$
.pipe(
combineLatestWith(query$),
map(([{ suggestions }, value]) => {
const words = value.split(/([\s-]+)/)
if (suggestions?.length && words[words.length - 1]) {
const last = suggestions[suggestions.length - 1]
if (last.startsWith(words[words.length - 1]))
words[words.length - 1] = last
} else {
words.length = 0
}
return words
})
)
.subscribe(words => el.innerHTML = words
.join("")
.replace(/\s/g, "&nbsp;")
)
/* Set up search keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "search")
)
.subscribe(key => {
switch (key.type) {
/* Right arrow: accept current suggestion */
case "ArrowRight":
if (
el.innerText.length &&
query.selectionStart === query.value.length
)
query.value = el.innerText
break
}
})
/* Filter search result message */
const result$ = rx$
.pipe(
filter(isSearchResultMessage),
map(({ data }) => data)
)
/* Create and return component */
return result$
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(() => ({ ref: el }))
)
}

View File

@@ -29,6 +29,7 @@ import {
SearchHighlightFactoryFn, SearchHighlightFactoryFn,
setupSearchHighlighter setupSearchHighlighter
} from "../highlighter" } from "../highlighter"
import { SearchOptions } from "../options"
import { import {
SearchQueryTerms, SearchQueryTerms,
getSearchQueryTerms, getSearchQueryTerms,
@@ -58,21 +59,6 @@ export interface SearchIndexDocument {
/* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */
/**
* Search index pipeline function
*/
export type SearchIndexPipelineFn =
| "trimmer" /* Trimmer */
| "stopWordFilter" /* Stop word filter */
| "stemmer" /* Stemmer */
/**
* Search index pipeline
*/
export type SearchIndexPipeline = SearchIndexPipelineFn[]
/* ------------------------------------------------------------------------- */
/** /**
* Search index * Search index
* *
@@ -83,7 +69,7 @@ export interface SearchIndex {
config: SearchIndexConfig /* Search index configuration */ config: SearchIndexConfig /* Search index configuration */
docs: SearchIndexDocument[] /* Search index documents */ docs: SearchIndexDocument[] /* Search index documents */
index?: object /* Prebuilt index */ index?: object /* Prebuilt index */
pipeline?: SearchIndexPipeline /* Search index pipeline */ options: SearchOptions /* Search options */
} }
/* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */
@@ -98,10 +84,25 @@ export interface SearchMetadata {
/* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */
/**
* Search result document
*/
export type SearchResultDocument = SearchDocument & SearchMetadata
/**
* Search result item
*/
export type SearchResultItem = SearchResultDocument[]
/* ------------------------------------------------------------------------- */
/** /**
* Search result * Search result
*/ */
export type SearchResult = Array<SearchDocument & SearchMetadata> export interface SearchResult {
items: SearchResultItem[] /* Search result items */
suggestions?: string[] /* Search suggestions */
}
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
@@ -151,12 +152,20 @@ export class Search {
*/ */
protected index: lunr.Index protected index: lunr.Index
/**
* Search options
*/
protected options: SearchOptions
/** /**
* Create the search integration * Create the search integration
* *
* @param data - Search index * @param data - Search index
*/ */
public constructor({ config, docs, pipeline, index }: SearchIndex) { public constructor({ config, docs, index, options }: SearchIndex) {
this.options = options
/* Set up document map and highlighter factory */
this.documents = setupSearchDocumentMap(docs) this.documents = setupSearchDocumentMap(docs)
this.highlight = setupSearchHighlighter(config) this.highlight = setupSearchHighlighter(config)
@@ -177,7 +186,7 @@ export class Search {
/* Compute functions to be removed from the pipeline */ /* Compute functions to be removed from the pipeline */
const fns = difference([ const fns = difference([
"trimmer", "stopWordFilter", "stemmer" "trimmer", "stopWordFilter", "stemmer"
], pipeline!) ], options.pipeline)
/* Remove functions from the pipeline for registered languages */ /* Remove functions from the pipeline for registered languages */
for (const lang of config.lang.map(language => ( for (const lang of config.lang.map(language => (
@@ -189,11 +198,13 @@ export class Search {
} }
} }
/* Set up fields and reference */ /* Set up reference */
this.field("title", { boost: 1000 })
this.field("text")
this.ref("location") this.ref("location")
/* Set up fields */
this.field("title", { boost: 1e3 })
this.field("text")
/* Index documents */ /* Index documents */
for (const doc of docs) for (const doc of docs)
this.add(doc) this.add(doc)
@@ -221,7 +232,7 @@ export class Search {
* *
* @returns Search results * @returns Search results
*/ */
public search(query: string): SearchResult[] { public search(query: string): SearchResult {
if (query) { if (query) {
try { try {
const highlight = this.highlight(query) const highlight = this.highlight(query)
@@ -236,7 +247,7 @@ export class Search {
const groups = this.index.search(`${query}*`) const groups = this.index.search(`${query}*`)
/* Apply post-query boosts based on title and search query terms */ /* Apply post-query boosts based on title and search query terms */
.reduce<SearchResult>((results, { ref, score, matchData }) => { .reduce<SearchResultItem>((item, { ref, score, matchData }) => {
const document = this.documents.get(ref) const document = this.documents.get(ref)
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
const { location, title, text, parent } = document const { location, title, text, parent } = document
@@ -249,34 +260,55 @@ export class Search {
/* Highlight title and text and apply post-query boosts */ /* Highlight title and text and apply post-query boosts */
const boost = +!parent + +Object.values(terms).every(t => t) const boost = +!parent + +Object.values(terms).every(t => t)
results.push({ item.push({
location, location,
title: highlight(title), title: highlight(title),
text: highlight(text), text: highlight(text),
score: score * (1 + boost), score: score * (1 + boost),
terms terms
}) })
} }
return results return item
}, []) }, [])
/* Sort search results again after applying boosts */ /* Sort search results again after applying boosts */
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
/* Group search results by page */ /* Group search results by page */
.reduce((results, result) => { .reduce((items, result) => {
const document = this.documents.get(result.location) const document = this.documents.get(result.location)
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
const ref = "parent" in document const ref = "parent" in document
? document.parent!.location ? document.parent!.location
: document.location : document.location
results.set(ref, [...results.get(ref) || [], result]) items.set(ref, [...items.get(ref) || [], result])
} }
return results return items
}, new Map<string, SearchResult>()) }, new Map<string, SearchResultItem>())
/* Expand grouped search results */ /* Generate search suggestions, if desired */
return [...groups.values()] let suggestions: string[] | undefined
if (this.options.suggestions) {
const titles = this.index.query(builder => {
for (const clause of clauses)
builder.term(clause.term, {
fields: ["title"],
presence: lunr.Query.presence.REQUIRED,
wildcard: lunr.Query.wildcard.TRAILING
})
})
/* Retrieve suggestions for best match */
suggestions = titles.length
? Object.keys(titles[0].matchData.metadata)
: []
}
/* Return items and suggestions */
return {
items: [...groups.values()],
...typeof suggestions !== "undefined" && { suggestions }
}
/* Log errors to console (for now) */ /* Log errors to console (for now) */
} catch { } catch {
@@ -285,6 +317,6 @@ export class Search {
} }
/* Return nothing in case of error or empty query */ /* Return nothing in case of error or empty query */
return [] return { items: [] }
} }
} }

View File

@@ -23,5 +23,6 @@
export * from "./_" export * from "./_"
export * from "./document" export * from "./document"
export * from "./highlighter" export * from "./highlighter"
export * from "./options"
export * from "./query" export * from "./query"
export * from "./worker" export * from "./worker"

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2016-2021 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search pipeline function
*/
export type SearchPipelineFn =
| "trimmer" /* Trimmer */
| "stopWordFilter" /* Stop word filter */
| "stemmer" /* Stemmer */
/**
* Search pipeline
*/
export type SearchPipeline = SearchPipelineFn[]
/* ------------------------------------------------------------------------- */
/**
* Search options
*/
export interface SearchOptions {
pipeline: SearchPipeline /* Search pipeline */
suggestions: boolean /* Search suggestions */
}

View File

@@ -23,10 +23,14 @@
import { ObservableInput, Subject, from } from "rxjs" import { ObservableInput, Subject, from } from "rxjs"
import { map, share } from "rxjs/operators" import { map, share } from "rxjs/operators"
import { configuration, translation } from "~/_" import { configuration, feature, translation } from "~/_"
import { WorkerHandler, watchWorker } from "~/browser" import { WorkerHandler, watchWorker } from "~/browser"
import { SearchIndex, SearchIndexPipeline } from "../../_" import { SearchIndex } from "../../_"
import {
SearchOptions,
SearchPipeline
} from "../../options"
import { import {
SearchMessage, SearchMessage,
SearchMessageType, SearchMessageType,
@@ -71,10 +75,16 @@ function setupSearchIndex(
/* Set pipeline from translation */ /* Set pipeline from translation */
const pipeline = translation("search.config.pipeline") const pipeline = translation("search.config.pipeline")
.split(/\s*,\s*/) .split(/\s*,\s*/)
.filter(Boolean) as SearchIndexPipeline .filter(Boolean) as SearchPipeline
/* Determine search options */
const options: SearchOptions = {
pipeline,
suggestions: feature("search.suggest")
}
/* Return search index after defaulting */ /* Return search index after defaulting */
return { config, docs, index, pipeline } return { config, docs, index, options }
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
@@ -105,7 +115,7 @@ export function setupSearchWorker(
.pipe( .pipe(
map(message => { map(message => {
if (isSearchResultMessage(message)) { if (isSearchResultMessage(message)) {
for (const result of message.data) for (const result of message.data.items)
for (const document of result) for (const document of result)
document.location = `${config.base}/${document.location}` document.location = `${config.base}/${document.location}`
} }

View File

@@ -150,7 +150,7 @@ export async function handler(
case SearchMessageType.QUERY: case SearchMessageType.QUERY:
return { return {
type: SearchMessageType.RESULT, type: SearchMessageType.RESULT,
data: index ? index.search(message.data) : [] data: index ? index.search(message.data) : { items: [] }
} }
/* All other messages */ /* All other messages */

View File

@@ -66,7 +66,7 @@ export interface SearchQueryMessage {
*/ */
export interface SearchResultMessage { export interface SearchResultMessage {
type: SearchMessageType.RESULT /* Message type */ type: SearchMessageType.RESULT /* Message type */
data: SearchResult[] /* Message data */ data: SearchResult /* Message data */
} }
/* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */

View File

@@ -20,11 +20,11 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
import { translation } from "~/_" import { feature, translation } from "~/_"
import { import {
SearchDocument, SearchDocument,
SearchMetadata, SearchMetadata,
SearchResult SearchResultItem
} from "~/integrations/search" } from "~/integrations/search"
import { h, truncate } from "~/utilities" import { h, truncate } from "~/utilities"
@@ -65,10 +65,17 @@ function renderSearchDocument(
.flat() .flat()
.slice(0, -1) .slice(0, -1)
/* Assemble query string for highlighting */
const url = new URL(document.location)
if (feature("search.highlight"))
url.searchParams.set("h", Object.entries(document.terms)
.filter(([, match]) => match)
.reduce((highlight, [value]) => `${highlight} ${value}`.trim(), "")
)
/* Render article or section, depending on flags */ /* Render article or section, depending on flags */
const url = document.location
return ( return (
<a href={url} class="md-search-result__link" tabIndex={-1}> <a href={`${url}`} class="md-search-result__link" tabIndex={-1}>
<article <article
class={["md-search-result__article", ...parent class={["md-search-result__article", ...parent
? ["md-search-result__article--document"] ? ["md-search-result__article--document"]
@@ -104,8 +111,8 @@ function renderSearchDocument(
* *
* @returns Element * @returns Element
*/ */
export function renderSearchResult( export function renderSearchResultItem(
result: SearchResult result: SearchResultItem
): HTMLElement { ): HTMLElement {
const threshold = result[0].score const threshold = result[0].score
const docs = [...result] const docs = [...result]

View File

@@ -216,10 +216,32 @@
// Search form // Search form
&__form { &__form {
position: relative; position: relative;
z-index: 2;
height: px2rem(48px);
background-color: var(--md-default-bg-color);
box-shadow: 0 0 px2rem(12px) transparent;
transition:
color 250ms,
background-color 250ms;
// [tablet landscape +]: Header-embedded search // [tablet landscape +]: Header-embedded search
@include break-from-device(tablet landscape) { @include break-from-device(tablet landscape) {
height: px2rem(36px);
background-color: hsla(0, 0%, 0%, 0.26);
border-radius: px2rem(2px); border-radius: px2rem(2px);
// Search form on hover
&:hover {
background-color: hsla(0, 0%, 100%, 0.12);
}
}
// Adjust appearance when search is active
[data-md-toggle="search"]:checked ~ .md-header & {
color: var(--md-default-fg-color);
background-color: var(--md-default-bg-color);
border-radius: px2rem(2px) px2rem(2px) 0 0;
box-shadow: 0 0 px2rem(12px) hsla(0, 0%, 0%, 0.07);
} }
} }
@@ -227,14 +249,12 @@
&__input { &__input {
position: relative; position: relative;
z-index: 2; z-index: 2;
width: 100%;
height: 100%;
padding: 0 px2rem(44px) 0 px2rem(72px); padding: 0 px2rem(44px) 0 px2rem(72px);
font-size: px2rem(18px);
text-overflow: ellipsis; text-overflow: ellipsis;
background-color: var(--md-default-bg-color); background: transparent;
box-shadow: 0 0 px2rem(12px) transparent;
transition:
color 250ms,
background-color 250ms,
box-shadow 250ms;
// Adjust for right-to-left languages // Adjust for right-to-left languages
[dir="rtl"] & { [dir="rtl"] & {
@@ -257,11 +277,6 @@
display: none; display: none;
} }
// Adjust appearance when search is active
[data-md-toggle="search"]:checked ~ .md-header & {
box-shadow: 0 0 px2rem(12px) hsla(0, 0%, 0%, 0.07);
}
// [tablet portrait -]: Search modal // [tablet portrait -]: Search modal
@include break-to-device(tablet portrait) { @include break-to-device(tablet portrait) {
width: 100%; width: 100%;
@@ -271,40 +286,28 @@
// [tablet landscape +]: Header-embedded search // [tablet landscape +]: Header-embedded search
@include break-from-device(tablet landscape) { @include break-from-device(tablet landscape) {
width: 100%;
height: px2rem(36px);
padding-left: px2rem(44px); padding-left: px2rem(44px);
color: inherit; color: inherit;
font-size: px2rem(16px); font-size: px2rem(16px);
background-color: hsla(0, 0%, 0%, 0.26);
border-radius: px2rem(2px);
// Adjust for right-to-left languages // Adjust for right-to-left languages
[dir="rtl"] & { [dir="rtl"] & {
padding-right: px2rem(44px); padding-right: px2rem(44px);
} }
// Search icon
+ .md-search__icon {
color: var(--md-primary-bg-color);
}
// Search placeholder // Search placeholder
&::placeholder { &::placeholder {
color: var(--md-primary-bg-color--light); color: var(--md-primary-bg-color--light);
} }
// Search input on hover // Search icon
&:hover { + .md-search__icon {
background-color: hsla(0, 0%, 100%, 0.12); color: var(--md-primary-bg-color);
} }
// Adjust appearance when search is active // Adjust appearance when search is active
[data-md-toggle="search"]:checked ~ .md-header & { [data-md-toggle="search"]:checked ~ .md-header & {
color: var(--md-default-fg-color);
text-overflow: clip; text-overflow: clip;
background-color: var(--md-default-bg-color);
border-radius: px2rem(2px) px2rem(2px) 0 0;
// Search icon and placeholder // Search icon and placeholder
+ .md-search__icon, + .md-search__icon,
@@ -317,8 +320,7 @@
// Search icon // Search icon
&__icon { &__icon {
position: absolute; display: inline-block;
z-index: 2;
width: px2rem(24px); width: px2rem(24px);
height: px2rem(24px); height: px2rem(24px);
cursor: pointer; cursor: pointer;
@@ -333,8 +335,10 @@
// Search focus button // Search focus button
&[for="__search"] { &[for="__search"] {
position: absolute;
top: px2rem(6px); top: px2rem(6px);
left: px2rem(10px); left: px2rem(10px);
z-index: 2;
// Adjust for right-to-left languages // Adjust for right-to-left languages
[dir="rtl"] & { [dir="rtl"] & {
@@ -374,34 +378,48 @@
} }
} }
} }
}
// Search reset button // Search options
&[type="reset"] { &__options {
top: px2rem(6px); position: absolute;
right: px2rem(10px); top: px2rem(6px);
right: px2rem(10px);
z-index: 2;
pointer-events: none;
// Adjust for right-to-left languages
[dir="rtl"] & {
right: initial;
left: px2rem(10px);
}
// [tablet portrait -]: Search modal
@include break-to-device(tablet portrait) {
top: px2rem(12px);
right: px2rem(16px);
// Adjust for right-to-left languages
[dir="rtl"] & {
right: initial;
left: px2rem(16px);
}
}
// Search option buttons
> * {
margin-left: px2rem(4px);
color: var(--md-default-fg-color--light);
transform: scale(0.75); transform: scale(0.75);
opacity: 0; opacity: 0;
transition: transition:
transform 150ms cubic-bezier(0.1, 0.7, 0.1, 1), transform 150ms cubic-bezier(0.1, 0.7, 0.1, 1),
opacity 150ms; opacity 150ms;
pointer-events: none;
// Adjust for right-to-left languages // Hide outline for pointer devices
[dir="rtl"] & { &:not(.focus-visible) {
right: initial; outline: none;
left: px2rem(10px); -webkit-tap-highlight-color: transparent;
}
// [tablet portrait -]: Search modal
@include break-to-device(tablet portrait) {
top: px2rem(12px);
right: px2rem(16px);
// Adjust for right-to-left languages
[dir="rtl"] & {
right: initial;
left: px2rem(16px);
}
} }
// Show reset button when search is active and input non-empty // Show reset button when search is active and input non-empty
@@ -419,6 +437,44 @@
} }
} }
// Search suggestions
&__suggest {
position: absolute;
top: 0;
display: flex;
align-items: center;
width: 100%;
height: 100%;
padding: 0 px2rem(44px) 0 px2rem(72px);
color: var(--md-default-fg-color--lighter);
font-size: px2rem(18px);
white-space: nowrap;
opacity: 0;
transition: opacity 50ms;
// Adjust for right-to-left languages
[dir="rtl"] & {
padding: 0 px2rem(72px) 0 px2rem(44px);
}
// [tablet landscape +]: Header-embedded search
@include break-from-device(tablet landscape) {
padding-left: px2rem(44px);
font-size: px2rem(16px);
// Adjust for right-to-left languages
[dir="rtl"] & {
padding-right: px2rem(44px);
}
}
// Show suggestions when search is active
[data-md-toggle="search"]:checked ~ .md-header & {
opacity: 1;
transition: opacity 300ms 100ms;
}
}
// Search output // Search output
&__output { &__output {
position: absolute; position: absolute;

View File

@@ -81,25 +81,20 @@
// [tablet portrait +]: Header-embedded search // [tablet portrait +]: Header-embedded search
@include break-from-device(tablet landscape) { @include break-from-device(tablet landscape) {
// Search input // Search form
.md-search__input { .md-search__form {
background-color: hsla(0, 0%, 0%, 0.07); background-color: hsla(0, 0%, 0%, 0.07);
// Search icon color // Search form on hover
+ .md-search__icon {
color: hsla(0, 0%, 0%, 0.87);
}
// Placeholder color
&::placeholder {
color: hsla(0, 0%, 0%, 0.54);
}
// Search input on hover
&:hover { &:hover {
background-color: hsla(0, 0%, 0%, 0.32); background-color: hsla(0, 0%, 0%, 0.32);
} }
} }
// Search icon
.md-search__input + .md-search__icon {
color: hsla(0, 0%, 0%, 0.87);
}
} }
// [screen +]: Add bottom border for tabs // [screen +]: Add bottom border for tabs
@@ -144,8 +139,8 @@
// [tablet landscape +]: Header-embedded search // [tablet landscape +]: Header-embedded search
@include break-from-device(tablet landscape) { @include break-from-device(tablet landscape) {
// Search input // Search form
.md-search__input { .md-search__form {
background-color: hsla(0, 0%, 100%, 0.12); background-color: hsla(0, 0%, 100%, 0.12);
// Search form on hover // Search form on hover

View File

@@ -32,6 +32,8 @@
"meta.source": "Quellcode", "meta.source": "Quellcode",
"search.config.lang": "de", "search.config.lang": "de",
"search.placeholder": "Suche", "search.placeholder": "Suche",
"search.share": "Teilen",
"search.reset": "Zurücksetzen",
"search.result.initializer": "Suche wird initialisiert", "search.result.initializer": "Suche wird initialisiert",
"search.result.placeholder": "Suchbegriff eingeben", "search.result.placeholder": "Suchbegriff eingeben",
"search.result.none": "Keine Suchergebnisse", "search.result.none": "Keine Suchergebnisse",
@@ -40,6 +42,7 @@
"search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite", "search.result.more.one": "1 weiteres Suchergebnis auf dieser Seite",
"search.result.more.other": "# weitere Suchergebnisse auf dieser Seite", "search.result.more.other": "# weitere Suchergebnisse auf dieser Seite",
"search.result.term.missing": "Es fehlt", "search.result.term.missing": "Es fehlt",
"search.title": "Suche",
"select.language.title": "Sprache wechseln", "select.language.title": "Sprache wechseln",
"select.version.title": "Version auswählen", "select.version.title": "Version auswählen",
"skip.link.title": "Zum Inhalt", "skip.link.title": "Zum Inhalt",

View File

@@ -38,6 +38,7 @@
"search.config.pipeline": "trimmer, stopWordFilter", "search.config.pipeline": "trimmer, stopWordFilter",
"search.config.separator": "[\s\-]+", "search.config.separator": "[\s\-]+",
"search.placeholder": "Search", "search.placeholder": "Search",
"search.share": "Share",
"search.reset": "Clear", "search.reset": "Clear",
"search.result.initializer": "Initializing search", "search.result.initializer": "Initializing search",
"search.result.placeholder": "Type to start searching", "search.result.placeholder": "Type to start searching",
@@ -47,6 +48,7 @@
"search.result.more.one": "1 more on this page", "search.result.more.one": "1 more on this page",
"search.result.more.other": "# more on this page", "search.result.more.other": "# more on this page",
"search.result.term.missing": "Missing", "search.result.term.missing": "Missing",
"search.title": "Search",
"select.language.title": "Select language", "select.language.title": "Select language",
"select.version.title": "Select version", "select.version.title": "Select version",
"skip.link.title": "Skip to content", "skip.link.title": "Skip to content",

View File

@@ -27,6 +27,8 @@
<label class="md-search__overlay" for="__search"></label> <label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search"> <div class="md-search__inner" role="search">
<form class="md-search__form" name="search"> <form class="md-search__form" name="search">
<!-- Search input -->
<input <input
type="text" type="text"
class="md-search__input" class="md-search__input"
@@ -38,24 +40,59 @@
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
data-md-component="search-query" data-md-component="search-query"
data-md-state="active"
required required
/> />
<!-- Button to open search -->
<label class="md-search__icon md-icon" for="__search"> <label class="md-search__icon md-icon" for="__search">
{% include ".icons/material/magnify.svg" %} {% include ".icons/material/magnify.svg" %}
{% include ".icons/material/arrow-left.svg" %} {% include ".icons/material/arrow-left.svg" %}
</label> </label>
<button
type="reset" <!-- Search options -->
class="md-search__icon md-icon" <nav
aria-label="{{ lang.t('search.reset') }}" class="md-search__options"
tabindex="-1" aria-label="{{ lang.t('search.title') }}"
> >
{% include ".icons/material/close.svg" %}
</button> <!-- Button to share search -->
{% if "search.share" in features %}
<a
href="javascript:void(0)"
class="md-search__icon md-icon"
aria-label="{{ lang.t('search.share') }}"
data-clipboard
data-clipboard-text=""
data-md-component="search-share"
tabindex="-1"
>
{% include ".icons/material/share-variant.svg" %}
</a>
{% endif %}
<!-- Button to reset search -->
<button
type="reset"
class="md-search__icon md-icon"
aria-label="{{ lang.t('search.reset') }}"
tabindex="-1"
>
{% include ".icons/material/close.svg" %}
</button>
</nav>
<!-- Search suggestions -->
{% if "search.suggest" in features %}
<div
class="md-search__suggest"
data-md-component="search-suggest"
></div>
{% endif %}
</form> </form>
<div class="md-search__output"> <div class="md-search__output">
<div class="md-search__scrollwrap" data-md-scrollfix> <div class="md-search__scrollwrap" data-md-scrollfix>
<!-- Search results -->
<div class="md-search-result" data-md-component="search-result"> <div class="md-search-result" data-md-component="search-result">
<div class="md-search-result__meta"> <div class="md-search-result__meta">
{{ lang.t("search.result.initializer") }} {{ lang.t("search.result.initializer") }}