From 5d73e1d9238826cd8a9727c0718679ffc44d7691 Mon Sep 17 00:00:00 2001 From: Sam Dornan Date: Mon, 5 Oct 2026 15:04:46 -0400 Subject: [PATCH] refactor(v2): extract RSortHeader and make the index lists real tables (#5149) --- .../Collections/CollectionListHeader.vue | 114 ++---------- .../Collections/CollectionListRow.test.ts | 75 ++++++++ .../Collections/CollectionListRow.vue | 48 ++++- .../v2/components/Gallery/GameListHeader.vue | 176 ++++-------------- .../Platforms/PlatformListHeader.vue | 119 ++---------- .../Platforms/PlatformListRow.test.ts | 91 +++++++++ .../components/Platforms/PlatformListRow.vue | 53 ++++-- .../Platforms/platformListColumns.ts | 7 - .../v2/components/shared/IndexShell.test.ts | 51 +++++ .../src/v2/components/shared/IndexShell.vue | 27 ++- .../src/v2/components/shared/ListSortMenu.vue | 12 +- .../data/RSortHeader/RSortHeader.stories.ts | 154 +++++++++++++++ .../v2/lib/data/RSortHeader/RSortHeader.vue | 156 ++++++++++++++++ frontend/src/v2/lib/data/RSortHeader/index.ts | 3 + .../lib/data/RSortHeader/nextSortDir.test.ts | 14 ++ .../v2/lib/data/RSortHeader/nextSortDir.ts | 6 + frontend/src/v2/lib/data/RSortHeader/types.ts | 14 ++ .../src/v2/lib/data/RTable/RTable.stories.ts | 76 +++++++- frontend/src/v2/lib/data/RTable/RTable.vue | 141 ++------------ frontend/src/v2/lib/data/RTable/index.ts | 7 +- frontend/src/v2/lib/data/RTable/types.ts | 8 +- frontend/src/v2/lib/index.ts | 1 + .../src/v2/views/CollectionsIndex.test.ts | 2 +- frontend/src/v2/views/CollectionsIndex.vue | 49 +++-- frontend/src/v2/views/PlatformsIndex.test.ts | 2 +- frontend/src/v2/views/PlatformsIndex.vue | 47 +++-- 26 files changed, 874 insertions(+), 579 deletions(-) create mode 100644 frontend/src/v2/components/Collections/CollectionListRow.test.ts create mode 100644 frontend/src/v2/components/Platforms/PlatformListRow.test.ts create mode 100644 frontend/src/v2/components/shared/IndexShell.test.ts create mode 100644 frontend/src/v2/lib/data/RSortHeader/RSortHeader.stories.ts create mode 100644 frontend/src/v2/lib/data/RSortHeader/RSortHeader.vue create mode 100644 frontend/src/v2/lib/data/RSortHeader/index.ts create mode 100644 frontend/src/v2/lib/data/RSortHeader/nextSortDir.test.ts create mode 100644 frontend/src/v2/lib/data/RSortHeader/nextSortDir.ts create mode 100644 frontend/src/v2/lib/data/RSortHeader/types.ts diff --git a/frontend/src/v2/components/Collections/CollectionListHeader.vue b/frontend/src/v2/components/Collections/CollectionListHeader.vue index 1c91faeb9..ffc0f9466 100644 --- a/frontend/src/v2/components/Collections/CollectionListHeader.vue +++ b/frontend/src/v2/components/Collections/CollectionListHeader.vue @@ -3,7 +3,7 @@ // list-mode view. Mirrors GameListHeader's anatomy: each sortable // column is a button that toggles asc → desc → asc on the parent's // sort state via the `sort` event. -import { RIcon } from "@v2/lib"; +import { RSortHeader, type RSortDir } from "@v2/lib"; import { computed } from "vue"; import { useI18n } from "vue-i18n"; import ListSortMenu from "@/v2/components/shared/ListSortMenu.vue"; @@ -11,7 +11,6 @@ import { useBreakpoint } from "@/v2/composables/useBreakpoint"; import { COLLECTION_LIST_COLUMNS, COLLECTION_LIST_GRID_TEMPLATE, - type CollectionListColumn, type CollectionListSortKey, } from "./collectionListColumns"; @@ -19,16 +18,13 @@ interface Props { /** Currently-sorted column key. */ sortKey: CollectionListSortKey; /** Sort direction for the active key. */ - sortDir: "asc" | "desc"; + sortDir: RSortDir; } -const props = defineProps(); +defineProps(); const emit = defineEmits<{ - ( - e: "sort", - payload: { key: CollectionListSortKey; dir: "asc" | "desc" }, - ): void; + (e: "sort", payload: { key: CollectionListSortKey; dir: RSortDir }): void; }>(); const { t } = useI18n(); @@ -42,23 +38,10 @@ const sortOptions = computed(() => col.sortKey ? [{ key: col.sortKey, label: t(col.labelKey) }] : [], ), ); - -function handleClick(col: CollectionListColumn) { - if (!col.sortKey) return; - // Toggle direction when re-clicking the active column; otherwise - // start the new column at ascending: same rule as GameListHeader. - const nextDir: "asc" | "desc" = - props.sortKey === col.sortKey && props.sortDir === "asc" ? "desc" : "asc"; - emit("sort", { key: col.sortKey, dir: nextDir }); -} @@ -118,55 +81,4 @@ function handleClick(col: CollectionListColumn) { align-items: center; padding: 0 var(--r-row-pad); } - -.coll-list-header__cell { - appearance: none; - background: transparent; - border: 0; - padding: 0; - font: inherit; - color: var(--r-color-fg-secondary); - font-size: var(--r-font-size-xs); - font-weight: var(--r-font-weight-bold); - letter-spacing: 0.07em; - text-transform: uppercase; - display: inline-flex; - align-items: center; - gap: var(--r-space-1); - min-width: 0; - height: 100%; - cursor: default; - text-align: start; - border-radius: var(--r-radius-sm); - transition: color var(--r-motion-fast) var(--r-motion-ease-out); -} - -.coll-list-header__cell--end { - justify-content: flex-end; - text-align: end; -} - -.coll-list-header__cell--sortable { - cursor: pointer; -} - -.coll-list-header__cell--sortable:hover, -.coll-list-header__cell--sortable:focus-visible { - color: var(--r-color-fg); -} - -.coll-list-header__cell--active { - color: var(--r-color-fg); -} - -.coll-list-header__label { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.coll-list-header__icon { - flex-shrink: 0; - color: var(--r-color-brand-primary); -} diff --git a/frontend/src/v2/components/Collections/CollectionListRow.test.ts b/frontend/src/v2/components/Collections/CollectionListRow.test.ts new file mode 100644 index 000000000..3ea560de2 --- /dev/null +++ b/frontend/src/v2/components/Collections/CollectionListRow.test.ts @@ -0,0 +1,75 @@ +import { mount } from "@vue/test-utils"; +import axe from "axe-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, h, ref } from "vue"; +import IndexShell from "@/v2/components/shared/IndexShell.vue"; +import CollectionListHeader from "./CollectionListHeader.vue"; +import CollectionListRow from "./CollectionListRow.vue"; + +vi.mock("vue-i18n"); + +vi.mock("vue-router", async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/v2/components/Collections/CollectionMosaic.vue", () => ({ + default: defineComponent({ template: "" }), +})); + +const smAndDown = ref(false); +vi.mock("@/v2/composables/useBreakpoint", () => ({ + useBreakpoint: () => ({ smAndDown }), +})); + +function mountList() { + return mount(IndexShell, { + props: { listMode: true, listLabel: "Collections" }, + slots: { + listHeader: () => + h(CollectionListHeader, { sortKey: "name", sortDir: "asc" }), + listRows: () => [ + h(CollectionListRow, { id: 1, name: "RPGs", to: "/collection/1" }), + h(CollectionListRow, { + id: 2, + name: "Co-op", + to: "/collection/2", + kind: "smart", + }), + ], + }, + attachTo: document.body, + }); +} + +describe("CollectionListRow", () => { + beforeEach(() => { + smAndDown.value = false; + }); + + it("forms a valid table with the list header", async () => { + const wrapper = mountList(); + try { + const results = await axe.run(wrapper.element, { + rules: { "color-contrast": { enabled: false } }, + }); + expect(results.violations.map((v) => v.id)).toEqual([]); + } finally { + wrapper.unmount(); + } + }); + + it("is a row of cells with one link to the collection", () => { + const row = mount(CollectionListRow, { + props: { id: 1, name: "RPGs", to: "/collection/1" }, + }); + + expect(row.attributes("role")).toBe("row"); + expect(row.findAll(":scope > [role=cell]")).toHaveLength( + row.element.children.length, + ); + expect(row.findAll("a").map((a) => a.attributes("href"))).toEqual([ + "/collection/1", + ]); + }); +}); diff --git a/frontend/src/v2/components/Collections/CollectionListRow.vue b/frontend/src/v2/components/Collections/CollectionListRow.vue index a1b315f90..84c3a4154 100644 --- a/frontend/src/v2/components/Collections/CollectionListRow.vue +++ b/frontend/src/v2/components/Collections/CollectionListRow.vue @@ -119,28 +119,33 @@ function onRowClick(e: MouseEvent) { - -
+ -
+
diff --git a/frontend/src/v2/components/Platforms/PlatformListHeader.vue b/frontend/src/v2/components/Platforms/PlatformListHeader.vue index f3bd96259..8a65b93da 100644 --- a/frontend/src/v2/components/Platforms/PlatformListHeader.vue +++ b/frontend/src/v2/components/Platforms/PlatformListHeader.vue @@ -5,25 +5,21 @@ // // Phones and tablets have no columns to head (the rows go compact), so the // sort key they carried moves into a menu, same as the collections list. -import { RIcon } from "@v2/lib"; +import { RSortHeader, type RSortDir } from "@v2/lib"; import { computed } from "vue"; import ListSortMenu from "@/v2/components/shared/ListSortMenu.vue"; import { useBreakpoint } from "@/v2/composables/useBreakpoint"; -import { - PLATFORM_COLUMNS, - type PlatformColumn, - type PlatformSortKey, -} from "./platformListColumns"; +import { PLATFORM_COLUMNS, type PlatformSortKey } from "./platformListColumns"; interface Props { sortKey: PlatformSortKey; - sortDir: "asc" | "desc"; + sortDir: RSortDir; } -const props = defineProps(); +defineProps(); const emit = defineEmits<{ - (e: "sort", payload: { key: PlatformSortKey; dir: "asc" | "desc" }): void; + (e: "sort", payload: { key: PlatformSortKey; dir: RSortDir }): void; }>(); const { smAndDown } = useBreakpoint(); @@ -33,21 +29,10 @@ const sortOptions = computed(() => label: col.label, })), ); - -function handleClick(col: PlatformColumn) { - if (!col.sortable) return; - const nextDir: "asc" | "desc" = - props.sortKey === col.key && props.sortDir === "asc" ? "desc" : "asc"; - emit("sort", { key: col.key, dir: nextDir }); -} @@ -109,59 +73,4 @@ function handleClick(col: PlatformColumn) { align-items: center; padding: 0 var(--r-row-pad); } - -.plat-list-header__cell { - appearance: none; - background: transparent; - border: 0; - padding: 0; - font: inherit; - color: var(--r-color-fg-secondary); - font-size: var(--r-font-size-xs); - font-weight: var(--r-font-weight-bold); - letter-spacing: 0.07em; - text-transform: uppercase; - display: inline-flex; - align-items: center; - gap: var(--r-space-1); - min-width: 0; - height: 100%; - cursor: default; - text-align: start; - border-radius: var(--r-radius-sm); - transition: color var(--r-motion-fast) var(--r-motion-ease-out); -} - -.plat-list-header__cell--end { - justify-content: flex-end; - text-align: end; -} -.plat-list-header__cell--center { - justify-content: center; - text-align: center; -} - -.plat-list-header__cell--sortable { - cursor: pointer; -} - -.plat-list-header__cell--sortable:hover, -.plat-list-header__cell--sortable:focus-visible { - color: var(--r-color-fg); -} - -.plat-list-header__cell--active { - color: var(--r-color-fg); -} - -.plat-list-header__label { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.plat-list-header__icon { - flex-shrink: 0; - color: var(--r-color-brand-primary); -} diff --git a/frontend/src/v2/components/Platforms/PlatformListRow.test.ts b/frontend/src/v2/components/Platforms/PlatformListRow.test.ts new file mode 100644 index 000000000..0bbfbd0a3 --- /dev/null +++ b/frontend/src/v2/components/Platforms/PlatformListRow.test.ts @@ -0,0 +1,91 @@ +import { mount } from "@vue/test-utils"; +import axe from "axe-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent, h, ref } from "vue"; +import IndexShell from "@/v2/components/shared/IndexShell.vue"; +import PlatformListHeader from "./PlatformListHeader.vue"; +import PlatformListRow from "./PlatformListRow.vue"; + +vi.mock("vue-i18n"); + +vi.mock("vue-router", async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/v2/components/shared/PlatformIcon.vue", () => ({ + default: defineComponent({ template: "" }), +})); + +vi.mock("@/v2/composables/usePlatformPlayable", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/v2/composables/usePlatformPlayable") + >()), + usePlatformPlayable: () => ({ + emulator: ref(null), + mode: ref(null), + streamLabel: ref(null), + }), +})); + +const smAndDown = ref(false); +vi.mock("@/v2/composables/useBreakpoint", () => ({ + useBreakpoint: () => ({ smAndDown }), +})); + +function mountList() { + return mount(IndexShell, { + props: { listMode: true, listLabel: "Platforms" }, + slots: { + listHeader: () => + h(PlatformListHeader, { sortKey: "name", sortDir: "asc" }), + listRows: () => [ + h(PlatformListRow, { id: 1, slug: "snes", displayName: "SNES" }), + h(PlatformListRow, { id: 2, slug: "n64", displayName: "N64" }), + ], + }, + attachTo: document.body, + }); +} + +describe("PlatformListRow", () => { + beforeEach(() => { + smAndDown.value = false; + }); + + it("forms a valid table with the list header", async () => { + const wrapper = mountList(); + try { + const results = await axe.run(wrapper.element, { + rules: { "color-contrast": { enabled: false } }, + }); + expect(results.violations.map((v) => v.id)).toEqual([]); + } finally { + wrapper.unmount(); + } + }); + + it("is a row of cells with one link to the platform", () => { + const row = mount(PlatformListRow, { + props: { id: 1, slug: "snes", displayName: "SNES" }, + }); + + expect(row.attributes("role")).toBe("row"); + expect(row.findAll(":scope > [role=cell]")).toHaveLength( + row.element.children.length, + ); + expect(row.findAll("a").map((a) => a.attributes("href"))).toEqual([ + "/platform/1", + ]); + }); + + it("stays a single link on phones", () => { + smAndDown.value = true; + const row = mount(PlatformListRow, { + props: { id: 1, slug: "snes", displayName: "SNES" }, + }); + + expect(row.element.tagName).toBe("A"); + expect(row.attributes("role")).toBeUndefined(); + }); +}); diff --git a/frontend/src/v2/components/Platforms/PlatformListRow.vue b/frontend/src/v2/components/Platforms/PlatformListRow.vue index 09b72ff34..5bc328392 100644 --- a/frontend/src/v2/components/Platforms/PlatformListRow.vue +++ b/frontend/src/v2/components/Platforms/PlatformListRow.vue @@ -140,18 +140,16 @@ function onRowClick(e: MouseEvent) { /> - -
+ -
+
{{ familyName }} —
-
+
{{ categoryLabel }} —
-
+
{{ generationLabel }} —
-
+
{{ t("collection.games-count", romCount, { named: { n: romCount } }) }} —
- +
diff --git a/frontend/src/v2/lib/data/RSortHeader/index.ts b/frontend/src/v2/lib/data/RSortHeader/index.ts new file mode 100644 index 000000000..39807dc4c --- /dev/null +++ b/frontend/src/v2/lib/data/RSortHeader/index.ts @@ -0,0 +1,3 @@ +export { default as RSortHeader } from "./RSortHeader.vue"; +export { nextSortDir } from "./nextSortDir"; +export type { RSortDir, RSortHeaderProps } from "./types"; diff --git a/frontend/src/v2/lib/data/RSortHeader/nextSortDir.test.ts b/frontend/src/v2/lib/data/RSortHeader/nextSortDir.test.ts new file mode 100644 index 000000000..23c19dfd6 --- /dev/null +++ b/frontend/src/v2/lib/data/RSortHeader/nextSortDir.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { nextSortDir } from "./nextSortDir"; + +describe("nextSortDir", () => { + it("flips the active column", () => { + expect(nextSortDir(true, "asc")).toBe("desc"); + expect(nextSortDir(true, "desc")).toBe("asc"); + }); + + it("starts any other column ascending", () => { + expect(nextSortDir(false, "asc")).toBe("asc"); + expect(nextSortDir(false, "desc")).toBe("asc"); + }); +}); diff --git a/frontend/src/v2/lib/data/RSortHeader/nextSortDir.ts b/frontend/src/v2/lib/data/RSortHeader/nextSortDir.ts new file mode 100644 index 000000000..88cdca90a --- /dev/null +++ b/frontend/src/v2/lib/data/RSortHeader/nextSortDir.ts @@ -0,0 +1,6 @@ +import type { RSortDir } from "./types"; + +/** Re-picking the active column flips it; any other column starts ascending. */ +export function nextSortDir(active: boolean, dir: RSortDir): RSortDir { + return active && dir === "asc" ? "desc" : "asc"; +} diff --git a/frontend/src/v2/lib/data/RSortHeader/types.ts b/frontend/src/v2/lib/data/RSortHeader/types.ts new file mode 100644 index 000000000..981c15084 --- /dev/null +++ b/frontend/src/v2/lib/data/RSortHeader/types.ts @@ -0,0 +1,14 @@ +export type RSortDir = "asc" | "desc"; + +export interface RSortHeaderProps { + label: string; + /** Renders the label as a sort button. */ + sortable?: boolean | undefined; + /** Whether this column is the one the list is sorted by. */ + active?: boolean | undefined; + /** Direction of the active sort. */ + dir?: RSortDir | undefined; + align?: "start" | "end" | "center" | undefined; + /** Keeps the label for screen readers only, for icon or action columns. */ + hideLabel?: boolean | undefined; +} diff --git a/frontend/src/v2/lib/data/RTable/RTable.stories.ts b/frontend/src/v2/lib/data/RTable/RTable.stories.ts index ac4e9636f..079c6ffa6 100644 --- a/frontend/src/v2/lib/data/RTable/RTable.stories.ts +++ b/frontend/src/v2/lib/data/RTable/RTable.stories.ts @@ -1,6 +1,9 @@ import type { Meta, StoryObj } from "@storybook/vue3-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { ref } from "vue"; +import type { RSortDir } from "@/v2/lib/data/RSortHeader/types"; import RTable from "./RTable.vue"; -import type { RTableColumn } from "./types"; +import type { RTableColumn, RTableSortPayload } from "./types"; // Cast through `Meta` because RTable is a generic component (``): // Vue's compiled type narrows T to `unknown` here, which Storybook's @@ -174,3 +177,74 @@ export const MobileCardStack: Story = { `, }), }; + +const onRowClick = fn(); + +// Sort state lives with the consumer, so the story wires `update:sort` back +// into the props the way a real call site does. +export const SortAndRowActivation: Story = { + name: "Sort and row activation (play)", + args: { + columns: COLUMNS, + items: ITEMS, + itemKey: "id", + clickableRows: true, + }, + render: (args) => ({ + components: { RTable }, + setup: () => { + const sortKey = ref("name"); + const sortDir = ref("asc"); + function onSort(payload: RTableSortPayload) { + sortKey.value = payload.key; + sortDir.value = payload.dir; + } + return { args, sortKey, sortDir, onSort, onRowClick }; + }, + template: ` +
+ +
+ `, + }), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const header = (name: string) => canvas.getByRole("columnheader", { name }); + + // Toggle rules and aria-sort belong to RSortHeader; this checks the + // header's event reaches `update:sort` with its column's key. + await step("clicking a header sorts by that column", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Size" })); + await expect(header("Size")).toHaveAttribute("aria-sort", "ascending"); + await expect(header("Title")).toHaveAttribute("aria-sort", "none"); + }); + + const row = canvas.getByText("Chrono Trigger").closest('[role="row"]'); + if (!(row instanceof HTMLElement)) throw new Error("expected a row"); + + await step("clicking a row emits it", async () => { + await userEvent.click(row); + await expect(onRowClick).toHaveBeenCalledTimes(1); + await expect(onRowClick).toHaveBeenLastCalledWith(ITEMS[2]); + }); + + await step("Enter activates the focused row", async () => { + row.focus(); + await userEvent.keyboard("{Enter}"); + await expect(onRowClick).toHaveBeenCalledTimes(2); + await expect(onRowClick).toHaveBeenLastCalledWith(ITEMS[2]); + }); + + await step("Space activates the focused row", async () => { + await userEvent.keyboard(" "); + await expect(onRowClick).toHaveBeenCalledTimes(3); + await expect(onRowClick).toHaveBeenLastCalledWith(ITEMS[2]); + }); + }, +}; diff --git a/frontend/src/v2/lib/data/RTable/RTable.vue b/frontend/src/v2/lib/data/RTable/RTable.vue index ce78c9b68..5e64ed0e5 100644 --- a/frontend/src/v2/lib/data/RTable/RTable.vue +++ b/frontend/src/v2/lib/data/RTable/RTable.vue @@ -29,15 +29,10 @@ import { useTimeoutFn } from "@vueuse/core"; import { computed, ref, watch } from "vue"; import { useLoadingPhase } from "@/v2/composables/useLoadingPhase"; -import REmptyState from "../../primitives/REmptyState/REmptyState.vue"; -import RIcon from "../../primitives/RIcon/RIcon.vue"; -import RSkeletonBlock from "../../primitives/RSkeletonBlock/RSkeletonBlock.vue"; -import type { - RTableColumn, - RTableProps, - RTableSortDir, - RTableSortPayload, -} from "./types"; +import RSortHeader from "@/v2/lib/data/RSortHeader/RSortHeader.vue"; +import REmptyState from "@/v2/lib/primitives/REmptyState/REmptyState.vue"; +import RSkeletonBlock from "@/v2/lib/primitives/RSkeletonBlock/RSkeletonBlock.vue"; +import type { RTableProps, RTableSortPayload } from "./types"; defineOptions({ inheritAttrs: false }); @@ -111,15 +106,6 @@ function cellValue(row: T, key: string): unknown { return (row as Record)[key] ?? ""; } -function handleSort(col: RTableColumn) { - if (!col.sortable) return; - // Toggle direction when re-clicking the active column; new column - // starts at ascending (mirrors VDataTable behaviour). - const nextDir: RTableSortDir = - props.sortKey === col.key && props.sortDir === "asc" ? "desc" : "asc"; - emit("update:sort", { key: col.key, dir: nextDir }); -} - const rowStyle = computed(() => props.rowHeight ? { height: props.rowHeight } : undefined, ); @@ -149,58 +135,27 @@ watch( role="table" v-bind="$attrs" > -