diff --git a/lib/rdoc/generator/template/aliki/DESIGN.md b/lib/rdoc/generator/template/aliki/DESIGN.md index 3703055f61..ea863d5281 100644 --- a/lib/rdoc/generator/template/aliki/DESIGN.md +++ b/lib/rdoc/generator/template/aliki/DESIGN.md @@ -302,6 +302,19 @@ One search form and one result list serve both header layouts. CSS changes the p | Type badge (`.search-type-*`) | inline-block, `space-0 space-2` pad, `xs`, weight 500, `radius-sm`, colors per §2 | | Matched term (`li em`) | `search-highlight-bg`, `font-style: normal` | +The desktop field has a `/` keycap and an adjacent **Search shortcuts** button +with a keyboard icon. On compact screens, the keycap and adjacent button are +hidden. The same help is available below the search results, outside their +scroll area. + +The help uses one native dialog: a 376 px panel below the desktop button and a +compact sheet aligned with the search surface. Pressing `?` outside an input, +text area, select, or editable content opens help. It lists this shortcut and +the existing `/`, arrow, `Enter`, and `Esc` actions, grouped by context. Close and Escape dismiss +help and restore the previous focus without clearing the search. Pressing `/` +in help closes the dialog and focuses the search field. Colors, keycaps, +borders, and focus indicators use the shared light and dark tokens. + ### Right TOC (`#table-of-contents`) | Property | Value | diff --git a/lib/rdoc/generator/template/aliki/_header.rhtml b/lib/rdoc/generator/template/aliki/_header.rhtml index b7bcce114f..018df4c013 100644 --- a/lib/rdoc/generator/template/aliki/_header.rhtml +++ b/lib/rdoc/generator/template/aliki/_header.rhtml @@ -15,9 +15,10 @@ + @@ -31,7 +32,22 @@

No recent searches

+ + + + + <%= render '_search_shortcuts.rhtml' %> diff --git a/lib/rdoc/generator/template/aliki/_icons.rhtml b/lib/rdoc/generator/template/aliki/_icons.rhtml index 9c11a4a120..25f46720b5 100644 --- a/lib/rdoc/generator/template/aliki/_icons.rhtml +++ b/lib/rdoc/generator/template/aliki/_icons.rhtml @@ -1,4 +1,10 @@ + + + + + +
+

Search shortcuts

+ +
+

A few keys to find your way around.

+ +
+

When you are not typing in an input

+
+
Focus search
/
+
Open search shortcuts
?
+
+
+
+

When results are shown

+
+
+
Move through results
+
↑ ↓
+
+
Open selected result
Enter
+
+
+
+

While search is open

