diff --git a/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx b/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx index 6e891f92..0ad0f190 100644 --- a/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx +++ b/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx @@ -1,6 +1,10 @@ import { RouteDataPendingScreen } from "@solid-imager/ui/router-status"; import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen"; -import { ClientOnly, createFileRoute } from "@tanstack/solid-router"; +import { + ClientOnly, + createFileRoute, + useRouterState, +} from "@tanstack/solid-router"; import { type Accessor, createSignal, onMount, Show } from "solid-js"; import { MediaSidebar } from "~/components/media/media-sidebar"; import { MediaViewer } from "~/components/media/media-viewer"; @@ -21,6 +25,10 @@ interface MediaRouteParams { export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({ ssr: true, + remountDeps: ({ params }: { params: MediaRouteParams }) => [ + params.mediaSourceId, + params.mediaId, + ], loader: async ({ context, params }: RouteLoaderContext) => { await Promise.all([ context.queryClient.prefetchQuery( @@ -70,7 +78,13 @@ function MediaRouteFallback() { function MediaRouteContent() { const routeData = Route.useLoaderData(); - const currentParams = Route.useParams(); + const currentParams = useRouterState({ + select: (state) => + state.matches.find( + (match: { routeId: string; params: MediaRouteParams }) => + match.routeId === Route.id, + )?.params, + }); const mediaSourceId = () => currentParams()?.mediaSourceId ?? routeData().mediaSourceId; const mediaId = () => currentParams()?.mediaId ?? routeData().mediaId; diff --git a/apps/server/src/routes/sources/$mediaSourceId/components/source-media-page.tsx b/apps/server/src/routes/sources/$mediaSourceId/components/source-media-page.tsx index 33f8760f..563877fc 100644 --- a/apps/server/src/routes/sources/$mediaSourceId/components/source-media-page.tsx +++ b/apps/server/src/routes/sources/$mediaSourceId/components/source-media-page.tsx @@ -3,7 +3,7 @@ import { createPresetClient } from "@solid-imager/ui/preset-client"; import { sourceMediaQueryKeys } from "@solid-imager/ui/query-options"; import { RouteDataPendingScreen } from "@solid-imager/ui/router-status"; import { SourceMediaPage as SourceMediaPageComponent } from "@solid-imager/ui/source-media-page"; -import { useQueryClient } from "@tanstack/solid-query"; +import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { useParams } from "@tanstack/solid-router"; import { createSignal, onMount, Show } from "solid-js"; import { BulkActionDialog } from "~/components/media/bulk-action-dialog"; @@ -25,6 +25,7 @@ import { allCharactersQueryOptions, allIpsQueryOptions, allProjectsQueryOptions, + mediaSourcesQueryOptions, tagsQueryOptions, } from "~/infrastructure/api-clients/queries"; import { searchMedia } from "~/infrastructure/api-clients/search-api"; @@ -47,6 +48,9 @@ export function SourceMediaPage() { const mediaSourceId = () => params().mediaSourceId; const queryClient = useQueryClient(); const [isMounted, setIsMounted] = createSignal(false); + const mediaSources = createQuery(mediaSourcesQueryOptions); + const mediaSourceName = () => + mediaSources.data?.find((source) => source.id === mediaSourceId())?.name; const transport = createServerTransport(mediaSourceId); @@ -98,6 +102,7 @@ export function SourceMediaPage() { [ + params.mediaSourceId, + ], loader: async ({ context }: RouteLoaderContext) => { await Promise.all([ context.queryClient.prefetchQuery(tagsQueryOptions()), @@ -20,6 +24,7 @@ export const Route = createFileRoute("/sources/$mediaSourceId/")({ context.queryClient.prefetchQuery(allIpsQueryOptions()), context.queryClient.prefetchQuery(allCharactersQueryOptions()), context.queryClient.prefetchQuery(allAuthorsQueryOptions()), + context.queryClient.prefetchQuery(mediaSourcesQueryOptions()), ]); }, pendingComponent: SourceMediaRouteFallback, 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/apps/server/src/tests/e2e/loading-recovery.spec.ts b/apps/server/src/tests/e2e/loading-recovery.spec.ts index 7f169c7a..f61e2365 100644 --- a/apps/server/src/tests/e2e/loading-recovery.spec.ts +++ b/apps/server/src/tests/e2e/loading-recovery.spec.ts @@ -1,5 +1,7 @@ +import type { Page } from "@playwright/test"; import { E2E_PRIMARY_FILE_NAME, + E2E_SOURCE_NAME, mediaPath, sourcePath, } from "./support/fixture"; @@ -30,6 +32,29 @@ const networkFailures = [ }, ] as const; +async function getGridColumnCount( + page: Page, + selector: string, +): Promise { + return await page + .locator(selector) + .evaluate( + (element) => + window.getComputedStyle(element).gridTemplateColumns.split(" ").length, + ); +} + +async function getVisibleSkeletonItemCount(page: Page): Promise { + return await page + .locator('[data-skeleton="media-grid"] > div > [aria-hidden="true"]') + .evaluateAll( + (elements) => + elements.filter( + (element) => window.getComputedStyle(element).display !== "none", + ).length, + ); +} + test.describe("loading and recovery", () => { test("keeps the app shell visible while the initial search response is delayed", async ({ page, @@ -63,6 +88,13 @@ test.describe("loading and recovery", () => { .locator('[data-skeleton="media-grid"] [aria-hidden="true"]') .first(), ).toHaveCSS("animation-name", "none"); + const skeletonColumnCount = await getGridColumnCount( + page, + '[data-skeleton="media-grid"] > div', + ); + expect(await getVisibleSkeletonItemCount(page)).toBe( + skeletonColumnCount * 2, + ); await expect( page.getByText("APIの応答を待っています...", { exact: true }), ).toBeVisible(); @@ -72,6 +104,9 @@ test.describe("loading and recovery", () => { await expect( page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), ).toBeVisible(); + expect(await getGridColumnCount(page, "[data-media-grid]")).toBe( + skeletonColumnCount, + ); await expect(screenSkeleton).toHaveCount(0); }); @@ -148,7 +183,7 @@ test.describe("loading and recovery", () => { await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); await expect( page.getByRole("heading", { - name: /Media in Source:/, + name: E2E_SOURCE_NAME, }), ).toBeVisible(); diff --git a/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts b/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts index 47450c6d..10e88966 100644 --- a/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts +++ b/apps/server/src/tests/e2e/media-detail-manager-config.responsive.spec.ts @@ -109,6 +109,9 @@ test("media detail follows the second search result after returning to search", test("media detail, manager, and settings remain usable on narrow screens", async ({ page, }, testInfo) => { + if (testInfo.project.name === "responsive-768") { + await page.setViewportSize({ width: 940, height: 1036 }); + } await page.goto(mediaPath()); await expect( page.getByRole("heading", { name: E2E_PRIMARY_FILE_NAME, exact: true }), @@ -117,6 +120,37 @@ test("media detail, manager, and settings remain usable on narrow screens", asyn await expect( page.getByRole("img", { name: E2E_PRIMARY_FILE_NAME, exact: true }), ).toBeVisible(); + if (testInfo.project.name === "responsive-desktop") { + const verticalOverflow = await page.evaluate( + () => document.documentElement.scrollHeight - window.innerHeight, + ); + expect(verticalOverflow).toBeLessThanOrEqual(1); + const viewer = page.locator("[data-media-viewer]"); + const image = page.getByRole("img", { + name: E2E_PRIMARY_FILE_NAME, + exact: true, + }); + const viewerState = await Promise.all([ + viewer.evaluate((element) => getComputedStyle(element).backgroundColor), + viewer.evaluate((element) => element.clientHeight), + image.evaluate((element) => element.clientHeight), + ]); + expect(viewerState[0]).toBe("rgba(0, 0, 0, 0)"); + expect(viewerState[2]).toBe(viewerState[1]); + } + if (testInfo.project.name === "responsive-768") { + const viewer = page.locator("[data-media-viewer]"); + const image = page.getByRole("img", { + name: E2E_PRIMARY_FILE_NAME, + exact: true, + }); + const viewerBox = await viewer.boundingBox(); + const imageBox = await image.boundingBox(); + expect(viewerBox).not.toBeNull(); + expect(imageBox).not.toBeNull(); + expect(viewerBox?.width ?? 0).toBeGreaterThanOrEqual(900); + expect(imageBox?.width ?? 0).toBeGreaterThanOrEqual(900); + } const detailsHeading = page.getByRole("heading", { name: "Details", exact: true, diff --git a/apps/server/src/tests/e2e/route-reload.spec.ts b/apps/server/src/tests/e2e/route-reload.spec.ts index b4420b01..d6c89cc9 100644 --- a/apps/server/src/tests/e2e/route-reload.spec.ts +++ b/apps/server/src/tests/e2e/route-reload.spec.ts @@ -1,7 +1,6 @@ import type { Page } from "@playwright/test"; import { E2E_PRIMARY_FILE_NAME, - E2E_SOURCE_ID, E2E_SOURCE_NAME, mediaPath, sourcePath, @@ -140,7 +139,7 @@ const routeCases: readonly RouteCase[] = [ { name: "seeded source", path: sourcePath(), - heading: `Media in Source: ${E2E_SOURCE_ID}`, + heading: E2E_SOURCE_NAME, ssrText: "メディア一覧を準備しています...", hydratedEndpoints: sourceMediaFilterEndpoints, clientEndpoints: ["/api/rpc/media/search"], @@ -413,7 +412,7 @@ test("SPA intent prefetch and cache revisit do not duplicate route queries", asy await expect(page).toHaveURL(new RegExp(`${sourcePath()}/?$`)); await expect( page.getByRole("heading", { - name: `Media in Source: ${E2E_SOURCE_ID}`, + name: E2E_SOURCE_NAME, exact: true, }), ).toBeVisible(); 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/apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts b/apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts index 81cb6d6f..fcb2d8af 100644 --- a/apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts +++ b/apps/server/src/tests/e2e/sources-source-media.responsive.spec.ts @@ -1,7 +1,6 @@ import type { Locator, Page } from "@playwright/test"; import { E2E_PRIMARY_FILE_NAME, - E2E_SOURCE_ID, E2E_SOURCE_NAME, getFixtureMediaPath, sourcePath, @@ -117,11 +116,25 @@ test("source media exposes mobile filters and touch selection", async ({ await page.goto(sourcePath()); await expect( page.getByRole("heading", { - name: `Media in Source: ${E2E_SOURCE_ID}`, + name: E2E_SOURCE_NAME, exact: true, }), ).toBeVisible(); - await expect(page.locator("[data-media-id]").first()).toBeVisible(); + const resultCount = page.getByText(/^2 件の結果$/); + await expect(resultCount).toHaveCount(1); + await expect(resultCount).toBeVisible(); + const firstMedia = page.locator("[data-media-id]").first(); + await expect(firstMedia).toBeVisible(); + const filterCard = page + .getByRole("heading", { name: "検索フィルター", exact: true }) + .locator("..") + .locator(".."); + const [filterTop, mediaTop] = await Promise.all( + [filterCard, firstMedia].map((locator) => + locator.evaluate((element) => element.getBoundingClientRect().top), + ), + ); + expect(Math.abs(filterTop - mediaTop)).toBeLessThanOrEqual(1); await waitForAppHydration(page); await expect(page.getByTestId("media-load-more-sentinel")).toBeVisible(); await expectNoHorizontalOverflow(page); diff --git a/packages/ui/src/async-state.tsx b/packages/ui/src/async-state.tsx index 9dc69216..dd458810 100644 --- a/packages/ui/src/async-state.tsx +++ b/packages/ui/src/async-state.tsx @@ -214,51 +214,63 @@ export type QueryStatusProps = { fetchState: QueryUiFetchState; hasData: boolean; hasError?: boolean; + /** Omit the empty status region instead of reserving vertical space. */ + hideWhenIdle?: boolean; offlineLabel: string; updatingLabel: string; }; export function QueryStatus(props: QueryStatusProps) { + const hasStatus = () => + props.fetchState === "background-fetching" || + (props.fetchState === "paused" && props.hasData) || + Boolean(props.hasError && props.hasData); + return ( -
- - -

-

-
- -

-

-
- -

-

-
-
-
+ +
+ + +

+

+
+ +

+

+
+ +

+

+
+
+
+
); } 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) { ))}
  • +
    {(url) => ( -
    - + {(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/media-detail-screen.tsx b/packages/ui/src/screens/media-detail-screen.tsx index ac929ed2..92c22894 100644 --- a/packages/ui/src/screens/media-detail-screen.tsx +++ b/packages/ui/src/screens/media-detail-screen.tsx @@ -86,7 +86,7 @@ export function MediaDetailScreen(props: MediaDetailScreenProps) { }); return ( -
    +
    {(details) => ( -
    -
    +
    +
    {props.renderMediaViewer(details, props.sourceRootPath)}
    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) {
    -