mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user