fix(desktop): polish settings search (#48210)

Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
David Hill
2026-09-11 23:27:23 -06:00
committed by GitHub
parent 27027777d2
commit bbf4cd4975
16 changed files with 578 additions and 179 deletions
@@ -192,7 +192,7 @@ test("clearing project fields sends explicit removal values", async ({ page }) =
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
await expect(settings).toBeFocused()
await page.keyboard.press("Control+t")
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
@@ -22,9 +22,7 @@ test.beforeEach(async ({ page }) => {
)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(
page.getByTestId("settings-screen").getByRole("combobox", { name: "Search settings", exact: true }),
).toBeFocused()
await expect(page.getByTestId("settings-screen")).toBeFocused()
})
for (const viewport of [
@@ -31,7 +31,7 @@ for (const viewport of [
const panel = settings.getByRole("tabpanel")
const main = page.getByRole("main")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
await expect(settings).toBeFocused()
await page.setViewportSize(viewport)
await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/)
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
@@ -34,8 +34,9 @@ function ui(page: Page) {
const settings = page.getByTestId("settings-screen")
return {
settings,
search: settings.getByRole("combobox", { name: "Search settings", exact: true }),
search: settings.getByRole("combobox", { name: "Search", exact: true }),
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
viewport: settings.locator(".settings-search-scroll > .scroll-view__viewport"),
}
}
@@ -53,14 +54,14 @@ test.beforeEach(async ({ page }) => {
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
await page.getByRole("button", { name: "Settings", exact: true }).click()
const view = ui(page)
await expect(view.search).toBeFocused()
await expect(view.settings).toBeFocused()
// Readiness includes the server-backed project inventory, not just the settings shell.
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(1)
await view.search.clear()
})
test("autofocus, pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
test("pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
const view = ui(page)
await view.search.fill("font")
const code = view.results.getByRole("option", { name: "Code Font, Appearance", exact: true })
@@ -104,7 +105,7 @@ test("page priority, compact icons, section context, and the minimal empty state
await view.search.fill("work")
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
await expect(pageResult).toHaveCSS("min-height", "32px")
await expect(pageResult).toHaveCSS("height", "28px")
await expect(pageResult.locator("svg")).toHaveCount(1)
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
await view.search.fill("Agent")
@@ -116,13 +117,45 @@ test("page priority, compact icons, section context, and the minimal empty state
)
await view.search.fill("zzzzzzzzzz")
await expect(view.results.getByRole("option")).toHaveCount(0)
await expect(view.settings.getByRole("status")).toHaveText("No setting found")
await expect(view.settings.getByRole("status")).toHaveText('No results for "zzzzzzzzzz"')
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await view.search.press("Escape")
await expect(view.search).toHaveValue("")
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
})
test("empty queries keep the closing quote beside the ellipsis while typing and resizing", async ({ page }) => {
const view = ui(page)
const query = "zzzz 🧑🏽‍💻 ".repeat(40)
await view.search.fill(query)
const status = view.settings.getByRole("status")
const quoted = status.locator("bdi")
await expect(status).toHaveAccessibleName(`No results for "${query}"`)
await expect(quoted).toHaveText(/^".+…"$/)
await expect(status).toHaveCSS("height", "28px")
await expect
.poll(() =>
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
)
.toBe(true)
const text = await quoted.textContent()
await page.setViewportSize({ width: 390, height: 844 })
await expect(quoted).toHaveText(/^".+…"$/)
await expect(quoted).not.toHaveText(text!)
await expect
.poll(() =>
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
)
.toBe(true)
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
await view.search.fill("zzzzzzzzzz")
await expect(status).toHaveText('No results for "zzzzzzzzzz"')
await view.search.pressSequentially("x")
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
})
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
const view = ui(page)
for (const entry of [
@@ -214,8 +247,8 @@ test("qualified project results preserve query and selection on return", async (
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
await view.search.fill("OpenCode")
const project = view.results.getByRole("option")
await expect(project).toHaveText("OOpenCodeProjects")
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "14px")
await expect(project).toHaveText("OOpenCode")
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "16px")
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
await view.search.fill("OpenCode name")
const name = view.results.getByRole("option")
@@ -241,16 +274,29 @@ test("returning from a project restores a scrolled result list", async ({ page }
const view = ui(page)
await view.search.fill("OpenCode")
await expect(view.results.getByRole("option")).toHaveCount(30)
const scrollbar = view.settings.locator('.settings-search-scroll > .scroll-view__thumb[data-orientation="vertical"]')
await view.viewport.hover()
await expect(scrollbar).toHaveAttribute("data-visible", "true")
const bounds = (await scrollbar.boundingBox())!
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
await page.mouse.down()
await expect(scrollbar).toHaveAttribute("data-dragging", "true")
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 80, { steps: 4 })
await page.mouse.up()
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
await project.scrollIntoViewIfNeeded()
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const scroll = await view.results.evaluate((list) => list.scrollTop)
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
const scroll = await view.viewport.evaluate((list) => list.scrollTop)
await project.click()
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
await expect(view.search).toBeFocused()
await expect(project).toHaveAttribute("aria-selected", "true")
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBe(scroll)
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBe(scroll)
await view.search.fill("about")
await expect(view.results.getByRole("option", { name: "About", exact: true })).toBeVisible()
await expect(scrollbar).toHaveCount(0)
})
test("IME confirmation does not activate a search result", async ({ page }) => {
@@ -361,6 +407,44 @@ for (const direction of ["ltr", "rtl"] as const) {
colorScheme: direction === "rtl" ? "dark" : "light",
contextOptions: { reducedMotion: "reduce" },
})
test("search input fades track typing, caret scrolling, resizing, and clearing", async ({ page }) => {
const view = ui(page)
await page.setViewportSize({ width: 1280, height: 900 })
await page.evaluate((direction) => {
document.documentElement.dir = direction
}, direction)
await view.search.fill(direction === "rtl" ? "غيرموجود ".repeat(30) : "zzzz ".repeat(30))
await view.search.press("End")
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).not.toHaveCSS("mask-image", "none")
await view.search.press("Home")
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
await view.search.evaluate((input: HTMLInputElement, direction) => {
input.scrollLeft = ((input.scrollWidth - input.clientWidth) / 2) * (direction === "rtl" ? -1 : 1)
}, direction)
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
await view.search.fill("z".repeat(50))
await expect(view.search).not.toHaveCSS("mask-image", "none")
await page.setViewportSize({ width: 600, height: 844 })
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).toHaveCSS("mask-image", "none")
await view.search.fill("zzzz ".repeat(30))
await expect(view.search).not.toHaveCSS("mask-image", "none")
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
await expect(view.search).toHaveValue("")
await expect(view.search).toBeFocused()
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
await expect(view.search).toHaveCSS("mask-image", "none")
})
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
const view = ui(page)
await page.evaluate((direction) => {
+7 -2
View File
@@ -999,12 +999,17 @@ export const dict = {
"settings.backToApp": "Back to app",
"settings.backToSettings": "Back to settings",
"settings.backToProjects": "Back to projects",
"settings.search.placeholder": "Search settings",
"settings.search.placeholder": "Search",
"settings.search.results": "Settings results",
"settings.search.group.pages": "Pages",
"settings.search.group.settings": "Settings",
"settings.search.group.projects": "Projects",
"settings.search.group.servers": "Servers",
"settings.search.result": "{{title}}, {{scope}}, {{page}}",
"settings.search.page": "{{title}}, {{scope}}",
"settings.search.result.unscoped": "{{title}}, {{page}}",
"settings.search.empty": "No setting found",
"settings.search.empty": "No results for {{query}}",
"settings.search.empty.query": '"{{query}}"',
"settings.search.refine": "Narrow your search to see more specific results.",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
@@ -221,6 +221,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
params?: Record<string, string | number | boolean>,
) => string
const rich = (key: Parameters<typeof t>[0], params: Record<string, JSX.Element>) =>
t(key)
.split(/(\{\{\w+\}\})/g)
.map((part, index) => (index % 2 ? (params[part.slice(2, -2)] ?? part) : part))
const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
@@ -262,6 +267,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
locales: LOCALES,
label,
t,
rich,
plural,
pluralForm,
setLocale(next: Locale) {
@@ -0,0 +1,68 @@
import { createEffect, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/runtime/i18n/language"
export function SettingsSearchEmpty(props: { query: string }) {
const language = useLanguage()
const [state, setState] = createStore({ query: props.query })
let quoted: HTMLSpanElement | undefined
let measure: HTMLSpanElement | undefined
const text = (query: string) => language.t("settings.search.empty.query", { query })
const update = () => {
if (!quoted || !measure) return
const width = quoted.getBoundingClientRect().width
const fits = (query: string) => {
measure!.textContent = text(query)
return measure!.getBoundingClientRect().width <= width
}
if (fits(props.query)) {
setState("query", props.query)
return
}
const characters = Array.from(
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(props.query),
(item) => item.segment,
)
let start = 0
let end = characters.length
while (start < end) {
const middle = Math.ceil((start + end) / 2)
if (fits(`${characters.slice(0, middle).join("").trimEnd()}…`)) {
start = middle
continue
}
end = middle - 1
}
setState("query", `${characters.slice(0, start).join("").trimEnd()}…`)
}
createEffect(update)
onMount(() => {
if (!quoted || !measure) return
// The measuring text also observes font changes that do not resize the available space.
createResizeObserver([quoted, measure], update)
})
return (
<>
<div
class="settings-search-empty"
role="status"
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
>
{language.rich("settings.search.empty", {
query: (
<span ref={quoted} class="settings-search-empty-quoted">
<bdi dir="auto">{text(state.query)}</bdi>
</span>
),
})}
</div>
<span class="settings-search-empty-measure" aria-hidden="true">
<span ref={measure} dir="auto" />
</span>
</>
)
}
+180 -37
View File
@@ -8,105 +8,248 @@
.settings-search {
position: relative;
gap: 0;
}
.settings-search[data-empty="true"] [data-slot="text-input-v2-icon-button"] {
visibility: hidden;
}
.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] {
color: var(--v2-icon-icon-faint);
}
.settings-search
[data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) {
background: transparent;
color: var(--v2-icon-icon-base);
}
.settings-search > [data-component="text-input-v2"] {
width: 100%;
flex-shrink: 0;
background: transparent;
box-shadow: none;
}
.settings-search > [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]),
.settings-search > [data-component="text-input-v2"]:has(input:not(:placeholder-shown)):not([data-disabled]) {
background: var(--v2-overlay-simple-overlay-hover);
}
.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] {
--mask-start: 16px;
}
.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] {
--mask-end: 16px;
}
.settings-search [data-slot="text-input-v2-input"]:dir(rtl) {
--mask-direction: to left;
}
.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) {
mask-image: linear-gradient(
var(--mask-direction, to right),
transparent,
black var(--mask-start, 0px),
black calc(100% - var(--mask-end, 0px)),
transparent
);
}
.settings-search-empty {
min-width: 0;
margin-block-start: 16px;
overflow: hidden;
white-space: pre;
}
.settings-search-empty-quoted {
flex: 1;
min-width: 0;
color: var(--v2-text-text-base);
}
.settings-search-empty-measure {
position: absolute;
visibility: hidden;
width: 0;
height: 0;
overflow: hidden;
white-space: pre;
pointer-events: none;
}
.settings-search-empty-measure > span {
display: block;
width: max-content;
}
.settings-search-category,
.settings-search-group,
.settings-search-detail,
.settings-search-note {
color: var(--v2-text-text-muted);
font-size: 12px;
font-size: 13px;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
.settings-search-results {
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
padding: 0;
/* Keep the first and last results clear of the fades when scrolling to either end. */
padding-block: 16px 32px;
}
.settings-search-group {
padding: 16px 6px 6px;
.settings-search-scroll {
/* The 4px thumb is centered in a 12px hit target, leaving an 8px gap beside the rows. */
padding-inline-end: 16px;
}
@container settings-screen (min-width: 800px) {
.settings-search-scroll {
width: calc(100% + 16px);
margin-inline-end: -16px;
}
}
.settings-search-scroll > .scroll-view__viewport {
overscroll-behavior: contain;
scroll-padding-block: 16px 32px;
-webkit-mask-image: linear-gradient(to bottom, transparent, black 16px, black calc(100% - 32px), transparent);
mask-image: linear-gradient(to bottom, transparent, black 16px, black calc(100% - 32px), transparent);
}
.settings-search-scroll[data-empty="true"] {
display: none;
}
.settings-search-category {
padding: 16px 6px 4px;
padding-inline-start: 8px;
overflow-wrap: anywhere;
font-weight: 530;
}
.settings-search-group:first-child {
padding-top: 4px;
.settings-search-category:first-child {
padding-block: 6px;
}
.settings-search-group {
padding: 8px 6px 4px;
padding-inline-start: 8px;
overflow-wrap: anywhere;
font-weight: 440;
}
.settings-search-result,
.settings-search-empty {
display: flex;
align-items: center;
flex-shrink: 0;
width: 100%;
height: 28px;
padding: 0 6px;
padding-inline-start: 8px;
color: var(--v2-text-text-muted);
}
.settings-search-result {
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
width: 100%;
min-height: 48px;
padding: 8px 6px;
gap: 8px;
border: 0;
border-radius: 6px;
border-radius: 4px;
background: transparent;
text-align: start;
cursor: pointer;
cursor: default;
}
.settings-search-result:has(.settings-search-detail) {
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 3px;
height: auto;
min-height: 48px;
padding-block: 6px;
}
.settings-search-title,
.settings-search-empty,
.settings-search-empty-measure {
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
.settings-search-title {
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
overflow-wrap: anywhere;
grid-column: 2;
justify-self: start;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-search-result[data-compact="true"] {
min-height: 32px;
.settings-search-detail {
min-width: 0;
padding-inline-start: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-search-label {
display: flex;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
width: 100%;
min-width: 0;
align-items: center;
gap: 6px;
color: var(--v2-icon-icon-muted);
gap: 8px;
}
.settings-search-result[data-icons="false"] .settings-search-label {
grid-template-columns: 0 minmax(0, 1fr);
gap: 0;
}
.settings-search-result[data-icons="false"] .settings-search-detail {
padding-inline-start: 0;
}
.settings-search-project-icon[data-component="project-avatar-v2"] {
width: 14px;
height: 14px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.settings-search-result[data-highlighted="true"],
.settings-search-result[aria-current="location"],
.settings-search-result:active {
background: var(--v2-overlay-simple-overlay-pressed);
background: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
}
.settings-search-result:focus-visible {
outline: none;
background: var(--v2-overlay-simple-overlay-pressed);
background: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
}
@media (hover: hover) {
.settings-search-result:hover:not([data-highlighted="true"]):not([aria-current="location"]):not(:active) {
background: var(--v2-overlay-simple-overlay-hover);
background: var(--v2-background-bg-layer-03);
}
}
.settings-search-empty {
padding: 20px 6px;
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
.settings-search-note {
padding: 12px 6px 0;
}
+182 -116
View File
@@ -4,6 +4,7 @@ import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { TextInput } from "@opencode/ui/text-input"
import { ScrollView } from "@opencode/ui/scroll-view"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
@@ -14,6 +15,7 @@ import { useSettingsSurface } from "./surface"
import { pageIcons } from "./pages"
import { rankSettings, type SettingsSearchResult } from "./search-results"
import { settingsSearchIndex } from "./search-index"
import { SettingsSearchEmpty } from "./search-empty"
export function SettingsSearch() {
const language = useLanguage()
@@ -24,12 +26,22 @@ export function SettingsSearch() {
const surface = useSettingsSurface()
const search = surface.search
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({ narrow: false })
const [state, setState] = createStore({ narrow: false, overflow: { start: false, end: false } })
const listID = `settings-results-${createUniqueId()}`
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let results: HTMLDivElement | undefined
const updateOverflow = () => {
if (!input) return
const offset = Math.abs(input.scrollLeft)
setState("overflow", {
start: offset > 1,
end: input.scrollWidth - input.clientWidth - offset > 1,
})
}
createEffect(on(() => search.state.query, updateOverflow))
onMount(() => {
if (input) createResizeObserver(input, updateOverflow)
const screen = root?.closest<HTMLElement>(".settings-screen")
if (!screen) return
setState("narrow", screen.clientWidth < 800)
@@ -69,7 +81,34 @@ export function SettingsSearch() {
}),
)
const matches = createMemo(() => rankSettings(search.state.query, catalog(), origin()))
const shown = createMemo(() => matches().slice(0, 60))
const category = (item: SettingsSearchResult) =>
item.topLevel
? "settings.search.group.pages"
: !item.entity
? "settings.search.group.settings"
: item.project
? "settings.search.group.projects"
: "settings.search.group.servers"
const shown = createMemo(() => {
const groups = new Map<ReturnType<typeof category>, SettingsSearchResult[]>()
matches()
.slice(0, 60)
.forEach((item) => {
const key = category(item)
const items = groups.get(key)
if (items) return items.push(item)
groups.set(key, [item])
})
return Array.from(groups.values()).flat()
})
const iconGroups = createMemo(
() =>
new Set(
shown()
.filter((item) => item.topLevel || item.projectInfo)
.map(category),
),
)
const expanded = () => !!search.state.query.trim() && (!state.narrow || search.state.expanded)
const highlighted = () => shown().find((item) => item.id === search.state.highlighted) ?? shown()[0]
const optionID = (id: string) => `${listID}-${encodeURIComponent(id)}`
@@ -83,6 +122,7 @@ export function SettingsSearch() {
),
)
const group = (item: SettingsSearchResult) => {
if (item.entity && !item.project) return ""
if (!item.server || servers().length > 1) return item.owner
return item.projectName ?? ""
}
@@ -104,50 +144,56 @@ export function SettingsSearch() {
class="settings-search"
data-expanded={search.state.expanded}
data-empty={!search.state.query.trim()}
onKeyDown={(event) => {
if (
event.defaultPrevented ||
event.isComposing ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
)
return
if (
event.target !== input &&
!(event.target instanceof Element && event.target.closest(".settings-search-result"))
)
return
if (event.key === "Escape" && search.state.query) {
on:keydown={{
// Handle result navigation before ScrollView's generic scrolling keys.
capture: true,
handleEvent: (event) => {
if (
event.defaultPrevented ||
event.isComposing ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
)
return
if (
event.target !== input &&
!(event.target instanceof Element && event.target.closest(".settings-search-result"))
)
return
if (event.key === "Escape" && search.state.query) {
event.preventDefault()
event.stopPropagation()
clear()
return
}
if (event.key === "Enter" && expanded() && highlighted()) {
event.preventDefault()
event.stopPropagation()
select(highlighted()!)
return
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
if ((event.key === "Home" || event.key === "End") && event.target === input) return
event.preventDefault()
event.stopPropagation()
clear()
return
}
if (event.key === "Enter" && expanded() && highlighted()) {
event.preventDefault()
select(highlighted()!)
return
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
if ((event.key === "Home" || event.key === "End") && event.target === input) return
event.preventDefault()
search.expand()
const index = shown().findIndex((item) => item.id === highlighted()?.id)
const next =
shown()[
event.key === "Home"
? 0
: event.key === "End"
? shown().length - 1
: Math.max(0, Math.min(shown().length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))
]
if (!next) return
search.highlight(next.id)
const row = results?.querySelector<HTMLElement>(`#${CSS.escape(optionID(next.id))}`)
if (event.target !== input) row?.focus({ preventScroll: true })
row?.scrollIntoView({ block: "nearest", inline: "nearest" })
search.expand()
const index = shown().findIndex((item) => item.id === highlighted()?.id)
const next =
shown()[
event.key === "Home"
? 0
: event.key === "End"
? shown().length - 1
: Math.max(0, Math.min(shown().length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))
]
if (!next) return
search.highlight(next.id)
const row = results?.querySelector<HTMLElement>(`#${CSS.escape(optionID(next.id))}`)
if (event.target !== input) row?.focus({ preventScroll: true })
row?.scrollIntoView({ block: "nearest", inline: "nearest" })
},
}}
>
<TextInput
@@ -162,9 +208,13 @@ export function SettingsSearch() {
placeholder={language.t("settings.search.placeholder")}
aria-label={language.t("settings.search.placeholder")}
aria-controls={search.state.query.trim() ? listID : undefined}
data-overflow-start={state.overflow.start}
data-overflow-end={state.overflow.end}
showClearButton
clearIcon="circle-xmark"
onClearClick={clear}
onFocus={() => search.expand()}
onScroll={updateOverflow}
onInput={(event) => {
search.input(event.currentTarget.value)
}}
@@ -173,87 +223,103 @@ export function SettingsSearch() {
/>
<Show when={search.state.query.trim()}>
<div class="settings-search-matches">
<div
ref={(element) => {
<ScrollView
class="settings-search-scroll"
data-empty={!shown().length}
viewportRef={(element) => {
results = element
queueMicrotask(() => {
if (element.isConnected) element.scrollTop = search.state.scrollTop
})
}}
id={listID}
class="settings-search-results"
role="listbox"
aria-label={language.t("settings.search.results")}
onScroll={(event) => search.scroll(event.currentTarget.scrollTop)}
>
<For each={shown()}>
{(item, index) => (
<>
<Show when={group(item) && (index() === 0 || group(shown()[index() - 1]) !== group(item))}>
<div class="settings-search-group" role="presentation">
<bdi dir="auto">{group(item)}</bdi>
</div>
</Show>
<button
id={optionID(item.id)}
role="option"
aria-selected={highlighted()?.id === item.id}
aria-label={
item.page === item.title && !item.owner
? item.title
: language.t(
item.page === item.title
? "settings.search.page"
: item.owner
? "settings.search.result"
: "settings.search.result.unscoped",
{ title: item.title, scope: item.owner, page: item.page },
)
}
type="button"
class="settings-search-result"
data-compact={item.page === item.title}
data-setting-target={item.view.target}
data-result-id={item.id}
data-highlighted={highlighted()?.id === item.id}
aria-current={search.state.selected === item.id ? "location" : undefined}
title={[item.owner, item.page].filter(Boolean).join(" › ")}
tabIndex={highlighted()?.id === item.id ? 0 : -1}
onFocus={() => search.highlight(item.id)}
onClick={(event) => {
if (item.view.type === "root" && !state.narrow) event.currentTarget.focus({ preventScroll: true })
select(item)
}}
>
<span class="settings-search-label">
<Show
when={item.projectInfo}
fallback={
<Show when={item.topLevel}>
<Icon name={pageIcons[item.view.tab]} />
</Show>
}
>
{(project) => (
<ProjectIcon project={project()} class="settings-search-project-icon" aria-hidden="true" />
)}
</Show>
<bdi dir="auto" class="settings-search-title">
{item.title}
</bdi>
</span>
<Show when={item.page !== item.title}>
<span class="settings-search-detail">{item.page}</span>
<div
id={listID}
class="settings-search-results"
role="listbox"
aria-label={language.t("settings.search.results")}
>
<For each={shown()}>
{(item, index) => (
<>
<Show when={index() === 0 || category(shown()[index() - 1]) !== category(item)}>
<div class="settings-search-category" role="presentation">
{language.t(category(item))}
</div>
</Show>
</button>
</>
)}
</For>
</div>
<Show when={!matches().length}>
<div class="settings-search-empty" role="status">
{language.t("settings.search.empty")}
<Show
when={
group(item) &&
(index() === 0 ||
category(shown()[index() - 1]) !== category(item) ||
group(shown()[index() - 1]) !== group(item))
}
>
<div class="settings-search-group" role="presentation">
<bdi dir="auto">{group(item)}</bdi>
</div>
</Show>
<button
id={optionID(item.id)}
role="option"
aria-selected={highlighted()?.id === item.id}
aria-label={
item.page === item.title && !item.owner
? item.title
: language.t(
item.page === item.title
? "settings.search.page"
: item.owner
? "settings.search.result"
: "settings.search.result.unscoped",
{ title: item.title, scope: item.owner, page: item.page },
)
}
type="button"
class="settings-search-result"
data-icons={iconGroups().has(category(item))}
data-setting-target={item.view.target}
data-result-id={item.id}
data-highlighted={highlighted()?.id === item.id}
aria-current={search.state.selected === item.id ? "location" : undefined}
title={[item.owner, item.page].filter(Boolean).join(" › ")}
tabIndex={highlighted()?.id === item.id ? 0 : -1}
onFocus={() => search.highlight(item.id)}
onClick={(event) => {
if (item.view.type === "root" && !state.narrow)
event.currentTarget.focus({ preventScroll: true })
select(item)
}}
>
<span class="settings-search-label">
<Show
when={item.projectInfo}
fallback={
<Show when={item.topLevel}>
<Icon name={pageIcons[item.view.tab]} />
</Show>
}
>
{(project) => (
<ProjectIcon project={project()} class="settings-search-project-icon" aria-hidden="true" />
)}
</Show>
<bdi dir="auto" class="settings-search-title">
{item.title}
</bdi>
</span>
<Show when={item.page !== item.title && !(item.entity && item.project)}>
<span class="settings-search-detail">{item.page}</span>
</Show>
</button>
</>
)}
</For>
</div>
</ScrollView>
<Show when={!matches().length}>
<SettingsSearchEmpty query={search.state.query} />
</Show>
<Show when={matches().length > shown().length}>
<p class="settings-search-note">{language.t("settings.search.refine")}</p>
+5 -1
View File
@@ -42,7 +42,7 @@
flex-shrink: 0;
width: 328px;
min-width: 328px;
padding-block: 48px;
padding-block: 70px 48px;
padding-inline-start: 24px;
padding-inline-end: 64px;
border-inline-end: none;
@@ -76,6 +76,10 @@
gap: 16px;
}
.settings-nav > .settings-back {
margin-block-end: 8px;
}
.settings-nav-groups {
display: flex;
width: 100%;
+1 -3
View File
@@ -76,9 +76,7 @@ export function SettingsScreen() {
let viewType = surface.view().type
let activation = 0
onMount(() =>
(root?.querySelector<HTMLInputElement>(".settings-search input") ?? root)?.focus({ preventScroll: true }),
)
onMount(() => root?.focus({ preventScroll: true }))
createEffect(() => {
const next = surface.view().type
if (next === viewType) return
@@ -10,6 +10,8 @@ export interface TextInputProps extends Omit<ComponentProps<"input">, "type"> {
showCopyButton?: boolean
/** Show the trailing clear action. */
showClearButton?: boolean
/** Icon used by the trailing clear action. */
clearIcon?: ComponentProps<typeof Icon>["name"]
/** Accessible label for the copy button. */
copyLabel?: string
/** Accessible label for the clear button. */
@@ -33,6 +35,7 @@ export function TextInput(props: TextInputProps) {
"leadingIcon",
"showCopyButton",
"showClearButton",
"clearIcon",
"copyLabel",
"clearLabel",
"onCopyClick",
@@ -91,7 +94,7 @@ export function TextInput(props: TextInputProps) {
local.onCopyClick?.(event)
}}
>
<Icon name={local.showClearButton ? "xmark-small" : "copy"} />
<Icon name={local.showClearButton ? (local.clearIcon ?? "xmark-small") : "copy"} />
</button>
</Show>
</div>
+4
View File
@@ -52,6 +52,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M8.75 11.75H7.25V10.25H8.75V11.75Z" fill="currentColor"/><path d="M8.75 9.25H7.25V4.25H8.75V9.25Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C9.93286 1 11.684 1.7836 12.9502 3.0498C14.2164 4.31601 15 6.06714 15 8C15 11.866 11.866 15 8 15C6.06714 15 4.31601 14.2164 3.0498 12.9502C1.7836 11.684 1 9.93286 1 8C1 4.13401 4.13401 1 8 1ZM8 2C4.68629 2 2 4.68629 2 8C2 9.65699 2.67148 11.1559 3.75781 12.2422C4.84415 13.3285 6.34301 14 8 14C11.3137 14 14 11.3137 14 8C14 6.34301 13.3285 4.84415 12.2422 3.75781C11.1559 2.67148 9.65699 2 8 2Z" fill="currentColor"/>`,
},
"circle-xmark": {
viewBox: "0 0 16 16",
body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.33334 8.00016C1.33334 4.31826 4.31811 1.3335 8.00001 1.3335C11.6819 1.3335 14.6667 4.31826 14.6667 8.00016C14.6667 11.6821 11.6819 14.6668 8.00001 14.6668C4.31811 14.6668 1.33334 11.6821 1.33334 8.00016ZM6.00001 5.29306L5.2929 6.00016L7.2929 8.00016L5.2929 10.0002L6.00001 10.7073L8.00001 8.70727L10 10.7073L10.7071 10.0002L8.70712 8.00016L10.7071 6.00016L10 5.29306L8.00001 7.29306L6.00001 5.29306Z" fill="currentColor"/>`,
},
"sidebar-right": {
viewBox: "0 0 20 20",
body: `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
+2 -2
View File
@@ -168,7 +168,7 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-faint: var(--v2-grey-700);
--v2-icon-icon-inverse: var(--v2-grey-1000);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-400);
@@ -411,7 +411,7 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-faint: var(--v2-grey-700);
--v2-icon-icon-inverse: var(--v2-grey-1100);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-400);
+1 -1
View File
@@ -430,7 +430,7 @@
"v2-text-text-code-path": "var(--v2-blue-300)",
"v2-icon-icon-base": "var(--v2-grey-400)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-faint": "var(--v2-grey-500)",
"v2-icon-icon-faint": "var(--v2-grey-700)",
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
"v2-icon-icon-contrast": "var(--v2-grey-100)",
"v2-icon-icon-accent": "var(--v2-blue-400)",
+21 -1
View File
@@ -1,9 +1,20 @@
import { describe, expect, test } from "bun:test"
import type { DesktopTheme } from "../types"
import { contrastRatio } from "../color"
import type { DesktopTheme, HexColor, ResolvedV2Theme } from "../types"
import { resolveThemeV2, resolveThemeVariantV2, themeV2ToCss } from "./resolve"
const theme: DesktopTheme = await Bun.file(new URL("../themes/oc-2.json", import.meta.url)).json()
describe("icon emphasis", () => {
test.each(["light", "dark"] as const)("OC-2 %s icons increase in contrast from faint to base", (mode) => {
expectIconEmphasis(resolveThemeV2(theme)[mode])
})
test.each([false, true])("custom theme fallbacks preserve icon emphasis (dark: %s)", (dark) => {
expectIconEmphasis(resolveThemeVariantV2({ ...theme[dark ? "dark" : "light"], v2Overrides: undefined }, dark))
})
})
describe("contrast icon-button tokens", () => {
test("OC-2 dark mode uses a light background and an inverse icon matching the base surface", () => {
const tokens = resolveThemeV2(theme).dark
@@ -44,3 +55,12 @@ describe("contrast icon-button tokens", () => {
expect(tokens["v2-background-bg-contrast"]).toBe("var(--v2-grey-700)")
})
})
function expectIconEmphasis(tokens: ResolvedV2Theme) {
const resolve = (value: string): HexColor =>
value.startsWith("var(--") ? resolve(tokens[value.slice(6, -1)]) : (value as HexColor)
const background = resolve(tokens["v2-background-bg-base"])
const contrast = (role: string) => contrastRatio(resolve(tokens[`v2-icon-icon-${role}`]), background)
expect(contrast("faint")).toBeLessThan(contrast("muted"))
expect(contrast("muted")).toBeLessThan(contrast("base"))
}