+
+
Close search
Esc
+
+
+ diff --git a/lib/rdoc/generator/template/aliki/css/rdoc.css b/lib/rdoc/generator/template/aliki/css/rdoc.css index 61a8329e26..320ab87e6d 100644 --- a/lib/rdoc/generator/template/aliki/css/rdoc.css +++ b/lib/rdoc/generator/template/aliki/css/rdoc.css @@ -476,6 +476,9 @@ header.top-navbar .navbar-brand:hover { } header.top-navbar .navbar-search { + display: flex; + align-items: center; + gap: var(--space-3); grid-column: 2; justify-self: center; box-sizing: border-box; @@ -485,6 +488,8 @@ header.top-navbar .navbar-search { header.top-navbar .search-surface { position: relative; + flex: 0 1 var(--layout-search-width); + min-width: 0; width: min(100%, var(--layout-search-width)); margin: 0; } @@ -1991,7 +1996,8 @@ header.top-navbar #search-field { display: block; box-sizing: border-box; width: 100%; - padding: var(--space-2) var(--space-4); + min-height: 2.625rem; + padding: var(--space-2) var(--space-12) var(--space-2) var(--space-4); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); font-size: var(--font-size-base); @@ -2036,7 +2042,193 @@ header.top-navbar #search-results[aria-expanded="false"] { display: none; } +/* Search shortcut hint and help */ +.search-shortcuts kbd, +.search-shortcut-hint { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-width: var(--space-6); + height: var(--space-6); + padding: 0 var(--space-1); + border: 1px solid var(--color-border-default); + border-bottom-width: 2px; + border-radius: var(--radius-sm); + background: var(--color-background-secondary); + color: var(--color-text-secondary); + font: var(--font-size-xs) / 1 var(--font-family-mono); +} + +.search-shortcut-hint { + position: absolute; + top: 50%; + right: var(--space-3); + transform: translateY(-50%); + pointer-events: none; +} + +.search-shortcuts-toggle, +.search-shortcuts-close { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-background-secondary); + color: var(--color-text-secondary); + font: inherit; + font-size: var(--font-size-sm); + cursor: pointer; +} + +.search-shortcuts-toggle { + min-height: 2.625rem; + white-space: nowrap; +} + +.search-shortcuts-toggle svg { + width: 1.125rem; + height: var(--space-4); +} + +.search-shortcuts-toggle:hover, +.search-shortcuts-close:hover, +.search-shortcuts-toggle[aria-expanded="true"] { + border-color: var(--color-border-default); + color: var(--color-text-primary); +} + +.search-shortcuts-toggle:focus-visible, +.search-shortcuts-close:focus-visible { + outline: 2px solid var(--color-accent-primary); + outline-offset: 2px; +} + +.search-shortcuts-footer { + display: none; +} + +.search-shortcuts { + --search-shortcuts-right: var(--space-4); + + position: fixed; + inset: calc(var(--layout-header-height) + var(--space-2)) var(--search-shortcuts-right) auto auto; + box-sizing: border-box; + width: 23.5rem; + max-width: calc(100% - var(--space-6)); + max-height: calc(100dvh - var(--layout-header-height) - var(--space-6)); + margin: 0; + padding: var(--space-5); + overflow: auto; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + background: var(--color-background-primary); + color: var(--color-text-primary); + box-shadow: var(--shadow-lg); +} + +.search-shortcuts::backdrop { + background: transparent; +} + +.search-shortcuts-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.search-shortcuts-heading h2 { + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); +} + +.search-shortcuts-intro { + margin: var(--space-2) 0 var(--space-4); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.search-shortcuts-group + .search-shortcuts-group { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border-subtle); +} + +.search-shortcuts-group h3 { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.search-shortcuts-group dl { + margin: 0; + font-size: var(--font-size-sm); +} + +.search-shortcuts-group dl > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + min-height: 2.75rem; +} + +.search-shortcuts-group dd { + display: flex; + flex-shrink: 0; + gap: var(--space-1); + margin: 0; +} + @media (width <= 1023px) { + .search-shortcut-hint, + .search-shortcuts-desktop { + display: none; + } + + .search-shortcuts-footer { + display: block; + flex-shrink: 0; + padding: var(--space-1) var(--space-3); + border-top: 1px solid var(--color-border-subtle); + background: var(--color-background-secondary); + } + + .search-shortcuts-footer .search-shortcuts-toggle { + min-height: 2.75rem; + border-color: transparent; + background: transparent; + } + + .search-shortcuts, + header.top-navbar .navbar-search.is-open .search-surface { + inset: var(--space-16) auto auto 50%; + width: calc(100% - var(--space-8)); + max-width: 600px; + transform: translateX(-50%); + } + + .search-shortcuts { + max-height: 80dvh; + } + + .search-shortcuts::backdrop { + background: var(--color-overlay); + } + + .search-shortcuts-close { + min-height: 2.75rem; + min-width: 2.75rem; + } + body:has(header.top-navbar .navbar-search.is-open) { overflow: hidden; } @@ -2055,20 +2247,15 @@ header.top-navbar #search-results[aria-expanded="false"] { header.top-navbar .navbar-search.is-open .search-surface { position: fixed; - top: var(--space-16); - left: 50%; z-index: 2; display: flex; flex-direction: column; - width: calc(100% - var(--space-8)); - max-width: 600px; max-height: 80vh; margin: 0; overflow: hidden; background: var(--color-background-primary); border-radius: var(--radius-lg); box-shadow: var(--shadow-xl); - transform: translateX(-50%); } header.top-navbar .navbar-search.is-open .search-form { @@ -2175,9 +2362,17 @@ header.top-navbar #search-results[aria-expanded="false"] { } @media (width <= 420px) { + .search-shortcuts, header.top-navbar .navbar-search.is-open .search-surface { top: var(--space-4); width: calc(100% - var(--space-6)); + } + + .search-shortcuts { + padding: var(--space-4); + } + + header.top-navbar .navbar-search.is-open .search-surface { border-radius: var(--radius-md); } diff --git a/lib/rdoc/generator/template/aliki/js/aliki.js b/lib/rdoc/generator/template/aliki/js/aliki.js index 2855cabb75..9af319d69e 100644 --- a/lib/rdoc/generator/template/aliki/js/aliki.js +++ b/lib/rdoc/generator/template/aliki/js/aliki.js @@ -132,6 +132,7 @@ function hookSearch() { }); } + hookSearchShortcuts(container, search, openSearch); hookFocus(openSearch); // Hide search results when clicking outside the search area @@ -172,6 +173,72 @@ function hookSearch() { /* ===== Keyboard Shortcuts ===== */ +function hookSearchShortcuts(container, search, openSearch) { + const dialog = document.querySelector('#search-shortcuts'); + if (!dialog) return; + + const triggers = container.querySelectorAll('[data-search-shortcuts]'); + let navigationActive = false; + + const openHelp = () => { + navigationActive = search.navigationActive; + search.setNavigationActive(false); + const right = container.getBoundingClientRect().right; + dialog.style.setProperty('--search-shortcuts-right', `${window.innerWidth - right}px`); + triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'true')); + dialog.showModal(); + }; + + triggers.forEach((trigger) => { + // SearchNavigation also handles Enter on document, even outside the input. + trigger.addEventListener('keydown', (event) => { + if (event.key === 'Enter') event.stopPropagation(); + }); + trigger.addEventListener('click', () => { + trigger.focus({ preventScroll: true }); + openHelp(); + }); + }); + + document.addEventListener('keydown', (event) => { + if (event.key !== '?' || event.defaultPrevented || event.isComposing || + event.ctrlKey || event.metaKey || event.altKey) return; + const active = document.activeElement; + if (active.matches('input, textarea, select') || active.isContentEditable) return; + + event.preventDefault(); + openHelp(); + }); + + // Keep search navigation paused, but let users try the focus shortcut. + dialog.addEventListener('keydown', (event) => { + event.stopPropagation(); + if (event.key === '/') { + event.preventDefault(); + // Restore search focus after the dialog restores its navigation state. + dialog.addEventListener('close', () => { + if (container.classList.contains('is-open')) { + search.input.focus({ preventScroll: true }); + } else { + openSearch(); + } + }, { once: true }); + dialog.close(); + } + }); + dialog.querySelector('.search-shortcuts-close').addEventListener('click', () => dialog.close()); + dialog.addEventListener('click', (event) => { + if (event.target !== dialog) return; + const bounds = dialog.getBoundingClientRect(); + if (event.clientX < bounds.left || event.clientX > bounds.right || + event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close(); + }); + dialog.addEventListener('close', () => { + triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false')); + search.setNavigationActive(navigationActive); + }); +} + function hookFocus(openSearch) { document.addEventListener("keydown", (event) => { if (document.activeElement.tagName === 'INPUT') {