refactor(v2): extract RSortHeader and make the index lists real tables (#5149)

This commit is contained in:
Sam Dornan
2026-10-05 15:04:46 -04:00
committed by GitHub
parent 9ae2cd0e12
commit 5d73e1d923
26 changed files with 874 additions and 579 deletions
@@ -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<Props>();
defineProps<Props>();
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 });
}
</script>
<template>
<div
v-if="smAndDown"
class="coll-list-header coll-list-header--compact"
role="row"
>
<div v-if="smAndDown" class="coll-list-header coll-list-header--compact">
<ListSortMenu
:options="sortOptions"
:sort-key="sortKey"
@@ -68,36 +51,16 @@ function handleClick(col: CollectionListColumn) {
</div>
<div v-else class="coll-list-header" :style="gridStyle" role="row">
<button
<RSortHeader
v-for="col in COLLECTION_LIST_COLUMNS"
:key="col.key"
type="button"
class="coll-list-header__cell"
:class="{
'coll-list-header__cell--sortable': !!col.sortKey,
'coll-list-header__cell--end': col.align === 'end',
'coll-list-header__cell--active':
col.sortKey && sortKey === col.sortKey,
}"
:aria-sort="
col.sortKey && sortKey === col.sortKey
? sortDir === 'asc'
? 'ascending'
: 'descending'
: 'none'
"
:tabindex="col.sortKey ? 0 : -1"
:disabled="!col.sortKey"
@click="handleClick(col)"
>
<span class="coll-list-header__label">{{ t(col.labelKey) }}</span>
<RIcon
v-if="col.sortKey && sortKey === col.sortKey"
:icon="sortDir === 'asc' ? 'mdi-arrow-up-thin' : 'mdi-arrow-down-thin'"
size="14"
class="coll-list-header__icon"
/>
</button>
:label="t(col.labelKey)"
:sortable="!!col.sortKey"
:active="!!col.sortKey && sortKey === col.sortKey"
:dir="sortDir"
:align="col.align"
@sort="col.sortKey && emit('sort', { key: col.sortKey, dir: $event })"
/>
</div>
</template>
@@ -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);
}
</style>
@@ -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<typeof import("vue-router")>()),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/v2/components/Collections/CollectionMosaic.vue", () => ({
default: defineComponent({ template: "<i />" }),
}));
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",
]);
});
});
@@ -119,28 +119,33 @@ function onRowClick(e: MouseEvent) {
</div>
</a>
<a
<div
v-else
ref="rowEl"
role="row"
class="coll-list-row coll-list-row--columns"
:class="entranceClass"
:style="[gridStyle, entranceStyle]"
:href="to"
:aria-label="t('rom.open-game', { name })"
@click="onRowClick"
@animationend.self="endEntrance"
>
<div class="coll-list-row__cell coll-list-row__title">
<div role="cell" class="coll-list-row__cell coll-list-row__title">
<div ref="coverEl" class="coll-list-row__thumb" :style="morphStyle">
<CollectionMosaic :covers="covers" />
<PublicBadge v-if="isPublic" class="coll-list-row__public" />
</div>
<div class="coll-list-row__meta">
<div class="coll-list-row__name">{{ name }}</div>
<a
class="coll-list-row__name coll-list-row__link"
:href="to"
:aria-label="t('rom.open-game', { name })"
@click="onRowClick"
>
{{ name }}
</a>
</div>
</div>
<div class="coll-list-row__cell">
<div role="cell" class="coll-list-row__cell">
<RTooltip :text="kindBadge.label" location="top">
<template #activator="{ props: tip }">
<span
@@ -159,10 +164,10 @@ function onRowClick(e: MouseEvent) {
</RTooltip>
</div>
<div class="coll-list-row__cell coll-list-row__cell--end">
<div role="cell" class="coll-list-row__cell coll-list-row__cell--end">
{{ t("collection.games-count", romCount, { named: { n: romCount } }) }}
</div>
</a>
</div>
</template>
<style scoped>
@@ -179,22 +184,42 @@ function onRowClick(e: MouseEvent) {
transition: background var(--r-motion-fast) var(--r-motion-ease-out);
}
.coll-list-row:last-child {
border-bottom: 0;
}
.coll-list-row:hover {
background: var(--r-color-bg-elevated);
}
/* Kept off the compact row, whose shared flex layout this would outrank. */
.coll-list-row--columns {
position: relative;
display: grid;
gap: 0 var(--r-space-3);
padding: 0 max(var(--r-space-3), var(--r-list-bleed, 0px));
}
.coll-list-row:focus-visible {
/* The title link stretches over the row, so the whole row opens it. */
.coll-list-row__link {
display: block;
text-decoration: none;
}
.coll-list-row__link::after {
content: "";
position: absolute;
inset: 0;
}
.coll-list-row:focus-visible,
.coll-list-row:has(.coll-list-row__link:focus-visible) {
outline: none;
background: var(--r-color-bg-elevated);
box-shadow: inset 0 0 0 2px var(--r-color-brand-primary);
}
.coll-list-row__link:focus-visible {
outline: none;
}
.coll-list-row__cell {
min-width: 0;
@@ -254,7 +279,10 @@ function onRowClick(e: MouseEvent) {
/* The icon holds its size, the label shrinks and ellipsises within the
column. */
/* Above the stretched link, so its tooltip still answers to hover. */
.coll-list-row__kind {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: var(--r-space-1);
@@ -10,7 +10,7 @@
// Sticky positioning is owned by the parent (`GalleryShell` pins this
// below the toolbar at `top: --r-v2-shell-toolbar-h`). The header
// itself only paints: it doesn't manage scroll.
import { RCheckbox, RIcon } from "@v2/lib";
import { RCheckbox, RSortHeader, type RSortDir } from "@v2/lib";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import ListSortMenu from "@/v2/components/shared/ListSortMenu.vue";
@@ -30,7 +30,7 @@ interface Props {
/** Currently-sorted column key. `null` when no sort is active. */
sortKey: ListSortKey | null;
/** Sort direction for the active key. */
sortDir: "asc" | "desc";
sortDir: RSortDir;
/** Include the `platform` column. True on cross-platform surfaces
* (Search / Collection / Missing games); false on Platform.vue where
* every row shares the same platform. Mirrors the prop of the same
@@ -51,7 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
(e: "sort", payload: { key: ListSortKey; dir: "asc" | "desc" }): void;
(e: "sort", payload: { key: ListSortKey; dir: RSortDir }): void;
(e: "unsort"): void;
}>();
@@ -83,13 +83,7 @@ function onSelectAllClick(e: MouseEvent) {
}
}
function ariaSort(col: ListColumn) {
if (!col.sortable) return undefined;
if (props.sortKey !== col.key) return "none";
return props.sortDir === "asc" ? "ascending" : "descending";
}
function handleClick(col: ListColumn) {
function onSort(col: ListColumn, dir: RSortDir) {
if (!isSortableColumn(col)) return;
if (
props.unsortedLabel &&
@@ -99,11 +93,7 @@ function handleClick(col: ListColumn) {
emit("unsort");
return;
}
// Toggle direction when re-clicking the active column; otherwise start the
// new one ascending, like every other sortable table in the app.
const nextDir: "asc" | "desc" =
props.sortKey === col.key && props.sortDir === "asc" ? "desc" : "asc";
emit("sort", { key: col.key, dir: nextDir });
emit("sort", { key: col.key, dir });
}
</script>
@@ -138,67 +128,42 @@ function handleClick(col: ListColumn) {
</div>
<div v-else class="game-list-header" :style="gridStyle" role="row">
<div
v-for="col in columns"
:key="String(col.key)"
role="columnheader"
class="game-list-header__col"
:class="{ 'game-list-header__col--center': col.key === 'select' }"
:aria-sort="ariaSort(col)"
>
<template v-for="col in columns" :key="String(col.key)">
<!-- Tri-state select-all checkbox (off → some → all), judged
against the whole filtered result. -->
<RCheckbox
<div
v-if="col.key === 'select'"
class="game-list-header__check"
:model-value="selectionState === 'all'"
:indeterminate="selectionState === 'some'"
shape="circle"
size="sm"
color="primary"
bare
hide-details
:aria-label="
selectionState === 'all'
? t('gallery.selection-deselect-all')
: t('gallery.selection-select-all')
"
@click="onSelectAllClick"
/>
<span
v-else-if="!col.sortable"
class="game-list-header__cell"
:class="{ 'game-list-header__cell--end': col.align === 'end' }"
role="columnheader"
class="game-list-header__select"
>
<span v-if="col.label" class="game-list-header__label">
{{ col.label }}
</span>
<span v-else class="game-list-header__hidden">
{{ col.hiddenLabel }}
</span>
</span>
<button
v-else
type="button"
class="game-list-header__cell game-list-header__cell--sortable"
:class="{
'game-list-header__cell--end': col.align === 'end',
'game-list-header__cell--active': sortKey === col.key,
}"
@click="handleClick(col)"
>
<span class="game-list-header__label">{{ col.label }}</span>
<RIcon
v-if="col.sortable && sortKey === col.key"
:icon="
sortDir === 'asc' ? 'mdi-arrow-up-thin' : 'mdi-arrow-down-thin'
<RCheckbox
class="game-list-header__check"
:model-value="selectionState === 'all'"
:indeterminate="selectionState === 'some'"
shape="circle"
size="sm"
color="primary"
bare
hide-details
:aria-label="
selectionState === 'all'
? t('gallery.selection-deselect-all')
: t('gallery.selection-select-all')
"
size="14"
class="game-list-header__icon"
@click="onSelectAllClick"
/>
</button>
</div>
</div>
<RSortHeader
v-else
:label="col.label || col.hiddenLabel || ''"
:hide-label="!col.label"
:sortable="col.sortable"
:active="sortKey === col.key"
:dir="sortDir"
:align="col.align"
@sort="onSort(col, $event)"
/>
</template>
</div>
</template>
@@ -225,71 +190,11 @@ function handleClick(col: ListColumn) {
width: var(--r-list-select-w);
}
.game-list-header__col {
.game-list-header__select {
display: flex;
min-width: 0;
height: 100%;
}
.game-list-header__col--center {
justify-content: center;
}
.game-list-header__cell {
flex: 1;
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);
}
.game-list-header__cell--end {
justify-content: flex-end;
text-align: end;
}
/* End-aligned labels hug the right edge, so the sort glyph goes on the
label's left. Appending it would shove the label sideways on click. */
.game-list-header__cell--end .game-list-header__icon {
order: -1;
}
.game-list-header__cell--sortable {
cursor: pointer;
}
.game-list-header__cell--sortable:hover,
.game-list-header__cell--sortable:focus-visible {
color: var(--r-color-fg);
}
.game-list-header__cell--active {
color: var(--r-color-fg);
}
.game-list-header__label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.game-list-header__icon {
flex-shrink: 0;
color: var(--r-color-brand-primary);
}
/* Select-all checkbox in the leading column: RCheckbox bare/circle, matching the
@@ -297,13 +202,4 @@ function handleClick(col: ListColumn) {
.game-list-header__check {
align-self: center;
}
.game-list-header__hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>
@@ -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<Props>();
defineProps<Props>();
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 });
}
</script>
<template>
<div
v-if="smAndDown"
class="plat-list-header plat-list-header--compact"
role="row"
>
<div v-if="smAndDown" class="plat-list-header plat-list-header--compact">
<ListSortMenu
:options="sortOptions"
:sort-key="sortKey"
@@ -57,37 +42,16 @@ function handleClick(col: PlatformColumn) {
</div>
<div v-else class="plat-list-header" role="row">
<button
<RSortHeader
v-for="col in PLATFORM_COLUMNS"
:key="col.key"
type="button"
class="plat-list-header__cell"
:class="{
'plat-list-header__cell--meta': col.meta,
'plat-list-header__cell--sortable': col.sortable,
'plat-list-header__cell--end': col.align === 'end',
'plat-list-header__cell--center': col.align === 'center',
'plat-list-header__cell--active': col.sortable && sortKey === col.key,
}"
:aria-sort="
col.sortable && sortKey === col.key
? sortDir === 'asc'
? 'ascending'
: 'descending'
: 'none'
"
:tabindex="col.sortable ? 0 : -1"
:disabled="!col.sortable"
@click="handleClick(col)"
>
<span class="plat-list-header__label">{{ col.label }}</span>
<RIcon
v-if="col.sortable && sortKey === col.key"
:icon="sortDir === 'asc' ? 'mdi-arrow-up-thin' : 'mdi-arrow-down-thin'"
size="14"
class="plat-list-header__icon"
/>
</button>
:label="col.label"
:sortable="col.sortable"
:active="sortKey === col.key"
:dir="sortDir"
:align="col.align"
@sort="emit('sort', { key: col.key, dir: $event })"
/>
</div>
</template>
@@ -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);
}
</style>
@@ -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<typeof import("vue-router")>()),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/v2/components/shared/PlatformIcon.vue", () => ({
default: defineComponent({ template: "<i />" }),
}));
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();
});
});
@@ -140,18 +140,16 @@ function onRowClick(e: MouseEvent) {
/>
</a>
<a
<div
v-else
ref="rowEl"
role="row"
class="plat-list-row plat-list-row--columns"
:class="entranceClass"
:style="entranceStyle"
:href="href"
:aria-label="t('common.open-item', { name: displayName })"
@click="onRowClick"
@animationend.self="endEntrance"
>
<div class="plat-list-row__cell plat-list-row__title">
<div role="cell" class="plat-list-row__cell plat-list-row__title">
<div ref="iconEl" class="plat-list-row__thumb" :style="morphStyle">
<PlatformIcon
:slug="slug"
@@ -162,24 +160,32 @@ function onRowClick(e: MouseEvent) {
/>
</div>
<div class="plat-list-row__meta">
<div class="plat-list-row__name">{{ displayName }}</div>
<a
class="plat-list-row__name plat-list-row__link"
:href="href"
:aria-label="t('common.open-item', { name: displayName })"
@click="onRowClick"
>
{{ displayName }}
</a>
<div class="plat-list-row__slug">{{ slug }}</div>
</div>
</div>
<div class="plat-list-row__cell plat-list-row__cell--meta">
<div role="cell" class="plat-list-row__cell plat-list-row__cell--meta">
<span v-if="familyName">{{ familyName }}</span>
<span v-else class="plat-list-row__placeholder">—</span>
</div>
<div class="plat-list-row__cell plat-list-row__cell--meta">
<div role="cell" class="plat-list-row__cell plat-list-row__cell--meta">
<span v-if="categoryLabel">{{ categoryLabel }}</span>
<span v-else class="plat-list-row__placeholder">—</span>
</div>
<div class="plat-list-row__cell plat-list-row__cell--meta">
<div role="cell" class="plat-list-row__cell plat-list-row__cell--meta">
<span v-if="generationLabel">{{ generationLabel }}</span>
<span v-else class="plat-list-row__placeholder">—</span>
</div>
<div
role="cell"
class="plat-list-row__cell plat-list-row__cell--meta plat-list-row__cell--center"
>
<PlayModeBadge
@@ -205,13 +211,13 @@ function onRowClick(e: MouseEvent) {
</span>
</div>
<div class="plat-list-row__cell plat-list-row__cell--end">
<div role="cell" class="plat-list-row__cell plat-list-row__cell--end">
<span v-if="romCount != null">{{
t("collection.games-count", romCount, { named: { n: romCount } })
}}</span>
<span v-else class="plat-list-row__count-unit">—</span>
</div>
</a>
</div>
</template>
<style scoped>
@@ -232,28 +238,48 @@ function onRowClick(e: MouseEvent) {
transition: background var(--r-motion-fast) var(--r-motion-ease-out);
}
.plat-list-row:last-child {
border-bottom: 0;
}
.plat-list-row:hover {
background: var(--r-color-bg-elevated);
}
/* Kept off the compact row, whose shared flex layout this would outrank. */
.plat-list-row--columns {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 160px 130px 110px 88px 96px;
gap: 0 var(--r-space-3);
padding: 0 max(var(--r-space-3), var(--r-list-bleed, 0px));
}
/* The title link stretches over the row, so the whole row opens it. */
.plat-list-row__link {
display: block;
text-decoration: none;
}
.plat-list-row__link::after {
content: "";
position: absolute;
inset: 0;
}
/* Clear of the screen edge, which the row itself runs to. */
.plat-list-row--compact .plat-list-row__playable {
margin-inline-end: var(--r-space-2);
}
.plat-list-row:focus-visible {
.plat-list-row:focus-visible,
.plat-list-row:has(.plat-list-row__link:focus-visible) {
outline: none;
background: var(--r-color-bg-elevated);
box-shadow: inset 0 0 0 2px var(--r-color-brand-primary);
}
.plat-list-row__link:focus-visible {
outline: none;
}
.plat-list-row__cell {
min-width: 0;
@@ -295,7 +321,10 @@ function onRowClick(e: MouseEvent) {
opacity: 0.9;
}
/* Above the stretched link, so its tooltip still answers to hover. */
.plat-list-row__playable {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -16,9 +16,6 @@ export interface PlatformColumn {
label: string;
sortable: boolean;
align?: "start" | "end" | "center";
/** Drop this column on `xs` viewports (mirrors the row's compact
* layout: see PlatformListRow / PlatformListHeader CSS). */
meta?: boolean;
}
export const PLATFORM_COLUMNS: readonly PlatformColumn[] = [
@@ -28,28 +25,24 @@ export const PLATFORM_COLUMNS: readonly PlatformColumn[] = [
label: "Family",
sortable: true,
align: "start",
meta: true,
},
{
key: "category",
label: "Category",
sortable: true,
align: "start",
meta: true,
},
{
key: "generation",
label: "Generation",
sortable: true,
align: "start",
meta: true,
},
{
key: "playable",
label: "Playable",
sortable: true,
align: "center",
meta: true,
},
{ key: "rom_count", label: "Games", sortable: true, align: "end" },
];
@@ -0,0 +1,51 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import IndexShell from "./IndexShell.vue";
const smAndDown = ref(false);
vi.mock("@/v2/composables/useBreakpoint", () => ({
useBreakpoint: () => ({ smAndDown }),
}));
function mountShell(props: Record<string, unknown> = {}) {
return mount(IndexShell, {
props: { listMode: true, listLabel: "Platforms", ...props },
slots: {
listHeader: '<div role="row" class="hdr" />',
listRows: '<div role="row" class="body-row" />',
default: '<p class="content" />',
},
});
}
describe("IndexShell", () => {
beforeEach(() => {
smAndDown.value = false;
});
it("puts the list header and rows in one named table", () => {
const table = mountShell().get("[role=table]");
expect(table.attributes("aria-label")).toBe("Platforms");
expect(table.find(".hdr").exists()).toBe(true);
expect(table.find(".body-row").exists()).toBe(true);
expect(table.find(".content").exists()).toBe(false);
});
it("drops the table on phones, where the list is plain links", () => {
smAndDown.value = true;
const wrapper = mountShell();
expect(wrapper.find("[role=table]").exists()).toBe(false);
expect(wrapper.find(".body-row").exists()).toBe(true);
});
it("renders neither list slot outside list mode", () => {
const wrapper = mountShell({ listMode: false });
expect(wrapper.find(".hdr").exists()).toBe(false);
expect(wrapper.find(".body-row").exists()).toBe(false);
expect(wrapper.find(".content").exists()).toBe(true);
});
});
@@ -19,25 +19,32 @@
// top bar.
// * `#listHeader`: list-mode column header (PlatformListHeader /
// CollectionListHeader), sticky below the toolbar.
// Only rendered when `listMode` is true.
// * default: index content (grid / list / panels).
// * `#listRows`: list-mode rows, in one table with the header.
// Both list slots render only when `listMode` is true.
// * default: index content (grid / panels / loading and empty states).
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
import { usePinnedToolbar } from "@/v2/composables/usePinnedToolbar";
interface Props {
/** Enables the sticky `#listHeader` band below the toolbar. */
/** Enables the list header and rows. */
listMode?: boolean;
/** Names the list's table. */
listLabel?: string | undefined;
}
withDefaults(defineProps<Props>(), { listMode: false });
withDefaults(defineProps<Props>(), { listMode: false, listLabel: undefined });
defineSlots<{
header(): unknown;
toolbar(): unknown;
listHeader(): unknown;
listRows(): unknown;
default(): unknown;
}>();
const { toolbarHeight, pinned, bindToolbar, bindSentinel } = usePinnedToolbar();
// Phones and tablets get a sort menu and plain link rows, so no table.
const { smAndDown } = useBreakpoint();
</script>
<template>
@@ -61,10 +68,16 @@ const { toolbarHeight, pinned, bindToolbar, bindSentinel } = usePinnedToolbar();
<div
v-if="listMode"
class="r-v2-idx-shell__list-header"
:class="{ 'r-pinned-list-header': pinned }"
:role="smAndDown ? undefined : 'table'"
:aria-label="smAndDown ? undefined : listLabel"
>
<slot name="listHeader" />
<div
class="r-v2-idx-shell__list-header"
:class="{ 'r-pinned-list-header': pinned }"
>
<slot name="listHeader" />
</div>
<slot name="listRows" />
</div>
<slot />
@@ -2,7 +2,7 @@
// ListSortMenu: the sort control a list header shows once its columns are
// gone (phones and tablets). Shared by the gallery, platforms and collections
// headers, so the three can't answer "what is this sorted by" three ways.
import { RIcon, RMenu, RMenuItem } from "@v2/lib";
import { nextSortDir, RIcon, RMenu, RMenuItem, type RSortDir } from "@v2/lib";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
@@ -10,7 +10,7 @@ const props = defineProps<{
options: readonly { key: K; label: string }[];
/** Null when the list's order is one the columns don't carry. */
sortKey: K | null;
sortDir: "asc" | "desc";
sortDir: RSortDir;
/** Offers the list's order without a sort key (Search's relevance) under this name. */
unsortedLabel?: string | undefined;
/** Whether that order is the one applied. */
@@ -18,7 +18,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: "sort", payload: { key: K; dir: "asc" | "desc" }): void;
(e: "sort", payload: { key: K; dir: RSortDir }): void;
(e: "unsort"): void;
}>();
@@ -36,11 +36,7 @@ const label = computed(
);
function pick(key: K) {
// Toggle direction when re-picking the active key; otherwise start the new
// one ascending, like every other sortable table in the app.
const dir: "asc" | "desc" =
props.sortKey === key && props.sortDir === "asc" ? "desc" : "asc";
emit("sort", { key, dir });
emit("sort", { key, dir: nextSortDir(props.sortKey === key, props.sortDir) });
}
</script>
@@ -0,0 +1,154 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { expect, userEvent, within } from "storybook/test";
import { ref } from "vue";
import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue";
import RSortHeader from "./RSortHeader.vue";
import type { RSortDir } from "./types";
const meta: Meta<typeof RSortHeader> = {
title: "Data/RSortHeader",
component: RSortHeader,
argTypes: {
dir: { control: "inline-radio", options: ["asc", "desc"] },
align: { control: "inline-radio", options: ["start", "end", "center"] },
},
// A columnheader is only valid inside a row, so every story renders one.
render: (args) => ({
components: { RSortHeader },
setup: () => ({ args }),
template: `
<div role="table" style="width: 240px;">
<div role="row" style="height: 40px; display: flex;">
<RSortHeader v-bind="args" style="flex: 1;" />
</div>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<typeof RSortHeader>;
export const Default: Story = {
args: { label: "Title", sortable: true, active: true, dir: "asc" },
};
// The toolbar switches every story's theme; this one pins light.
export const Light: Story = {
args: { label: "Title", sortable: true, active: true, dir: "asc" },
globals: { theme: "light" },
};
export const Inactive: Story = {
args: { label: "Size", sortable: true },
};
export const EndAligned: Story = {
args: {
label: "Rating",
sortable: true,
active: true,
dir: "desc",
align: "end",
},
};
export const NotSortable: Story = {
args: { label: "Tags" },
};
export const WithAppend: Story = {
args: { label: "Type", sortable: true },
render: (args) => ({
components: { RSortHeader, RBtn },
setup: () => ({ args }),
template: `
<div role="table" style="width: 240px;">
<div role="row" style="height: 40px; display: flex;">
<RSortHeader v-bind="args" style="flex: 1;">
<template #append>
<RBtn
variant="text"
size="small"
icon="mdi-information-outline"
aria-label="About types"
/>
</template>
</RSortHeader>
</div>
</div>
`,
}),
};
const COLUMNS = [
{ key: "name", label: "Title", sortable: true },
{ key: "cover", label: "Cover", hideLabel: true },
{ key: "size", label: "Size", sortable: true, align: "end" as const },
{ key: "tags", label: "Tags" },
];
// A full header row, with the sort state held by the parent the way a list
// header does.
export const KeyboardSort: Story = {
name: "Header row, keyboard sort (play)",
render: () => ({
components: { RSortHeader },
setup: () => {
const sortKey = ref("name");
const sortDir = ref<RSortDir>("asc");
function onSort(key: string, dir: RSortDir) {
sortKey.value = key;
sortDir.value = dir;
}
return { columns: COLUMNS, sortKey, sortDir, onSort };
},
template: `
<div role="table" style="width: 520px;">
<div
role="row"
style="display: grid; grid-template-columns: 1fr 60px 100px 1fr; gap: 12px; height: 40px;"
>
<RSortHeader
v-for="col in columns"
:key="col.key"
:label="col.label"
:sortable="col.sortable"
:hide-label="col.hideLabel"
:align="col.align"
:active="sortKey === col.key"
:dir="sortDir"
@sort="onSort(col.key, $event)"
/>
</div>
</div>
`,
}),
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const header = (name: string) => canvas.getByRole("columnheader", { name });
await step("only sortable headers are tab stops", async () => {
await userEvent.tab();
await expect(canvas.getByRole("button", { name: "Title" })).toHaveFocus();
await userEvent.tab();
await expect(canvas.getByRole("button", { name: "Size" })).toHaveFocus();
});
await step("Enter sorts a new column ascending", async () => {
await userEvent.keyboard("{Enter}");
await expect(header("Size")).toHaveAttribute("aria-sort", "ascending");
await expect(header("Title")).toHaveAttribute("aria-sort", "none");
});
await step("Space flips the active column", async () => {
await userEvent.keyboard(" ");
await expect(header("Size")).toHaveAttribute("aria-sort", "descending");
});
await step("a hidden label still names its header", async () => {
await expect(header("Cover")).not.toHaveAttribute("aria-sort");
await expect(header("Tags")).not.toHaveAttribute("aria-sort");
});
},
};
@@ -0,0 +1,156 @@
<script setup lang="ts">
// RSortHeader: one column header of a sortable list. The parent owns the sort
// state and the row grid; this owns `aria-sort`, the button and the glyph.
import { computed, useSlots } from "vue";
import RIcon from "@/v2/lib/primitives/RIcon/RIcon.vue";
import { nextSortDir } from "./nextSortDir";
import type { RSortDir, RSortHeaderProps } from "./types";
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<RSortHeaderProps>(), {
sortable: false,
active: false,
dir: "asc",
align: "start",
hideLabel: false,
});
const emit = defineEmits<{
(e: "sort", dir: RSortDir): void;
}>();
defineSlots<{
/** Adornment after the label, e.g. a help button. */
append?: () => unknown;
}>();
const slots = useSlots();
const ariaSort = computed(() => {
if (!props.sortable) return undefined;
if (!props.active) return "none";
return props.dir === "asc" ? "ascending" : "descending";
});
</script>
<template>
<div
class="r-sort-header"
:class="[
`r-sort-header--${align}`,
{ 'r-sort-header--has-append': !!slots.append },
]"
role="columnheader"
:aria-sort="ariaSort"
v-bind="$attrs"
>
<button
v-if="sortable"
type="button"
class="r-sort-header__btn"
:class="{ 'r-sort-header__btn--active': active }"
@click="emit('sort', nextSortDir(active, dir))"
>
<span
class="r-sort-header__label"
:class="{ 'r-sort-header__label--hidden': hideLabel }"
>
{{ label }}
</span>
<RIcon
v-if="active"
:icon="dir === 'asc' ? 'mdi-arrow-up-thin' : 'mdi-arrow-down-thin'"
size="14"
class="r-sort-header__icon"
/>
</button>
<span
v-else
class="r-sort-header__label"
:class="{ 'r-sort-header__label--hidden': hideLabel }"
>
{{ label }}
</span>
<slot name="append" />
</div>
</template>
<style scoped>
.r-sort-header {
display: flex;
align-items: center;
gap: var(--r-space-1);
min-width: 0;
height: 100%;
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;
}
.r-sort-header--end {
justify-content: flex-end;
text-align: end;
}
.r-sort-header--center {
justify-content: center;
text-align: center;
}
/* The button fills the cell so the whole header is the hit target, unless an
adornment has to sit right after the label. */
.r-sort-header__btn {
flex: 1;
appearance: none;
background: transparent;
border: 0;
padding: 0;
font: inherit;
color: inherit;
letter-spacing: inherit;
text-transform: inherit;
text-align: inherit;
display: inline-flex;
align-items: center;
justify-content: inherit;
gap: var(--r-space-1);
min-width: 0;
height: 100%;
cursor: pointer;
border-radius: var(--r-radius-sm);
transition: color var(--r-motion-fast) var(--r-motion-ease-out);
}
.r-sort-header--has-append .r-sort-header__btn {
flex: 0 1 auto;
}
.r-sort-header__btn:hover,
.r-sort-header__btn:focus-visible,
.r-sort-header__btn--active {
color: var(--r-color-fg);
}
.r-sort-header__label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.r-sort-header__label--hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.r-sort-header__icon {
flex-shrink: 0;
color: var(--r-color-brand-primary);
}
/* End-aligned labels hug the right edge, so the glyph goes on the label's
left. Appending it would shove the label sideways on click. */
.r-sort-header--end .r-sort-header__icon {
order: -1;
}
</style>
@@ -0,0 +1,3 @@
export { default as RSortHeader } from "./RSortHeader.vue";
export { nextSortDir } from "./nextSortDir";
export type { RSortDir, RSortHeaderProps } from "./types";
@@ -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");
});
});
@@ -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";
}
@@ -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;
}
@@ -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 (`<T>`):
// 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<string | null>("name");
const sortDir = ref<RSortDir>("asc");
function onSort(payload: RTableSortPayload) {
sortKey.value = payload.key;
sortDir.value = payload.dir;
}
return { args, sortKey, sortDir, onSort, onRowClick };
},
template: `
<div style="padding: 32px;">
<RTable
v-bind="args"
:sort-key="sortKey"
:sort-dir="sortDir"
@update:sort="onSort"
@row:click="onRowClick"
/>
</div>
`,
}),
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]);
});
},
};
+17 -124
View File
@@ -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<string, unknown>)[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"
>
<!-- Header row: each column header is a cell `<div>` containing
either a sort `<button>` (sortable cols) or a plain label, plus
an optional `header.<key>` slot for adornments (e.g. a help
icon next to "Type"). The sort button is the only interactive
element so adornments stay focusable / clickable on their own
without nesting buttons. -->
<!-- Presentational so rows resolve their role="table" parent (the
scroll/body divs are layout-only). -->
<div class="r-table__scroll" :style="scrollStyle" role="presentation">
<div class="r-table__header" :style="gridStyle" role="row">
<div
<!-- `header.<key>` adorns a column (e.g. a help button next to
"Type") beside the sort button rather than inside it. -->
<RSortHeader
v-for="col in columns"
:key="col.key"
class="r-table__header-cell"
:class="{
'r-table__header-cell--end': col.align === 'end',
'r-table__header-cell--center': col.align === 'center',
}"
role="columnheader"
:aria-sort="
col.sortable && sortKey === col.key
? sortDir === 'asc'
? 'ascending'
: 'descending'
: col.sortable
? 'none'
: undefined
"
:label="col.label"
:sortable="col.sortable"
:active="sortKey === col.key"
:dir="sortDir"
:align="col.align"
@sort="emit('update:sort', { key: col.key, dir: $event })"
>
<button
v-if="col.sortable"
type="button"
class="r-table__header-sort"
:class="{
'r-table__header-sort--active': sortKey === col.key,
}"
@click="handleSort(col)"
>
<span class="r-table__header-label">{{ col.label }}</span>
<RIcon
v-if="sortKey === col.key"
:icon="
sortDir === 'asc' ? 'mdi-arrow-up-thin' : 'mdi-arrow-down-thin'
"
size="14"
class="r-table__header-icon"
/>
</button>
<span v-else class="r-table__header-label">{{ col.label }}</span>
<slot :name="`header.${col.key}`" :column="col" />
</div>
<template v-if="$slots[`header.${col.key}`]" #append>
<slot :name="`header.${col.key}`" :column="col" />
</template>
</RSortHeader>
</div>
<!-- Body: skeletons / real rows / empty state, in that order. -->
@@ -335,68 +290,6 @@ watch(
backdrop-filter: blur(10px);
}
/* Outer cell: flex container holding the sort button (or static
label) and any consumer adornment from the `header.<key>` slot. */
.r-table__header-cell {
display: inline-flex;
align-items: center;
gap: var(--r-space-1, 4px);
min-width: 0;
height: 100%;
color: var(--r-color-fg-muted);
font-size: var(--r-font-size-xs, 10px);
font-weight: var(--r-font-weight-bold);
letter-spacing: 0.07em;
text-transform: uppercase;
}
.r-table__header-cell--end {
justify-content: flex-end;
text-align: end;
}
.r-table__header-cell--center {
justify-content: center;
text-align: center;
}
/* Inner sort button: the only interactive piece in the header. The
tint flips to fg on hover/focus/active so the affordance still reads
exactly like the previous all-cell button. */
.r-table__header-sort {
appearance: none;
background: transparent;
border: 0;
padding: 0;
font: inherit;
color: inherit;
display: inline-flex;
align-items: center;
gap: var(--r-space-1, 4px);
min-width: 0;
cursor: pointer;
text-align: inherit;
text-transform: inherit;
letter-spacing: inherit;
border-radius: var(--r-radius-sm);
transition: color var(--r-motion-fast) var(--r-motion-ease-out);
}
.r-table__header-sort:hover,
.r-table__header-sort:focus-visible {
color: var(--r-color-fg);
}
.r-table__header-sort--active {
color: var(--r-color-fg);
}
.r-table__header-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.r-table__header-icon {
flex-shrink: 0;
color: var(--r-color-brand-primary);
}
/* ------------------------- Body --------------------------- */
.r-table__body {
display: flex;
+1 -6
View File
@@ -1,7 +1,2 @@
export { default as RTable } from "./RTable.vue";
export type {
RTableColumn,
RTableProps,
RTableSortDir,
RTableSortPayload,
} from "./types";
export type { RTableColumn, RTableProps, RTableSortPayload } from "./types";
+4 -4
View File
@@ -1,3 +1,5 @@
import type { RSortDir } from "@/v2/lib/data/RSortHeader/types";
// RTable column descriptor: drives both the header and the row body.
// Widths are CSS grid track values (px, fr, minmax(...)) so consumers
// can mix fixed-width metrics with flexible title columns the same way
@@ -16,7 +18,7 @@ export interface RTableProps<TItem> {
/** Currently-sorted column key. `null` when no sort is active. */
sortKey?: string | null;
/** Sort direction for the active key. */
sortDir?: RTableSortDir;
sortDir?: RSortDir;
/** Empty-state icon when `items` is empty and not loading. */
emptyIcon?: string;
/** Empty-state message. */
@@ -51,9 +53,7 @@ export interface RTableColumn {
skeletonWidth?: number;
}
export type RTableSortDir = "asc" | "desc";
export interface RTableSortPayload {
key: string;
dir: RTableSortDir;
dir: RSortDir;
}
+1
View File
@@ -63,6 +63,7 @@ export * from "./overlays/RDialog";
export * from "./overlays/RDrawer";
// Data
export * from "./data/RSortHeader";
export * from "./data/RTable";
// Media
@@ -83,7 +83,7 @@ vi.mock("@/v2/components/Collections/CollectionTile.vue", () => ({
vi.mock("@/v2/components/shared/IndexShell.vue", () => ({
default: defineComponent({
template:
'<main><slot name="header" /><slot name="toolbar" /><slot name="listHeader" /><slot /></main>',
'<main><slot name="header" /><slot name="toolbar" /><slot name="listHeader" /><slot name="listRows" /><slot /></main>',
}),
}));
+21 -28
View File
@@ -439,21 +439,15 @@ const showVirtualLoading = computed(
curatedTiles.value.length > 0,
);
// Drives `IndexShell`'s sticky list-header band: only render the
// column header when the list itself is on screen (list layout + a
// row to show). Skipping it during the skeleton / empty / no-match
// states keeps an orphan header from floating above an empty viewport.
const showListHeader = computed(
() =>
layout.value === "list" &&
!isLoading.value &&
totalCount.value > 0 &&
filtered.value.length > 0,
// Header and rows render only while there is a row, so no header floats
// above the skeleton, empty or no-match states.
const showList = computed(
() => layout.value === "list" && sortedTiles.value.length > 0,
);
</script>
<template>
<IndexShell :list-mode="showListHeader">
<IndexShell :list-mode="showList" :list-label="t('common.collections')">
<template #header>
<PageHeader :title="t('common.collections')" :count="totalCount" />
<RDivider class="r-v2-cidx__header-divider" />
@@ -484,6 +478,20 @@ const showListHeader = computed(
/>
</template>
<template #listRows>
<CollectionListRow
v-for="c in sortedTiles"
:id="c.id"
:key="`${c.kind}-${c.id}`"
:to="c.link"
:name="c.name"
:rom-count="c.rom_count"
:covers="c.covers"
:kind="c.kind"
:is-public="c.isPublic"
/>
</template>
<div ref="gridRoot">
<div v-if="phase === 'skeleton'" class="r-v2-cidx__grid">
<RSkeletonBlock
@@ -507,19 +515,8 @@ const showListHeader = computed(
:title="emptyState.message"
/>
<div v-else-if="layout === 'list'" class="r-v2-cidx__list">
<CollectionListRow
v-for="c in sortedTiles"
:id="c.id"
:key="`${c.kind}-${c.id}`"
:to="c.link"
:name="c.name"
:rom-count="c.rom_count"
:covers="c.covers"
:kind="c.kind"
:is-public="c.isPublic"
/>
</div>
<!-- List mode: IndexShell renders the rows with the header. -->
<template v-else-if="layout === 'list'" />
<template v-else>
<!-- Curated panel: regular + smart in a translucent container,
@@ -658,10 +655,6 @@ const showListHeader = computed(
gap: 20px 16px;
}
.r-v2-cidx__list :deep(.coll-list-row:last-child) {
border-bottom: 0;
}
/* Curated panel: translucent container (same vocabulary as the
related-games surface in the game detail view via RCollapsible).
Acts as the visual "this is one cohesive group" cue against the
+1 -1
View File
@@ -67,7 +67,7 @@ vi.mock("@/v2/components/Platforms/PlatformTile.vue", () => ({
vi.mock("@/v2/components/shared/IndexShell.vue", () => ({
default: defineComponent({
template:
'<main><slot name="header" /><slot name="toolbar" /><slot name="listHeader" /><slot /></main>',
'<main><slot name="header" /><slot name="toolbar" /><slot name="listHeader" /><slot name="listRows" /><slot /></main>',
}),
}));
+23 -24
View File
@@ -450,7 +450,10 @@ const groupedBuckets = computed<Bucket[] | null>(() => {
</script>
<template>
<IndexShell :list-mode="layout === 'list'">
<IndexShell
:list-mode="layout === 'list'"
:list-label="t('common.platforms')"
>
<template #header>
<PageHeader :title="t('common.platforms')" :count="totalCount" />
<RDivider class="r-v2-pidx__header-divider" />
@@ -482,6 +485,23 @@ const groupedBuckets = computed<Bucket[] | null>(() => {
/>
</template>
<!-- Rows show the axes the toolbar groups by, so a flat list still
shows what would have separated them. -->
<template #listRows>
<PlatformListRow
v-for="p in sortedForList"
:id="p.id"
:key="p.id"
:slug="p.slug"
:fs-slug="p.fs_slug"
:display-name="p.display_name"
:rom-count="p.rom_count"
:family-name="p.family_name ?? null"
:category="p.category ?? null"
:generation="p.generation ?? null"
/>
</template>
<div ref="gridRoot">
<div v-if="phase === 'skeleton'" class="r-v2-pidx__grid">
<RSkeletonBlock
@@ -505,25 +525,8 @@ const groupedBuckets = computed<Bucket[] | null>(() => {
:title="noResultsMessage"
/>
<!-- List mode: rows underneath the sticky column header (rendered
by IndexShell via the `#listHeader` slot above). Rows surface
the same family / category / generation axes the toolbar can
group by, so the user reading the flat list still sees what
would have separated them. -->
<div v-else-if="layout === 'list'" class="r-v2-pidx__list">
<PlatformListRow
v-for="p in sortedForList"
:id="p.id"
:key="p.id"
:slug="p.slug"
:fs-slug="p.fs_slug"
:display-name="p.display_name"
:rom-count="p.rom_count"
:family-name="p.family_name ?? null"
:category="p.category ?? null"
:generation="p.generation ?? null"
/>
</div>
<!-- List mode: IndexShell renders the rows with the header. -->
<template v-else-if="layout === 'list'" />
<!-- Grid mode, grouped: letter uses RLetterHeading (large
single-character glyph); family / category / generation use
@@ -581,10 +584,6 @@ const groupedBuckets = computed<Bucket[] | null>(() => {
gap: 16px;
}
.r-v2-pidx__list :deep(.plat-list-row:last-child) {
border-bottom: 0;
}
/* Section heading used by family / category / generation grouping:
compact uppercase label with the same vocabulary as
RLetterHeading's metadata, so the two heading styles read as