From 472bc3e91b896d95a7a30eda17687213e0dd48bb Mon Sep 17 00:00:00 2001 From: hmjn Date: Wed, 29 Jul 2026 00:46:20 +0900 Subject: [PATCH 1/4] fix(ui): improve mobile navigation contrast --- .../src/tests/e2e/app-nav.responsive.spec.ts | 54 +++++++++++++++++++ packages/ui/src/layouts/app-nav.tsx | 16 ++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/server/src/tests/e2e/app-nav.responsive.spec.ts b/apps/server/src/tests/e2e/app-nav.responsive.spec.ts index ea4c99c1..43eee919 100644 --- a/apps/server/src/tests/e2e/app-nav.responsive.spec.ts +++ b/apps/server/src/tests/e2e/app-nav.responsive.spec.ts @@ -10,6 +10,55 @@ async function expectNoHorizontalOverflow(page: Page): Promise { expect(overflow).toBeLessThanOrEqual(1); } +async function expectTextContrast( + page: Page, + selector: string, + backgroundSelector: string, +): Promise { + const contrast = await page.evaluate( + ({ selector, backgroundSelector }) => { + const element = document.querySelector(selector); + const background = document.querySelector(backgroundSelector); + if ( + !(element instanceof HTMLElement) || + !(background instanceof HTMLElement) + ) { + throw new Error("Navigation link or drawer was not found"); + } + + const parseRgb = (color: string): number[] => + color + .match(/\d+(?:\.\d+)?/g) + ?.slice(0, 3) + .map(Number) ?? []; + const relativeLuminance = ([red, green, blue]: number[]): number => { + const [linearRed, linearGreen, linearBlue] = [red, green, blue].map( + (channel) => { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }, + ); + return 0.2126 * linearRed + 0.7152 * linearGreen + 0.0722 * linearBlue; + }; + + const foreground = relativeLuminance( + parseRgb(window.getComputedStyle(element).color), + ); + const drawerBackground = relativeLuminance( + parseRgb(window.getComputedStyle(background).backgroundColor), + ); + return ( + (Math.max(foreground, drawerBackground) + 0.05) / + (Math.min(foreground, drawerBackground) + 0.05) + ); + }, + { selector, backgroundSelector }, + ); + expect(contrast).toBeGreaterThanOrEqual(4.5); +} + test("app navigation is responsive and accessible", async ({ page, }, testInfo) => { @@ -36,6 +85,11 @@ test("app navigation is responsive and accessible", async ({ await expect( dialog.getByRole("link", { name: "About", exact: true }), ).toHaveAttribute("aria-current", "page"); + await expectTextContrast( + page, + '[role="dialog"] a[href="/search"]', + '[role="dialog"]', + ); await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); diff --git a/packages/ui/src/layouts/app-nav.tsx b/packages/ui/src/layouts/app-nav.tsx index 342c73e5..6db74c27 100644 --- a/packages/ui/src/layouts/app-nav.tsx +++ b/packages/ui/src/layouts/app-nav.tsx @@ -30,12 +30,18 @@ export function AppNav(props: AppNavProps) { const [isMenuOpen, setIsMenuOpen] = createSignal(false); const [isClient, setIsClient] = createSignal(false); const isActive = (path: string) => path === location().pathname; - const linkClass = (path: string) => + const desktopLinkClass = (path: string) => `rounded-md px-3 py-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-sky-800 ${ isActive(path) ? "bg-sky-700 text-white" : "text-sky-50 hover:bg-sky-700 hover:text-white" }`; + const mobileLinkClass = (path: string) => + `rounded-md px-3 py-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${ + isActive(path) + ? "bg-accent text-accent-foreground" + : "text-foreground hover:bg-accent hover:text-accent-foreground" + }`; const [isVisible, setIsVisible] = createSignal(true); const [lastScrollY, setLastScrollY] = createSignal(0); @@ -104,7 +110,7 @@ export function AppNav(props: AppNavProps) {
  • {item.label} @@ -113,7 +119,7 @@ export function AppNav(props: AppNavProps) { ))}
  • setIsMenuOpen(false)} to={item.to} > @@ -189,7 +195,7 @@ export function AppNav(props: AppNavProps) { ))}
  • Date: Wed, 29 Jul 2026 00:46:36 +0900 Subject: [PATCH 2/4] fix(search): preserve advanced filter focus and scrolling --- .../e2e/search-pro-dialog.responsive.spec.ts | 22 +++++++++++ .../src/tests/e2e/search.responsive.spec.ts | 39 +++++++++++++++++++ packages/ui/src/pro-search-builder.tsx | 36 ++++++++++++----- packages/ui/src/screens/search-screen.tsx | 2 +- 4 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/tests/e2e/search-pro-dialog.responsive.spec.ts diff --git a/apps/server/src/tests/e2e/search-pro-dialog.responsive.spec.ts b/apps/server/src/tests/e2e/search-pro-dialog.responsive.spec.ts new file mode 100644 index 00000000..21cb79c8 --- /dev/null +++ b/apps/server/src/tests/e2e/search-pro-dialog.responsive.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "./support/test"; + +test("pro search dialog keeps the value input focused while typing", async ({ + page, +}, testInfo) => { + test.skip( + testInfo.project.name !== "responsive-desktop", + "The pro-search editor is shown in the desktop filter panel.", + ); + + await page.goto("/search"); + await page.getByRole("button", { name: "詳細", exact: true }).click(); + await page.getByRole("button", { name: "詳細条件を編集" }).click(); + + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "+ 条件" }).click(); + + const valueInput = dialog.getByPlaceholder("値..."); + await valueInput.pressSequentially("focus"); + await expect(valueInput).toHaveValue("focus"); + await expect(valueInput).toBeFocused(); +}); diff --git a/apps/server/src/tests/e2e/search.responsive.spec.ts b/apps/server/src/tests/e2e/search.responsive.spec.ts index 76ffb2cb..cdfb7da1 100644 --- a/apps/server/src/tests/e2e/search.responsive.spec.ts +++ b/apps/server/src/tests/e2e/search.responsive.spec.ts @@ -91,6 +91,45 @@ test("search keeps controls usable without horizontal overflow", async ({ page.getByRole("heading", { name: "検索フィルター", exact: true }), ).toBeVisible(); await expect(page.getByPlaceholder("ファイル名を入力...")).toBeVisible(); + + if (testInfo.project.name === "responsive-768") { + await page.setViewportSize({ width: 768, height: 480 }); + const filterCard = page + .getByRole("heading", { name: "検索フィルター", exact: true }) + .locator("..") + .locator(".."); + const scrollState = await filterCard.evaluate((element) => { + element.scrollTop = element.scrollHeight; + const bounds = element.getBoundingClientRect(); + return { + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + isWithinViewport: + bounds.top >= 0 && bounds.bottom <= window.innerHeight, + }; + }); + expect(scrollState.scrollHeight).toBeGreaterThan( + scrollState.clientHeight, + ); + expect(scrollState.scrollTop).toBeGreaterThan(0); + expect(scrollState.isWithinViewport).toBe(true); + + const lastFilter = filterCard.getByPlaceholder("プロジェクトを検索..."); + await expect(lastFilter).toBeVisible(); + expect( + await lastFilter.evaluate((element) => { + const card = element.closest(".sticky"); + if (!(card instanceof HTMLElement)) return false; + const inputBounds = element.getBoundingClientRect(); + const cardBounds = card.getBoundingClientRect(); + return ( + inputBounds.top >= cardBounds.top && + inputBounds.bottom <= cardBounds.bottom + ); + }), + ).toBe(true); + } } await expectNoHorizontalOverflow(page); diff --git a/packages/ui/src/pro-search-builder.tsx b/packages/ui/src/pro-search-builder.tsx index 5fc7bd92..e5239077 100644 --- a/packages/ui/src/pro-search-builder.tsx +++ b/packages/ui/src/pro-search-builder.tsx @@ -7,7 +7,7 @@ import type { } from "@solid-imager/core/domain/media/schemas"; import type { Project } from "@solid-imager/core/domain/projects/schemas"; import type { TagResponse } from "@solid-imager/core/domain/tags/schemas"; -import { createMemo, For, Match, Show, Switch } from "solid-js"; +import { createMemo, Index, Match, Show, Switch } from "solid-js"; import { Button } from "./button"; import { Card, CardContent } from "./card"; import { @@ -145,6 +145,22 @@ function getValidOperators(target: string) { return Object.keys(OPERATOR_LABELS); } +function getSearchGroup(child: SearchCriterion | SearchGroup): SearchGroup { + if (child.type === "group") { + return child; + } + throw new Error("Expected a search group"); +} + +function getSearchCriterion( + child: SearchCriterion | SearchGroup, +): SearchCriterion { + if (child.type === "criterion") { + return child; + } + throw new Error("Expected a search criterion"); +} + function GroupBuilder(props: { group: SearchGroup; onChange: (value: SearchGroup) => void; @@ -258,17 +274,17 @@ function GroupBuilder(props: {
    - + {(child, index) => - child.type === "group" ? ( + child().type === "group" ? ( updateChild(index(), value)} - onRemove={() => removeChild(index())} + onChange={(value) => updateChild(index, value)} + onRemove={() => removeChild(index)} projects={props.projects} tags={props.tags} /> @@ -276,16 +292,16 @@ function GroupBuilder(props: { updateChild(index(), value)} - onRemove={() => removeChild(index())} + onChange={(value) => updateChild(index, value)} + onRemove={() => removeChild(index)} projects={props.projects} tags={props.tags} /> ) } - +
    条件がありません。「+ 条件」ボタンで追加してください。 diff --git a/packages/ui/src/screens/search-screen.tsx b/packages/ui/src/screens/search-screen.tsx index 7f05178f..c0e683fa 100644 --- a/packages/ui/src/screens/search-screen.tsx +++ b/packages/ui/src/screens/search-screen.tsx @@ -94,7 +94,7 @@ export function SearchScreen(props: SearchScreenProps) {
    -