diff --git a/src/lib/internationalization/locales/en.cts b/src/lib/internationalization/locales/en.cts index 7e0cef9fd..8bcd776f2 100644 --- a/src/lib/internationalization/locales/en.cts +++ b/src/lib/internationalization/locales/en.cts @@ -509,7 +509,6 @@ export = { theme_generated_using_typedoc: "Generated using TypeDoc", // If this includes "TypeDoc", theme will insert a link at that location. // Search theme_preparing_search_index: "Preparing search index...", - theme_search_index_not_available: "The search index is not available", // Left nav bar theme_loading: "Loading...", // Right nav bar @@ -537,4 +536,7 @@ export = { "This member is normally hidden due to your filter settings.", theme_hierarchy_expand: "Expand", theme_hierarchy_collapse: "Collapse", + theme_search_index_not_available: "The search index is not available", + theme_search_no_results_found_for_0: "No results found for {0}", + theme_search_placeholder: "Search the docs", } as const; diff --git a/src/lib/output/plugins/AssetsPlugin.ts b/src/lib/output/plugins/AssetsPlugin.ts index d656853d5..e1e24edb3 100644 --- a/src/lib/output/plugins/AssetsPlugin.ts +++ b/src/lib/output/plugins/AssetsPlugin.ts @@ -41,6 +41,12 @@ export class AssetsPlugin extends RendererComponent { hierarchy_expand: i18n.theme_hierarchy_expand(), hierarchy_collapse: i18n.theme_hierarchy_collapse(), folder: i18n.theme_folder(), + theme_search_index_not_available: + this.application.i18n.theme_search_index_not_available(), + theme_search_no_results_found_for_0: + this.application.i18n.theme_search_no_results_found_for_0( + "{0}", + ), }; for (const key of getEnumKeys(ReflectionKind)) { diff --git a/src/lib/output/themes/default/assets/bootstrap.ts b/src/lib/output/themes/default/assets/bootstrap.ts index 468499945..f5b45f811 100644 --- a/src/lib/output/themes/default/assets/bootstrap.ts +++ b/src/lib/output/themes/default/assets/bootstrap.ts @@ -26,3 +26,8 @@ Object.defineProperty(window, "app", { value: app }); initSearch(); initNav(); initHierarchy(); + +if ("virtualKeyboard" in navigator) { + // @ts-ignore + navigator.virtualKeyboard.overlaysContent = true; +} diff --git a/src/lib/output/themes/default/assets/typedoc/Application.ts b/src/lib/output/themes/default/assets/typedoc/Application.ts index fa26210d4..d31e6bf75 100644 --- a/src/lib/output/themes/default/assets/typedoc/Application.ts +++ b/src/lib/output/themes/default/assets/typedoc/Application.ts @@ -12,6 +12,8 @@ declare global { // Kind strings for icons folder: string; [k: `kind_${number}`]: string; + theme_search_index_not_available: string; + theme_search_no_results_found_for_0: string; }; } } @@ -50,6 +52,8 @@ window.translations ||= { kind_2097152: "Type Alias", kind_4194304: "Reference", kind_8388608: "Document", + theme_search_index_not_available: "The search index is not available", + theme_search_no_results_found_for_0: "No results found for {0}", }; /** diff --git a/src/lib/output/themes/default/assets/typedoc/components/Search.ts b/src/lib/output/themes/default/assets/typedoc/components/Search.ts index b5bd756db..166fbc805 100644 --- a/src/lib/output/themes/default/assets/typedoc/components/Search.ts +++ b/src/lib/output/themes/default/assets/typedoc/components/Search.ts @@ -1,6 +1,7 @@ import { debounce } from "../utils/debounce.js"; import { Index } from "lunr"; import { decompressJson } from "../utils/decompress.js"; +import { openModal, setUpModal } from "../utils/modal.js"; /** * Keep this in sync with the interface in src/lib/output/plugins/JavascriptIndexPlugin.ts @@ -33,7 +34,14 @@ interface SearchState { index?: Index; } -async function updateIndex(state: SearchState, searchEl: HTMLElement) { +/** Counter to get unique IDs for options */ +let optionsIdCounter = 0; + +/** + * Populates search data into `state`, if available. + * Removes deault loading message + */ +async function updateIndex(state: SearchState, status: HTMLElement) { if (!window.searchData) return; const data: IData = await decompressJson(window.searchData); @@ -41,117 +49,145 @@ async function updateIndex(state: SearchState, searchEl: HTMLElement) { state.data = data; state.index = Index.load(data.index); - searchEl.classList.remove("loading"); - searchEl.classList.add("ready"); + status.innerHTML = ""; } export function initSearch() { - const searchEl = document.getElementById("tsd-search"); - if (!searchEl) return; + const trigger = document.getElementById( + "tsd-search-trigger", + ) as HTMLButtonElement | null; - const state: SearchState = { - base: document.documentElement.dataset.base! + "/", - }; + const searchEl = document.getElementById( + "tsd-search", + ) as HTMLDialogElement | null; + + const field = document.getElementById( + "tsd-search-input", + ) as HTMLInputElement | null; + + const results = document.getElementById("tsd-search-results"); const searchScript = document.getElementById( "tsd-search-script", ) as HTMLScriptElement | null; - searchEl.classList.add("loading"); - if (searchScript) { - searchScript.addEventListener("error", () => { - searchEl.classList.remove("loading"); - searchEl.classList.add("failure"); - }); - searchScript.addEventListener("load", () => { - updateIndex(state, searchEl); - }); - updateIndex(state, searchEl); - } - const field = document.querySelector("#tsd-search input"); - const results = document.querySelector("#tsd-search .results"); + const status = document.getElementById("tsd-search-status"); - if (!field || !results) { - throw new Error( - "The input field or the result list wrapper was not found", - ); + if (!(trigger && searchEl && field && results && searchScript && status)) { + throw new Error("Search controls missing"); } - results.addEventListener("mouseup", () => { - hideSearch(searchEl); - }); + const state: SearchState = { + base: document.documentElement.dataset.base! + "/", + }; - field.addEventListener("focus", () => searchEl.classList.add("has-focus")); + searchScript.addEventListener("error", () => { + const message = window.translations.theme_search_index_not_available; + updateStatusEl(status, message); + }); + searchScript.addEventListener("load", () => { + updateIndex(state, status); + }); + updateIndex(state, status); - bindEvents(searchEl, results, field, state); + bindEvents({ trigger, searchEl, results, field, status }, state); } function bindEvents( - searchEl: HTMLElement, - results: HTMLElement, - field: HTMLInputElement, + elements: { + trigger: HTMLButtonElement; + searchEl: HTMLDialogElement; + results: HTMLElement; + field: HTMLInputElement; + status: HTMLElement; + }, state: SearchState, ) { + const { field, results, searchEl, status, trigger } = elements; + + setUpModal(searchEl, "fade-out", { closeOnClick: true }); + + trigger.addEventListener("click", () => openModal(searchEl)); + field.addEventListener( "input", debounce(() => { - updateResults(searchEl, results, field, state); + updateResults(results, field, status, state); }, 200), ); - // Narrator is a pain. It completely eats the up/down arrow key events, so we can't - // rely on detecting the input blurring to hide the focus. We have to instead check - // for a focus event on an item outside of the search field/results. field.addEventListener("keydown", (e) => { - if (e.key == "Enter") { - gotoCurrentResult(results, searchEl); - } else if (e.key == "ArrowUp") { - setCurrentResult(results, field, -1); - e.preventDefault(); - } else if (e.key === "ArrowDown") { - setCurrentResult(results, field, 1); - e.preventDefault(); + if ( + results.childElementCount === 0 || + e.ctrlKey || + e.metaKey || + e.altKey + ) { + return; + } + + // Get the visually focused element, if any + const currentId = field.getAttribute("aria-activedescendant"); + const current = currentId ? document.getElementById(currentId) : null; + + // Remove visual focus on cursor position change + if (current) { + switch (e.key) { + case "Home": + case "End": + case "ArrowLeft": + case "ArrowRight": + removeVisualFocus(field); + } + } + + if (e.shiftKey) return; + + switch (e.key) { + case "Enter": + current?.querySelector("a")?.click(); + break; + case "ArrowUp": + setNextResult(results, field, current, -1); + break; + case "ArrowDown": + setNextResult(results, field, current, 1); + break; } }); + field.addEventListener("change", () => removeVisualFocus(field)); + field.addEventListener("blur", () => removeVisualFocus(field)); + /** - * Start searching by pressing slash. + * Start searching by pressing slash, or Ctrl+K */ - document.body.addEventListener("keypress", (e) => { - if (e.altKey || e.ctrlKey || e.metaKey) return; - if (!field.matches(":focus") && e.key === "/") { - e.preventDefault(); - field.focus(); - } - }); + document.body.addEventListener("keydown", (e) => { + if (e.altKey || e.metaKey || e.shiftKey) return; - document.body.addEventListener("keyup", (e) => { - if ( - searchEl.classList.contains("has-focus") && - (e.key === "Escape" || - (!results.matches(":focus-within") && !field.matches(":focus"))) - ) { - field.blur(); - hideSearch(searchEl); + const ctrlK = e.ctrlKey && e.key === "k"; + const slash = !e.ctrlKey && !isKeyboardActive() && e.key === "/"; + + if (ctrlK || slash) { + e.preventDefault(); + openModal(searchEl); } }); } -function hideSearch(searchEl: HTMLElement) { - searchEl.classList.remove("has-focus"); -} - function updateResults( - searchEl: HTMLElement, results: HTMLElement, query: HTMLInputElement, + status: HTMLElement, state: SearchState, ) { // Don't clear results if loading state is not ready, // because loading or error message can be removed. if (!state.index || !state.data) return; - results.textContent = ""; + results.innerHTML = ""; + status.innerHTML = ""; + optionsIdCounter += 1; const searchText = query.value.trim(); @@ -173,6 +209,16 @@ function updateResults( res = []; } + if (res.length === 0 && searchText) { + const message = + window.translations.theme_search_no_results_found_for_0.replace( + "{0}", + ` "${escapeHtml(searchText)}" `, + ); + updateStatusEl(status, message); + return; + } + for (let i = 0; i < res.length; i++) { const item = res[i]; const row = state.data.rows[Number(item.ref)]; @@ -187,46 +233,36 @@ function updateResults( item.score *= boost; } - if (res.length === 0) { - let item = document.createElement("li"); - item.classList.add("no-results"); - - let anchor = document.createElement("span"); - anchor.textContent = "No results found"; - - item.appendChild(anchor); - results.appendChild(item); - } - res.sort((a, b) => b.score - a.score); - for (let i = 0, c = Math.min(10, res.length); i < c; i++) { + const c = Math.min(10, res.length); + for (let i = 0; i < c; i++) { const row = state.data.rows[Number(res[i].ref)]; const icon = ``; - // Bold the matched part of the query in the search results - let name = boldMatches(row.name, searchText); + // Highlight the matched part of the query in the search results + let name = highlightMatches(row.name, searchText); if (globalThis.DEBUG_SEARCH_WEIGHTS) { name += ` (score: ${res[i].score.toFixed(2)})`; } if (row.parent) { name = ` - ${boldMatches(row.parent, searchText)}.${name}`; + ${highlightMatches(row.parent, searchText)}.${name}`; } const item = document.createElement("li"); + item.id = `tsd-search:${optionsIdCounter}-${i}`; + item.role = "option"; + item.ariaSelected = "false"; item.classList.value = row.classes ?? ""; const anchor = document.createElement("a"); + // Make links unfocusable inside option + anchor.tabIndex = -1; anchor.href = state.base + row.url; - anchor.innerHTML = icon + name; + anchor.innerHTML = icon + `${name}`; item.append(anchor); - anchor.addEventListener("focus", () => { - results.querySelector(".current")?.classList.remove("current"); - item.classList.add("current"); - }); - results.appendChild(item); } } @@ -234,63 +270,44 @@ function updateResults( /** * Move the highlight within the result set. */ -function setCurrentResult( +function setNextResult( results: HTMLElement, field: HTMLInputElement, - dir: number, + current: Element | null, + dir: 1 | -1, ) { - let current = results.querySelector(".current"); - if (!current) { - current = results.querySelector( - dir == 1 ? "li:first-child" : "li:last-child", - ); - if (current) { - current.classList.add("current"); - } + let next: Element | null; + // If there's no active descendant, select the first or last + if (dir === 1) { + next = current?.nextElementSibling || results.firstElementChild; } else { - let rel: Element | undefined = current; - // Tricky: We have to check that rel has an offsetParent so that users can't mark a hidden result as - // current with the arrow keys. - if (dir === 1) { - do { - rel = rel.nextElementSibling ?? undefined; - } while (rel instanceof HTMLElement && rel.offsetParent == null); - } else { - do { - rel = rel.previousElementSibling ?? undefined; - } while (rel instanceof HTMLElement && rel.offsetParent == null); - } - - if (rel) { - current.classList.remove("current"); - rel.classList.add("current"); - } else if (dir === -1) { - current.classList.remove("current"); - field.focus(); - } + next = current?.previousElementSibling || results.lastElementChild; } -} -/** - * Navigate to the highlighted result. - */ -function gotoCurrentResult(results: HTMLElement, searchEl: HTMLElement) { - let current = results.querySelector(".current"); + // When only one child is present. + if (next === current) return; - if (!current) { - current = results.querySelector("li:first-child"); + // bad markup + if (!next || next.role !== "option") { + console.error("Option missing"); + return; } - if (current) { - const link = current.querySelector("a"); - if (link) { - window.location.href = link.href; - } - hideSearch(searchEl); - } + next.ariaSelected = "true"; + next.scrollIntoView({ behavior: "smooth", block: "nearest" }); + field.setAttribute("aria-activedescendant", next.id); + current?.setAttribute("aria-selected", "false"); +} + +function removeVisualFocus(field: HTMLInputElement) { + const currentId = field.getAttribute("aria-activedescendant"); + const current = currentId ? document.getElementById(currentId) : null; + + current?.setAttribute("aria-selected", "false"); + field.setAttribute("aria-activedescendant", ""); } -function boldMatches(text: string, search: string) { +function highlightMatches(text: string, search: string) { if (search === "") { return text; } @@ -304,9 +321,9 @@ function boldMatches(text: string, search: string) { while (index != -1) { parts.push( escapeHtml(text.substring(lastIndex, index)), - `${escapeHtml( + `${escapeHtml( text.substring(index, index + lowerSearch.length), - )}`, + )}`, ); lastIndex = index + lowerSearch.length; @@ -332,3 +349,47 @@ function escapeHtml(text: string) { (match) => SPECIAL_HTML[match as keyof typeof SPECIAL_HTML], ); } + +/** + * Updates the status element, with aria-live attriute, which should be announced to the user. + * @param message Message to set as **innerHTML** in a wrapper element, if not empty. + */ +function updateStatusEl(status: HTMLElement, message: string) { + status.innerHTML = message ? `
${message}
` : ""; +} + +/** + * that don't take printable character input from keyboard, + * to avoid catching "/" when active. + * + * based on [MDN: input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types) + */ +const inputWithoutKeyboard = [ + "button", + "checkbox", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", +]; + +/** Checks whether keyboard is active, i.e. an input is focused */ +function isKeyboardActive() { + const activeElement = document.activeElement as HTMLElement | null; + if (!activeElement) return false; + + if ( + activeElement.isContentEditable || + activeElement.tagName === "TEXTAREA" || + activeElement.tagName === "SEARCH" + ) + return true; + + return ( + activeElement.tagName === "INPUT" && + !inputWithoutKeyboard.includes((activeElement as HTMLInputElement).type) + ); +} diff --git a/src/lib/output/themes/default/assets/typedoc/utils/modal.ts b/src/lib/output/themes/default/assets/typedoc/utils/modal.ts new file mode 100644 index 000000000..92dbbde63 --- /dev/null +++ b/src/lib/output/themes/default/assets/typedoc/utils/modal.ts @@ -0,0 +1,119 @@ +/** + * @module + * + * Browsers allow scrolling of page with native dialog, which is a UX issue. + * + * `@starting-style` and `overlay` aren't well supported in FF, and only available in latest versions of chromium, + * hence, a custom overlay workaround is required. + * + * Workaround: + * + * - Append a custom overlay element (a div) to `document.body`, + * this does **NOT** handle nested modals, + * as the overlay div cannot be in the top layer, which wouldn't overshadow the parent modal. + * + * - Add exit animation on dialog and overlay, without actually closing them + * - Listen for `animationend` event, and close the modal immediately + * + * @see + * - The "[right](https://frontendmasters.com/blog/animating-dialog/)" way to animate modals + * - [Workaround](https://github.com/whatwg/html/issues/7732#issuecomment-2437820350) to prevent background scrolling + */ + +// Constants +const CLOSING_CLASS = "closing"; +const OVERLAY_ID = "tsd-overlay"; + +/** Fills the gap that scrollbar occupies. Call when the modal is opened */ +function hideScrollbar() { + // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes + // Should be computed *before* body overflow is set to hidden + const width = Math.abs( + window.innerWidth - document.documentElement.clientWidth, + ); + + document.body.style.overflow = "hidden"; + + // Give padding to element to balance the hidden scrollbar width + document.body.style.paddingRight = `${width}px`; +} + +/** Resets style changes made by {@link hideScrollbar} */ +function resetScrollbar() { + document.body.style.removeProperty("overflow"); + document.body.style.removeProperty("padding-right"); +} + +/** Could be popover too */ +type Modal = HTMLDialogElement; + +/** + * Must be called to setup a modal element properly for entry and exit side-effects. + * + * Adds event listeners to the modal element, for the closing animation. + * + * Adds workaround to fix scrolling issues caused by default browser behavior. + * + * @param closingAnimation Name of `@keyframes` for closing animation + * @param options Configure modal behavior + * @param options.closeOnEsc Defaults to true + * @param options.closeOnClick Closes modal when clicked on overlay, defaults to false. + */ +export function setUpModal( + modal: Modal, + closingAnimation: string, + options?: { + closeOnEsc?: boolean; + closeOnClick?: boolean; + }, +) { + // Event listener for closing animation + modal.addEventListener("animationend", (e) => { + if (e.animationName !== closingAnimation) return; + modal.classList.remove(CLOSING_CLASS); + document.getElementById(OVERLAY_ID)?.remove(); + modal.close(); + resetScrollbar(); + }); + + // Override modal cancel behavior, hopefully all browsers have same behavior + // > When a `` is dismissed with the `Esc` key, both the `cancel` and `close` events are fired. + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/cancel_event + modal.addEventListener("cancel", (e) => { + e.preventDefault(); + closeModal(modal); + }); + + if (options?.closeOnClick) { + document.addEventListener( + "click", + (e) => { + if (modal.open && !modal.contains(e.target as HTMLElement)) { + closeModal(modal); + } + }, + true, // Disable invoking this handler in bubbling phase + ); + } +} + +export function openModal(modal: Modal) { + if (modal.open) return; + + const overlay = document.createElement("div"); + overlay.id = OVERLAY_ID; + document.body.appendChild(overlay); + + modal.showModal(); + hideScrollbar(); +} + +/** Initiates modal closing, by adding a `closing` class that starts the closing animation */ +export function closeModal(modal: Modal) { + if (!modal.open) return; + const overlay = document.getElementById(OVERLAY_ID); + if (overlay) { + overlay.classList.add(CLOSING_CLASS); + } + modal.classList.add(CLOSING_CLASS); +} diff --git a/src/lib/output/themes/default/partials/toolbar.tsx b/src/lib/output/themes/default/partials/toolbar.tsx index cd18fe65b..bd1157171 100644 --- a/src/lib/output/themes/default/partials/toolbar.tsx +++ b/src/lib/output/themes/default/partials/toolbar.tsx @@ -7,42 +7,48 @@ import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext.js" export const toolbar = (context: DefaultThemeRenderContext, props: PageEvent) => (
- +
    +
    +
    {context.i18n.theme_preparing_search_index()}
    +
    +
    - + + {context.icons.menu()} + ); diff --git a/static/style.css b/static/style.css index 2ab8b836e..eea850faf 100644 --- a/static/style.css +++ b/static/style.css @@ -1,14 +1,33 @@ @layer typedoc { + :root { + --dim-toolbar-contents-height: 2.5rem; + --dim-toolbar-border-bottom-width: 1px; + --dim-header-height: calc( + var(--dim-toolbar-border-bottom-width) + + var(--dim-toolbar-contents-height) + ); + + /* 0rem For mobile; unit is required for calculation in `calc` */ + --dim-container-main-margin-y: 0rem; + + --dim-footer-height: 3.5rem; + + --modal-animation-duration: 0.2s; + } + :root { /* Light */ --light-color-background: #f2f4f8; --light-color-background-secondary: #eff0f1; - --light-color-warning-text: #222; + /* Not to be confused with [:active](https://developer.mozilla.org/en-US/docs/Web/CSS/:active) */ + --light-color-background-active: #d6d8da; --light-color-background-warning: #e6e600; + --light-color-warning-text: #222; --light-color-accent: #c5c7c9; - --light-color-active-menu-item: var(--light-color-accent); + --light-color-active-menu-item: var(--light-color-background-active); --light-color-text: #222; - --light-color-text-aside: #6e6e6e; + --light-color-contrast-text: #000; + --light-color-text-aside: #5e5e5e; --light-color-icon-background: var(--light-color-background); --light-color-icon-text: var(--light-color-text); @@ -56,15 +75,20 @@ --light-external-icon: url("data:image/svg+xml;utf8,"); --light-color-scheme: light; + } + :root { /* Dark */ --dark-color-background: #2b2e33; --dark-color-background-secondary: #1e2024; + /* Not to be confused with [:active](https://developer.mozilla.org/en-US/docs/Web/CSS/:active) */ + --dark-color-background-active: #5d5d6a; --dark-color-background-warning: #bebe00; --dark-color-warning-text: #222; --dark-color-accent: #9096a2; - --dark-color-active-menu-item: #5d5d6a; + --dark-color-active-menu-item: var(--dark-color-background-active); --dark-color-text: #f5f5f5; + --dark-color-contrast-text: #ffffff; --dark-color-text-aside: #dddddd; --dark-color-icon-background: var(--dark-color-background-secondary); @@ -119,11 +143,13 @@ --color-background-secondary: var( --light-color-background-secondary ); + --color-background-active: var(--light-color-background-active); --color-background-warning: var(--light-color-background-warning); --color-warning-text: var(--light-color-warning-text); --color-accent: var(--light-color-accent); --color-active-menu-item: var(--light-color-active-menu-item); --color-text: var(--light-color-text); + --color-contrast-text: var(--light-color-contrast-text); --color-text-aside: var(--light-color-text-aside); --color-icon-background: var(--light-color-icon-background); @@ -179,11 +205,13 @@ --color-background-secondary: var( --dark-color-background-secondary ); + --color-background-active: var(--dark-color-background-active); --color-background-warning: var(--dark-color-background-warning); --color-warning-text: var(--dark-color-warning-text); --color-accent: var(--dark-color-accent); --color-active-menu-item: var(--dark-color-active-menu-item); --color-text: var(--dark-color-text); + --color-contrast-text: var(--dark-color-contrast-text); --color-text-aside: var(--dark-color-text-aside); --color-icon-background: var(--dark-color-icon-background); @@ -237,19 +265,17 @@ color-scheme: var(--color-scheme); } - body { - margin: 0; - } - :root[data-theme="light"] { --color-background: var(--light-color-background); --color-background-secondary: var(--light-color-background-secondary); + --color-background-active: var(--light-color-background-active); --color-background-warning: var(--light-color-background-warning); --color-warning-text: var(--light-color-warning-text); --color-icon-background: var(--light-color-icon-background); --color-accent: var(--light-color-accent); --color-active-menu-item: var(--light-color-active-menu-item); --color-text: var(--light-color-text); + --color-contrast-text: var(--light-color-contrast-text); --color-text-aside: var(--light-color-text-aside); --color-icon-text: var(--light-color-icon-text); @@ -299,12 +325,14 @@ :root[data-theme="dark"] { --color-background: var(--dark-color-background); --color-background-secondary: var(--dark-color-background-secondary); + --color-background-active: var(--dark-color-background-active); --color-background-warning: var(--dark-color-background-warning); --color-warning-text: var(--dark-color-warning-text); --color-icon-background: var(--dark-color-icon-background); --color-accent: var(--dark-color-accent); --color-active-menu-item: var(--dark-color-active-menu-item); --color-text: var(--dark-color-text); + --color-contrast-text: var(--dark-color-contrast-text); --color-text-aside: var(--dark-color-text-aside); --color-icon-text: var(--dark-color-icon-text); @@ -421,16 +449,19 @@ border-top: 1px solid var(--color-accent); padding-top: 1rem; padding-bottom: 1rem; - max-height: 3.5rem; + max-height: var(--dim-footer-height); } footer > p { margin: 0 1em; } .container-main { - margin: 0 auto; + margin: var(--dim-container-main-margin-y) auto; /* toolbar, footer, margin */ - min-height: calc(100vh - 41px - 56px - 4rem); + min-height: calc( + 100svh - var(--dim-header-height) - var(--dim-footer-height) - 2 * + var(--dim-container-main-margin-y) + ); } @keyframes fade-in { @@ -450,29 +481,6 @@ opacity: 0; } } - @keyframes fade-in-delayed { - 0% { - opacity: 0; - } - 33% { - opacity: 0; - } - 100% { - opacity: 1; - } - } - @keyframes fade-out-delayed { - 0% { - opacity: 1; - visibility: visible; - } - 66% { - opacity: 0; - } - 100% { - opacity: 0; - } - } @keyframes pop-in-from-right { from { transform: translate(100%, 0); @@ -496,6 +504,7 @@ Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-size: 16px; color: var(--color-text); + margin: 0; } a { @@ -555,6 +564,52 @@ border-left: 4px solid gray; } + img { + max-width: 100%; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--color-accent) var(--color-icon-background); + } + + *::-webkit-scrollbar { + width: 0.75rem; + } + + *::-webkit-scrollbar-track { + background: var(--color-icon-background); + } + + *::-webkit-scrollbar-thumb { + background-color: var(--color-accent); + border-radius: 999rem; + border: 0.25rem solid var(--color-icon-background); + } + + dialog { + border: none; + outline: none; + padding: 0; + background-color: var(--color-background); + } + dialog::backdrop { + display: none; + } + #tsd-overlay { + background-color: rgba(0, 0, 0, 0.5); + position: fixed; + z-index: 9999; + top: 0; + left: 0; + right: 0; + bottom: 0; + animation: fade-in var(--modal-animation-duration) forwards; + } + #tsd-overlay.closing { + animation-name: fade-out; + } + .tsd-typography { line-height: 1.333em; } @@ -629,6 +684,7 @@ .tsd-breadcrumb { margin: 0; + margin-top: 1rem; padding: 0; color: var(--color-text-aside); } @@ -876,7 +932,8 @@ } .tsd-navigation.settings { - margin: 1rem 0; + margin: 0; + margin-bottom: 1rem; } .tsd-navigation > a, .tsd-navigation .tsd-accordion-summary { @@ -898,6 +955,7 @@ .tsd-navigation a.current, .tsd-page-navigation a.current { background: var(--color-active-menu-item); + color: var(--color-contrast-text); } .tsd-navigation a:hover, .tsd-page-navigation a:hover { @@ -1062,117 +1120,106 @@ margin-bottom: 1rem; } - #tsd-search { - transition: background-color 0.2s; - } - #tsd-search .title { - position: relative; - z-index: 2; + #tsd-search[open] { + animation: fade-in var(--modal-animation-duration) ease-out forwards; } - #tsd-search .field { - position: absolute; - left: 0; - top: 0; - right: 2.5rem; - height: 100%; + #tsd-search[open].closing { + animation-name: fade-out; } - #tsd-search .field input { + + /* Avoid setting `display` on closed dialog */ + #tsd-search[open] { + display: flex; + flex-direction: column; + padding: 1rem; + width: 32rem; + max-width: 90vw; + max-height: calc(100vh - env(keyboard-inset-height, 0px) - 25vh); + /* Anchor dialog to top */ + margin-top: 10vh; + border-radius: 6px; + will-change: max-height; + } + #tsd-search-input { box-sizing: border-box; - position: relative; - top: -50px; - z-index: 1; width: 100%; - padding: 0 10px; - opacity: 0; + padding: 0 0.625rem; /* 10px */ outline: 0; - border: 0; - background: transparent; + border: 2px solid var(--color-accent); + background-color: transparent; color: var(--color-text); + border-radius: 4px; + height: 2.5rem; + flex: 0 0 auto; + font-size: 0.875rem; + transition: + border-color 0.2s, + background-color 0.2s; } - #tsd-search .field label { - position: absolute; - overflow: hidden; - right: -40px; + #tsd-search-input:focus-visible { + background-color: var(--color-background-active); + border-color: transparent; + color: var(--color-contrast-text); } - #tsd-search .field input, - #tsd-search .title, - #tsd-toolbar-links a { - transition: opacity 0.2s; + #tsd-search-input::placeholder { + color: inherit; + opacity: 0.8; } - #tsd-search .results { - position: absolute; - visibility: hidden; - top: 40px; - width: 100%; + #tsd-search-results { margin: 0; padding: 0; list-style: none; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow-y: auto; } - #tsd-search .results li { + #tsd-search-results:not(:empty) { + margin-top: 0.5rem; + } + #tsd-search-results > li { background-color: var(--color-background); - line-height: initial; - padding: 4px; + line-height: 1.5; + box-sizing: border-box; + border-radius: 4px; } - #tsd-search .results li:nth-child(even) { + #tsd-search-results > li:nth-child(even) { background-color: var(--color-background-secondary); } - #tsd-search .results li.state { - display: none; - } - #tsd-search .results li.current:not(.no-results), - #tsd-search .results li:hover:not(.no-results) { - background-color: var(--color-accent); + #tsd-search-results > li:is(:hover, [aria-selected="true"]) { + background-color: var(--color-background-active); + color: var(--color-contrast-text); } - #tsd-search .results a { + /* It's important that this takes full size of parent `li`, to capture a click on `li` */ + #tsd-search-results > li > a { display: flex; align-items: center; - padding: 0.25rem; + padding: 0.5rem 0.25rem; box-sizing: border-box; + width: 100%; } - #tsd-search .results a:before { - top: 10px; + #tsd-search-results > li > a > .text { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; } - #tsd-search .results span.parent { + #tsd-search-results > li > a .parent { color: var(--color-text-aside); - font-weight: normal; - } - #tsd-search.has-focus { - background-color: var(--color-accent); } - #tsd-search.has-focus .field input { - top: 0; - opacity: 1; - } - #tsd-search.has-focus .title, - #tsd-search.has-focus #tsd-toolbar-links a { - z-index: 0; - opacity: 0; - } - #tsd-search.has-focus .results { - visibility: visible; - } - #tsd-search.loading .results li.state.loading { - display: block; - } - #tsd-search.failure .results li.state.failure { - display: block; - } - - #tsd-toolbar-links { - position: absolute; - top: 0; - right: 2rem; - height: 100%; - display: flex; - align-items: center; - justify-content: flex-end; + #tsd-search-results > li > a mark { + color: inherit; + background-color: inherit; + font-weight: bold; } - #tsd-toolbar-links a { - margin-left: 1.5rem; + #tsd-search-status { + flex: 1; + display: grid; + place-content: center; + text-align: center; + overflow-wrap: anywhere; } - #tsd-toolbar-links a:hover { - text-decoration: underline; + #tsd-search-status:not(:empty) { + min-height: 6rem; } .tsd-signature { @@ -1257,78 +1304,54 @@ width: 100%; color: var(--color-text); background: var(--color-background-secondary); - border-bottom: 1px var(--color-accent) solid; + border-bottom: var(--dim-toolbar-border-bottom-width) + var(--color-accent) solid; transition: transform 0.3s ease-in-out; } .tsd-page-toolbar a { color: var(--color-text); - text-decoration: none; - } - .tsd-page-toolbar a.title { - font-weight: bold; - } - .tsd-page-toolbar a.title:hover { - text-decoration: underline; } - .tsd-page-toolbar .tsd-toolbar-contents { + .tsd-toolbar-contents { display: flex; - justify-content: space-between; - height: 2.5rem; + align-items: center; + height: var(--dim-toolbar-contents-height); margin: 0 auto; } - .tsd-page-toolbar .table-cell { - position: relative; - white-space: nowrap; - line-height: 40px; - } - .tsd-page-toolbar .table-cell:first-child { - width: 100%; + .tsd-toolbar-contents > .title { + font-weight: bold; + margin-right: auto; } - .tsd-page-toolbar .tsd-toolbar-icon { - box-sizing: border-box; - line-height: 0; - padding: 12px 0; + #tsd-toolbar-links { + display: flex; + align-items: center; + gap: 1.5rem; + margin-right: 1rem; } .tsd-widget { + box-sizing: border-box; display: inline-block; - overflow: hidden; opacity: 0.8; - height: 40px; + height: 2.5rem; + width: 2.5rem; transition: opacity 0.1s, - background-color 0.2s; - vertical-align: bottom; + background-color 0.1s; + text-align: center; cursor: pointer; + border: none; + background-color: transparent; } .tsd-widget:hover { opacity: 0.9; } - .tsd-widget.active { + .tsd-widget:active { opacity: 1; background-color: var(--color-accent); } - .tsd-widget.no-caption { - width: 40px; - } - .tsd-widget.no-caption:before { - margin: 0; - } - - .tsd-widget.options, - .tsd-widget.menu { + #tsd-toolbar-menu-trigger { display: none; } - input[type="checkbox"] + .tsd-widget:before { - background-position: -120px 0; - } - input[type="checkbox"]:checked + .tsd-widget:before { - background-position: -160px 0; - } - - img { - max-width: 100%; - } .tsd-member-summary-name { display: inline-flex; @@ -1437,41 +1460,26 @@ color: var(--color-text); } - * { - scrollbar-width: thin; - scrollbar-color: var(--color-accent) var(--color-icon-background); - } - - *::-webkit-scrollbar { - width: 0.75rem; - } - - *::-webkit-scrollbar-track { - background: var(--color-icon-background); - } - - *::-webkit-scrollbar-thumb { - background-color: var(--color-accent); - border-radius: 999rem; - border: 0.25rem solid var(--color-icon-background); - } - /* mobile */ @media (max-width: 769px) { - .tsd-widget.options, - .tsd-widget.menu { + #tsd-toolbar-menu-trigger { display: inline-block; + /* temporary fix to vertically align, for compatibilty */ + line-height: 2.5; + } + #tsd-toolbar-links { + display: none; } .container-main { display: flex; } - html .col-content { + .col-content { float: none; max-width: 100%; width: 100%; } - html .col-sidebar { + .col-sidebar { position: fixed !important; overflow-y: auto; -webkit-overflow-scrolling: touch; @@ -1486,10 +1494,10 @@ background-color: var(--color-background); transform: translate(100%, 0); } - html .col-sidebar > *:last-child { + .col-sidebar > *:last-child { padding-bottom: 20px; } - html .overlay { + .overlay { content: ""; display: block; position: fixed; @@ -1536,9 +1544,6 @@ .has-menu .tsd-navigation { max-height: 100%; } - #tsd-toolbar-links { - display: none; - } .tsd-navigation .tsd-nav-link { display: flex; } @@ -1550,7 +1555,11 @@ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); grid-template-areas: "sidebar content"; - margin: 2rem auto; + --dim-container-main-margin-y: 2rem; + } + + .tsd-breadcrumb { + margin-top: 0; } .col-sidebar { @@ -1563,11 +1572,15 @@ } @media (min-width: 770px) and (max-width: 1399px) { .col-sidebar { - max-height: calc(100vh - 2rem - 42px); + max-height: calc( + 100vh - var(--dim-header-height) - var(--dim-footer-height) - 2 * + var(--dim-container-main-margin-y) + ); overflow: auto; position: sticky; - top: 42px; - padding-top: 1rem; + top: calc( + var(--dim-header-height) + var(--dim-container-main-margin-y) + ); } .site-menu { margin-top: 1rem; @@ -1597,15 +1610,20 @@ } .site-menu { - margin-top: 1rem; + margin-top: 0rem; } .page-menu, .site-menu { - max-height: calc(100vh - 2rem - 42px); + max-height: calc( + 100vh - var(--dim-header-height) - var(--dim-footer-height) - 2 * + var(--dim-container-main-margin-y) + ); overflow: auto; position: sticky; - top: 42px; + top: calc( + var(--dim-header-height) + var(--dim-container-main-margin-y) + ); } } }