mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
refactor(v2): extract RSortHeader and make the index lists real tables (#5149)
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>',
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>',
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user