diff --git a/public/ui.ts b/public/ui.ts index 93accb8..03299ee 100644 --- a/public/ui.ts +++ b/public/ui.ts @@ -284,9 +284,54 @@ ); }); + // Search palette keyboard nav. Focus rovers over the real results rather + // than tracking a selected index, so Enter, middle-click and Tab keep their + // native link behaviour and no aria-activedescendant bookkeeping is needed. + // The cycle is input -> first result -> ... -> last result -> input. + document.addEventListener("keydown", (e) => { + const modal = dialogById("search-modal"); + if (!modal?.open) return; + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !modal.contains(active)) return; + + const input = $("search-input"); + const results = Array.from( + modal.querySelectorAll("[data-search-result]"), + ); + + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + if (results.length === 0 || !input) return; + e.preventDefault(); + // The query field is the first stop on the ring, so ArrowDown off the + // last result and ArrowUp off the first both land back on the query. + const ring: HTMLElement[] = [input, ...results]; + const current = ring.indexOf(active); + if (current === -1) return; + const step = e.key === "ArrowDown" ? 1 : -1; + ring[(current + step + ring.length) % ring.length]?.focus(); + return; + } + + // Typing with a result focused should reach the query field. Focus moves on + // keydown and the default action isn't cancelled, so the character still + // lands in the input it moved to. + if ( + active !== input && + e.key.length === 1 && + !e.ctrlKey && + !e.metaKey && + !e.altKey + ) { + input?.focus(); + } + }); + // Global search shortcut: opt-in via data-search-shortcut on . document.addEventListener("keydown", (e) => { if (!document.body.dataset["searchShortcut"]) return; + // Already open: showModal() on an open dialog throws, and focus is either in + // the query field or roving over a result, where the key means something else. + if (dialogById("search-modal")?.open) return; const target = e.target instanceof Element ? e.target : document.activeElement; if ( diff --git a/src/views/layout.ts b/src/views/layout.ts index aadf709..9dd585e 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -19,6 +19,7 @@ import { import { ICONS } from "./icons.ts"; import { dangerSmallButtonClass, + fieldFocusClass, kbdClass, outlineSmallButtonClass, primarySmallButtonClass, @@ -111,7 +112,7 @@ function renderSearchButton(locale: Locale = "en"): string { class="${sidebarButtonClass} mb-3"> ${ICONS.search} ${msg.search.searchNotes} - / + `; } @@ -140,11 +141,20 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { const editLink = options.currentNoteSlug && canEdit(level) - ? `${msg.wiki.edit}` + ? `${msg.wiki.edit}` : ""; const newNoteLink = canEdit(level) - ? `${msg.wiki.newNote}` + ? `${msg.wiki.newNote}` + : ""; + + // The button tokens are inline-flex, and Tailwind emits .inline-flex after + // .block, so a `block` on the anchor loses regardless of attribute order and + // the buttons sit side by side. Flex children are blockified, which stacks + // them full-width without fighting the token's display. + const noteActions = [editLink, newNoteLink].filter(Boolean); + const noteActionsBlock = noteActions.length + ? `
${noteActions.join("\n")}
` : ""; const dropdownItemClass = `block px-3 py-1.5 text-sm ${THEME.textSecondary} ${THEME.accentSoft} ${MENU_ITEM_FOCUS} rounded`; @@ -211,8 +221,7 @@ function renderWithSidebar(body: string, options: LayoutOptions): string {
${options.bookmarkHtml ? `
${options.bookmarkHtml}
` : ""} ${renderSearchButton(locale)} - ${editLink} - ${newNoteLink} + ${noteActionsBlock}
- +
${highlightMatch(r.snippet, query)}
` : ""; - return ` + return `
${highlightMatch(r.title, query)}
${snippetHtml}
`; diff --git a/src/views/settings.ts b/src/views/settings.ts index 5c6e0d1..f1c9117 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -10,22 +10,19 @@ import { type LayoutOptions, layout } from "./layout.ts"; import { dangerButtonClass, dangerSmallButtonClass, + denseInputClass, errorBanner, + fieldFocusClass, primarySmallButtonClass, successBanner, THEME, - touchFieldClass, touchTargetClass, } from "./theme/index.ts"; import { themes } from "./theme/themes.ts"; -// Settings is dense with one-off controls; these keep them on the same touch -// floor as the shared tokens without inheriting the tokens' desktop sizing. -const settingsFieldClass = `${touchFieldClass} px-3 py-2 md:py-1.5 border ${THEME.borderInput} rounded focus:outline-none focus:border-[var(--accent-focus-input)]`; - // The members table stays xs-dense on desktop, so it opts out of touchFieldClass' // text-sm and only takes the 16px floor that keeps iOS from zooming. -const tableFieldClass = `${touchTargetClass} text-base md:text-xs border ${THEME.border} rounded px-2 py-1 md:px-1 md:py-0.5`; +const tableFieldClass = `${touchTargetClass} text-base md:text-xs border ${THEME.border} rounded px-2 py-1 md:px-1 md:py-0.5 ${fieldFocusClass}`; interface SettingsPageOptions extends LayoutOptions { wikiDid: string; @@ -163,11 +160,11 @@ function renderAddMemberForm( + class="${denseInputClass}" />
- @@ -287,7 +284,7 @@ function renderDangerZone( ${fmt(msg.settings.typeToConfirm, { name: `${escapeHtml(wikiName)}` })} @@ -343,7 +340,7 @@ function renderHomepageSection( autocomplete="off" value="${escapeHtml(currentValue)}" placeholder="${escapeHtml(msg.settings.homepagePlaceholder)}" - class="${settingsFieldClass} w-full" /> + class="${denseInputClass}" />