Skip to content

Commit bbf4cd4

Browse files
iamdavidhillusrnk1Hona
authored
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>
1 parent 2702777 commit bbf4cd4

16 files changed

Lines changed: 577 additions & 178 deletions

File tree

‎packages/app/e2e/regression/settings-loading.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ test("clearing project fields sends explicit removal values", async ({ page }) =
192192

193193
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
194194
const settings = page.getByTestId("settings-screen")
195-
await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
195+
await expect(settings).toBeFocused()
196196
await page.keyboard.press("Control+t")
197197

198198
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)

‎packages/app/e2e/regression/settings-padding.spec.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ test.beforeEach(async ({ page }) => {
2222
)
2323
await page.goto("/")
2424
await page.getByRole("button", { name: "Settings", exact: true }).click()
25-
await expect(
26-
page.getByTestId("settings-screen").getByRole("combobox", { name: "Search settings", exact: true }),
27-
).toBeFocused()
25+
await expect(page.getByTestId("settings-screen")).toBeFocused()
2826
})
2927

3028
for (const viewport of [

‎packages/app/e2e/regression/settings-scroll.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ for (const viewport of [
3131
const panel = settings.getByRole("tabpanel")
3232
const main = page.getByRole("main")
3333
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
34-
await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
34+
await expect(settings).toBeFocused()
3535
await page.setViewportSize(viewport)
3636
await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/)
3737
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)

‎packages/app/e2e/regression/settings-search.spec.ts‎

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ function ui(page: Page) {
3434
const settings = page.getByTestId("settings-screen")
3535
return {
3636
settings,
37-
search: settings.getByRole("combobox", { name: "Search settings", exact: true }),
37+
search: settings.getByRole("combobox", { name: "Search", exact: true }),
3838
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
39+
viewport: settings.locator(".settings-search-scroll > .scroll-view__viewport"),
3940
}
4041
}
4142

@@ -53,14 +54,14 @@ test.beforeEach(async ({ page }) => {
5354
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
5455
await page.getByRole("button", { name: "Settings", exact: true }).click()
5556
const view = ui(page)
56-
await expect(view.search).toBeFocused()
57+
await expect(view.settings).toBeFocused()
5758
// Readiness includes the server-backed project inventory, not just the settings shell.
5859
await view.search.fill("OpenCode")
5960
await expect(view.results.getByRole("option")).toHaveCount(1)
6061
await view.search.clear()
6162
})
6263

63-
test("autofocus, pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
64+
test("pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
6465
const view = ui(page)
6566
await view.search.fill("font")
6667
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
104105
await view.search.fill("work")
105106
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
106107
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
107-
await expect(pageResult).toHaveCSS("min-height", "32px")
108+
await expect(pageResult).toHaveCSS("height", "28px")
108109
await expect(pageResult.locator("svg")).toHaveCount(1)
109110
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
110111
await view.search.fill("Agent")
@@ -116,13 +117,45 @@ test("page priority, compact icons, section context, and the minimal empty state
116117
)
117118
await view.search.fill("zzzzzzzzzz")
118119
await expect(view.results.getByRole("option")).toHaveCount(0)
119-
await expect(view.settings.getByRole("status")).toHaveText("No setting found")
120+
await expect(view.settings.getByRole("status")).toHaveText('No results for "zzzzzzzzzz"')
120121
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
121122
await view.search.press("Escape")
122123
await expect(view.search).toHaveValue("")
123124
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
124125
})
125126

127+
test("empty queries keep the closing quote beside the ellipsis while typing and resizing", async ({ page }) => {
128+
const view = ui(page)
129+
const query = "zzzz 🧑🏽‍💻 ".repeat(40)
130+
await view.search.fill(query)
131+
const status = view.settings.getByRole("status")
132+
const quoted = status.locator("bdi")
133+
await expect(status).toHaveAccessibleName(`No results for "${query}"`)
134+
await expect(quoted).toHaveText(/^".+…"$/)
135+
await expect(status).toHaveCSS("height", "28px")
136+
await expect
137+
.poll(() =>
138+
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
139+
)
140+
.toBe(true)
141+
142+
const text = await quoted.textContent()
143+
await page.setViewportSize({ width: 390, height: 844 })
144+
await expect(quoted).toHaveText(/^".+…"$/)
145+
await expect(quoted).not.toHaveText(text!)
146+
await expect
147+
.poll(() =>
148+
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
149+
)
150+
.toBe(true)
151+
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
152+
153+
await view.search.fill("zzzzzzzzzz")
154+
await expect(status).toHaveText('No results for "zzzzzzzzzz"')
155+
await view.search.pressSequentially("x")
156+
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
157+
})
158+
126159
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
127160
const view = ui(page)
128161
for (const entry of [
@@ -214,8 +247,8 @@ test("qualified project results preserve query and selection on return", async (
214247
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
215248
await view.search.fill("OpenCode")
216249
const project = view.results.getByRole("option")
217-
await expect(project).toHaveText("OOpenCodeProjects")
218-
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "14px")
250+
await expect(project).toHaveText("OOpenCode")
251+
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "16px")
219252
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
220253
await view.search.fill("OpenCode name")
221254
const name = view.results.getByRole("option")
@@ -241,16 +274,29 @@ test("returning from a project restores a scrolled result list", async ({ page }
241274
const view = ui(page)
242275
await view.search.fill("OpenCode")
243276
await expect(view.results.getByRole("option")).toHaveCount(30)
277+
const scrollbar = view.settings.locator('.settings-search-scroll > .scroll-view__thumb[data-orientation="vertical"]')
278+
await view.viewport.hover()
279+
await expect(scrollbar).toHaveAttribute("data-visible", "true")
280+
const bounds = (await scrollbar.boundingBox())!
281+
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
282+
await page.mouse.down()
283+
await expect(scrollbar).toHaveAttribute("data-dragging", "true")
284+
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 80, { steps: 4 })
285+
await page.mouse.up()
286+
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
244287
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
245288
await project.scrollIntoViewIfNeeded()
246-
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
247-
const scroll = await view.results.evaluate((list) => list.scrollTop)
289+
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
290+
const scroll = await view.viewport.evaluate((list) => list.scrollTop)
248291
await project.click()
249292
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
250293
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
251294
await expect(view.search).toBeFocused()
252295
await expect(project).toHaveAttribute("aria-selected", "true")
253-
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBe(scroll)
296+
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBe(scroll)
297+
await view.search.fill("about")
298+
await expect(view.results.getByRole("option", { name: "About", exact: true })).toBeVisible()
299+
await expect(scrollbar).toHaveCount(0)
254300
})
255301

256302
test("IME confirmation does not activate a search result", async ({ page }) => {
@@ -361,6 +407,44 @@ for (const direction of ["ltr", "rtl"] as const) {
361407
colorScheme: direction === "rtl" ? "dark" : "light",
362408
contextOptions: { reducedMotion: "reduce" },
363409
})
410+
test("search input fades track typing, caret scrolling, resizing, and clearing", async ({ page }) => {
411+
const view = ui(page)
412+
await page.setViewportSize({ width: 1280, height: 900 })
413+
await page.evaluate((direction) => {
414+
document.documentElement.dir = direction
415+
}, direction)
416+
await view.search.fill(direction === "rtl" ? "غيرموجود ".repeat(30) : "zzzz ".repeat(30))
417+
await view.search.press("End")
418+
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
419+
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
420+
await expect(view.search).not.toHaveCSS("mask-image", "none")
421+
422+
await view.search.press("Home")
423+
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
424+
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
425+
await view.search.evaluate((input: HTMLInputElement, direction) => {
426+
input.scrollLeft = ((input.scrollWidth - input.clientWidth) / 2) * (direction === "rtl" ? -1 : 1)
427+
}, direction)
428+
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
429+
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
430+
431+
await view.search.fill("z".repeat(50))
432+
await expect(view.search).not.toHaveCSS("mask-image", "none")
433+
await page.setViewportSize({ width: 600, height: 844 })
434+
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
435+
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
436+
await expect(view.search).toHaveCSS("mask-image", "none")
437+
438+
await view.search.fill("zzzz ".repeat(30))
439+
await expect(view.search).not.toHaveCSS("mask-image", "none")
440+
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
441+
await expect(view.search).toHaveValue("")
442+
await expect(view.search).toBeFocused()
443+
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
444+
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
445+
await expect(view.search).toHaveCSS("mask-image", "none")
446+
})
447+
364448
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
365449
const view = ui(page)
366450
await page.evaluate((direction) => {

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -999,12 +999,17 @@ export const dict = {
999999
"settings.backToApp": "Back to app",
10001000
"settings.backToSettings": "Back to settings",
10011001
"settings.backToProjects": "Back to projects",
1002-
"settings.search.placeholder": "Search settings",
1002+
"settings.search.placeholder": "Search",
10031003
"settings.search.results": "Settings results",
1004+
"settings.search.group.pages": "Pages",
1005+
"settings.search.group.settings": "Settings",
1006+
"settings.search.group.projects": "Projects",
1007+
"settings.search.group.servers": "Servers",
10041008
"settings.search.result": "{{title}}, {{scope}}, {{page}}",
10051009
"settings.search.page": "{{title}}, {{scope}}",
10061010
"settings.search.result.unscoped": "{{title}}, {{page}}",
1007-
"settings.search.empty": "No setting found",
1011+
"settings.search.empty": "No results for {{query}}",
1012+
"settings.search.empty.query": '"{{query}}"',
10081013
"settings.search.refine": "Narrow your search to see more specific results.",
10091014
"settings.tab.general": "General",
10101015
"settings.tab.preferences": "Preferences",

‎packages/app/src/runtime/i18n/language.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
221221
params?: Record<string, string | number | boolean>,
222222
) => string
223223

224+
const rich = (key: Parameters<typeof t>[0], params: Record<string, JSX.Element>) =>
225+
t(key)
226+
.split(/(\{\{\w+\}\})/g)
227+
.map((part, index) => (index % 2 ? (params[part.slice(2, -2)] ?? part) : part))
228+
224229
const pluralForm = (
225230
key: PluralKey,
226231
category: UiPluralCategory,
@@ -262,6 +267,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
262267
locales: LOCALES,
263268
label,
264269
t,
270+
rich,
265271
plural,
266272
pluralForm,
267273
setLocale(next: Locale) {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createEffect, onMount } from "solid-js"
2+
import { createStore } from "solid-js/store"
3+
import { createResizeObserver } from "@solid-primitives/resize-observer"
4+
import { useLanguage } from "@/runtime/i18n/language"
5+
6+
export function SettingsSearchEmpty(props: { query: string }) {
7+
const language = useLanguage()
8+
const [state, setState] = createStore({ query: props.query })
9+
let quoted: HTMLSpanElement | undefined
10+
let measure: HTMLSpanElement | undefined
11+
const text = (query: string) => language.t("settings.search.empty.query", { query })
12+
const update = () => {
13+
if (!quoted || !measure) return
14+
const width = quoted.getBoundingClientRect().width
15+
const fits = (query: string) => {
16+
measure!.textContent = text(query)
17+
return measure!.getBoundingClientRect().width <= width
18+
}
19+
if (fits(props.query)) {
20+
setState("query", props.query)
21+
return
22+
}
23+
24+
const characters = Array.from(
25+
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(props.query),
26+
(item) => item.segment,
27+
)
28+
let start = 0
29+
let end = characters.length
30+
while (start < end) {
31+
const middle = Math.ceil((start + end) / 2)
32+
if (fits(`${characters.slice(0, middle).join("").trimEnd()}…`)) {
33+
start = middle
34+
continue
35+
}
36+
end = middle - 1
37+
}
38+
setState("query", `${characters.slice(0, start).join("").trimEnd()}…`)
39+
}
40+
41+
createEffect(update)
42+
onMount(() => {
43+
if (!quoted || !measure) return
44+
// The measuring text also observes font changes that do not resize the available space.
45+
createResizeObserver([quoted, measure], update)
46+
})
47+
48+
return (
49+
<>
50+
<div
51+
class="settings-search-empty"
52+
role="status"
53+
aria-label={language.t("settings.search.empty", { query: text(props.query) })}
54+
>
55+
{language.rich("settings.search.empty", {
56+
query: (
57+
<span ref={quoted} class="settings-search-empty-quoted">
58+
<bdi dir="auto">{text(state.query)}</bdi>
59+
</span>
60+
),
61+
})}
62+
</div>
63+
<span class="settings-search-empty-measure" aria-hidden="true">
64+
<span ref={measure} dir="auto" />
65+
</span>
66+
</>
67+
)
68+
}

0 commit comments

Comments
 (0)