feat(spa): entity list view redesign with active-filter chips + pagination

Match the prototype's ModuleListView pattern:

- Toolbar: search InputGroup (search icon addon), filters toggle button
  with active-count badge, sort Select, item count filtered / total
- Filter panel: keep existing slot for category + tags + favorites,
  add optional kind-specific extras as chip toggles via new
  extraFilters prop (key/label/check predicate)
- Active-filter chips row: per filter (search, favorites, category,
  each tag, each extra) with accessible button close, "Clear all"
- Bulk-select bar: render above active-filter chips when selection > 0
- Empty state: "No matches" variant when filters active vs. branded
  empty CTA when no items
- Pagination footer: per-page select (10/15/25/50 default 15),
  first/prev/next/last buttons disabled at bounds, X-Y of Z range,
  page state local to component (slicing client-side)
- toolbar-actions slot for kind-specific buttons next to "New"
- Migrated styles to scoped --wp-* tokens

All six list screens (Wildcards, FixedValues, Combines, Derivations,
Constraints, Pipelines) consume this component via the same slot
contract — no consumer changes required.

Tests: +11 new EntityListView tests covering pagination bounds,
filter-chip removal, active-filter count badge, empty-state branching,
extraFilters chip rendering. Total 174 -> 185 passing.
This commit is contained in:
DumiFlex
2026-04-27 06:20:58 +03:00
parent d84bd2d60b
commit 370fdd8388
3 changed files with 698 additions and 62 deletions
@@ -0,0 +1,212 @@
import { mount, flushPromises } from "@vue/test-utils";
import { reactive } from "vue";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
import ConfirmationService from "primevue/confirmationservice";
import Column from "primevue/column";
import EntityListView from "../components/EntityListView.vue";
interface Row {
id: string;
name: string;
updated_at: string;
is_favorite?: boolean;
tags?: string[];
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/things/new", component: { template: "<div/>" } },
],
});
}
function makeItems(n: number): Row[] {
// Newest first sort baseline — generate descending updated_at strings.
return Array.from({ length: n }, (_, i) => ({
id: `r_${String(i).padStart(3, "0")}`,
name: `Row ${i}`,
updated_at: `2025-01-${String(31 - (i % 28)).padStart(2, "0")}T00:00:00Z`,
tags: i % 2 === 0 ? ["even"] : ["odd"],
is_favorite: i === 0,
}));
}
interface Filter {
q?: string;
favorites?: boolean;
category?: string;
tags?: string[];
sortBy?: string;
}
function mountView(opts: {
items: Row[];
filter?: Filter;
extraFilters?: { key: string; label: string; check: (r: Row) => boolean }[];
} = { items: [] }) {
const filter = reactive<Filter>(opts.filter ?? {});
const wrap = mount(EntityListView<Row>, {
props: {
title: "Things",
subtitle: "All the things",
newLabel: "New Thing",
newRoute: "/things/new",
items: opts.items,
loading: false,
filter,
emptyMessage: "No things yet.",
extraFilters: opts.extraFilters,
},
global: {
plugins: [makeRouter(), PrimeVue, ToastService, ConfirmationService],
components: { Column },
},
slots: {
columns: `<Column field="name" header="Name" />`,
},
});
return { wrap, filter };
}
beforeEach(() => {
setActivePinia(createPinia());
});
describe("EntityListView.vue", () => {
it("renders title, subtitle, and empty-state CTA when no items + no filters", async () => {
const { wrap } = mountView({ items: [] });
await flushPromises();
expect(wrap.text()).toContain("Things");
expect(wrap.text()).toContain("All the things");
expect(wrap.text()).toContain("No things yet.");
});
it("renders rows when items provided", async () => {
const { wrap } = mountView({ items: makeItems(3) });
await flushPromises();
expect(wrap.text()).toContain("Row 0");
expect(wrap.text()).toContain("Row 1");
expect(wrap.text()).toContain("Row 2");
});
it("hides pagination footer when items count below smallest page size", async () => {
const { wrap } = mountView({ items: makeItems(5) });
await flushPromises();
expect(wrap.find(".wp-pagination").exists()).toBe(false);
});
it("shows pagination footer + correct counts when more than 10 items", async () => {
const { wrap } = mountView({ items: makeItems(20) });
await flushPromises();
expect(wrap.find(".wp-pagination").exists()).toBe(true);
// Default pageSize = 15, total = 20, so first page shows 1–15 of 20.
const text = wrap.find(".wp-pagination").text();
expect(text).toContain("1");
expect(text).toContain("15");
expect(text).toContain("20");
});
it("disables prev/first on page 1 and enables next/last", async () => {
const { wrap } = mountView({ items: makeItems(40) });
await flushPromises();
const buttons = wrap.find(".wp-pager").findAll("button");
// Order: first, prev, next, last (label spans between prev and next).
expect(buttons[0].attributes("disabled")).toBeDefined();
expect(buttons[1].attributes("disabled")).toBeDefined();
expect(buttons[2].attributes("disabled")).toBeUndefined();
expect(buttons[3].attributes("disabled")).toBeUndefined();
});
it("disables next/last after navigating to last page", async () => {
const { wrap } = mountView({ items: makeItems(40) });
await flushPromises();
const inst = wrap.vm as unknown as { goToPage: (n: number) => void };
inst.goToPage(99);
await flushPromises();
const buttons = wrap.find(".wp-pager").findAll("button");
expect(buttons[0].attributes("disabled")).toBeUndefined();
expect(buttons[1].attributes("disabled")).toBeUndefined();
expect(buttons[2].attributes("disabled")).toBeDefined();
expect(buttons[3].attributes("disabled")).toBeDefined();
});
it("active filter count includes category, favorites, and each tag", async () => {
const { wrap } = mountView({
items: makeItems(3),
filter: { category: "cat-1", favorites: true, tags: ["a", "b"] },
});
await flushPromises();
expect(wrap.find(".wp-filter-count").exists()).toBe(true);
expect(wrap.find(".wp-filter-count").text()).toBe("4");
});
it("'No matches' empty state shows when filters active and result empty", async () => {
const { wrap } = mountView({
items: [],
filter: { q: "nothing matches" },
});
await flushPromises();
expect(wrap.text()).toContain("No matches");
expect(wrap.text()).not.toContain("No things yet.");
});
it("clear-all button removes filter chips", async () => {
const { wrap, filter } = mountView({
items: makeItems(3),
filter: { favorites: true, tags: ["a"], category: "cat-1" },
});
await flushPromises();
expect(wrap.findAll(".filter-chip").length).toBeGreaterThan(0);
const clearBtn = wrap.findAll("button").find((b) => b.text() === "Clear all");
expect(clearBtn).toBeDefined();
if (!clearBtn) throw new Error("missing clear button");
await clearBtn.trigger("click");
await flushPromises();
expect(filter.favorites).toBe(false);
expect(filter.category).toBeUndefined();
expect(filter.tags).toEqual([]);
expect(wrap.findAll(".filter-chip").length).toBe(0);
});
it("removing a tag chip removes only that tag", async () => {
const { wrap, filter } = mountView({
items: makeItems(3),
filter: { tags: ["a", "b"] },
});
await flushPromises();
const tagChips = wrap.findAll(".filter-chip--tag");
expect(tagChips.length).toBe(2);
// Click the close button of the first tag chip.
await tagChips[0].find(".filter-chip__close").trigger("click");
await flushPromises();
expect(filter.tags).toEqual(["b"]);
expect(wrap.findAll(".filter-chip--tag").length).toBe(1);
});
it("renders extraFilters as toggle chips inside the filter panel", async () => {
const { wrap } = mountView({
items: makeItems(4),
extraFilters: [
{ key: "fav", label: "Favorited", check: (r: Row) => !!r.is_favorite },
],
});
await flushPromises();
// Open the filter panel.
const filtersBtn = wrap.findAll("button").find((b) => b.text().includes("Filters"));
expect(filtersBtn).toBeDefined();
if (!filtersBtn) throw new Error("missing filters button");
await filtersBtn.trigger("click");
await flushPromises();
expect(wrap.find(".wp-extra-chip").exists()).toBe(true);
expect(wrap.find(".wp-extra-chip").text()).toContain("Favorited");
// Count badge reflects matches in items (1 of 4 favorited).
expect(wrap.find(".wp-extra-chip__count").text()).toBe("1");
});
});
+484 -60
View File
@@ -3,8 +3,8 @@ import { computed, ref, watch } from "vue";
import DataTable from "primevue/datatable";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import IconField from "primevue/iconfield";
import InputIcon from "primevue/inputicon";
import InputGroup from "primevue/inputgroup";
import InputGroupAddon from "primevue/inputgroupaddon";
import Select from "primevue/select";
import { useConfirm } from "primevue/useconfirm";
import ConfirmDialog from "primevue/confirmdialog";
@@ -17,11 +17,24 @@ interface Filter {
sortBy?: string;
}
interface ExtraFilter {
key: string;
label: string;
check: (item: T) => boolean;
}
const SORT_OPTIONS = [
{ label: "Updated (newest)", value: "updated-desc" },
{ label: "Updated (oldest)", value: "updated-asc" },
{ label: "Name (A → Z)", value: "name-asc" },
{ label: "Name (Z → A)", value: "name-desc" },
{ label: "Updated — newest", value: "updated-desc" },
{ label: "Updated — oldest", value: "updated-asc" },
{ label: "Name A → Z", value: "name-asc" },
{ label: "Name Z → A", value: "name-desc" },
];
const PAGE_SIZE_OPTIONS = [
{ label: "10", value: 10 },
{ label: "15", value: 15 },
{ label: "25", value: 25 },
{ label: "50", value: 50 },
];
const props = defineProps<{
@@ -33,6 +46,7 @@ const props = defineProps<{
loading: boolean;
filter: Filter;
emptyMessage: string;
extraFilters?: ExtraFilter[];
}>();
const emit = defineEmits<{
@@ -46,35 +60,52 @@ const search = ref(props.filter.q ?? "");
const filterPanelOpen = ref(false);
const selected = ref<T[]>([]);
const expandedRows = ref<Record<string, boolean>>({});
const activeExtras = ref<string[]>([]);
const page = ref(1);
const pageSize = ref(15);
// Public asset served by aiohttp at runtime — use dynamic binding so Vite
// does not try to inline/copy the file during the manager SPA build.
const faviconUrl = "/wp/images/favicon.svg";
watch(search, async (q) => {
watch(search, (q) => {
props.filter.q = q || undefined;
emit("fetch");
});
const activeFilterCount = computed(() => {
let n = 0;
if (props.filter.q) n++;
if (props.filter.favorites) n++;
if (props.filter.category) n++;
if (props.filter.tags?.length) n++;
if (props.filter.tags?.length) n += props.filter.tags.length;
n += activeExtras.value.length;
return n;
});
const hasActiveFilters = computed(
() => activeFilterCount.value > 0 || !!props.filter.q,
);
function clearFilters() {
search.value = "";
props.filter.q = undefined;
props.filter.favorites = false;
props.filter.category = undefined;
props.filter.tags = [];
activeExtras.value = [];
emit("fetch");
}
const sortedItems = computed(() => {
function removeTag(t: string) {
props.filter.tags = (props.filter.tags ?? []).filter((x) => x !== t);
emit("fetch");
}
function removeExtra(key: string) {
activeExtras.value = activeExtras.value.filter((k) => k !== key);
}
const filteredItems = computed(() => {
let list = [...props.items] as (T & {
name?: string;
updated_at?: string;
@@ -85,6 +116,14 @@ const sortedItems = computed(() => {
const wanted = new Set(props.filter.tags);
list = list.filter((m) => (m.tags ?? []).some((t) => wanted.has(t)));
}
// Apply extra (kind-specific) filters via passed predicates.
const extras = props.extraFilters;
if (activeExtras.value.length && extras?.length) {
const tests = activeExtras.value
.map((k) => extras.find((f) => f.key === k))
.filter((f): f is ExtraFilter => !!f);
list = list.filter((m) => tests.every((f) => f.check(m as unknown as T)));
}
switch (props.filter.sortBy) {
case "name-asc":
list.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
@@ -103,6 +142,43 @@ const sortedItems = computed(() => {
return list;
});
// Reset to page 1 whenever any filter input or page size changes.
watch(
() => [
props.filter.q,
props.filter.category,
props.filter.favorites,
props.filter.tags?.length ?? 0,
activeExtras.value.length,
props.filter.sortBy,
pageSize.value,
],
() => {
page.value = 1;
},
);
const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)),
);
const safePage = computed(() => Math.min(page.value, totalPages.value));
const pageStart = computed(() => (safePage.value - 1) * pageSize.value);
const pageEnd = computed(() =>
Math.min(pageStart.value + pageSize.value, filteredItems.value.length),
);
const pagedItems = computed(() =>
filteredItems.value.slice(pageStart.value, pageStart.value + pageSize.value),
);
const showPagination = computed(
() => filteredItems.value.length > 0 && filteredItems.value.length > Math.min(...PAGE_SIZE_OPTIONS.map((o) => o.value)),
);
function goToPage(p: number) {
page.value = Math.min(Math.max(1, p), totalPages.value);
}
function rowClass(row: T): string {
return (row as T & { is_favorite?: boolean }).is_favorite ? "wp-row-favorite" : "";
}
@@ -119,32 +195,57 @@ function confirmBulkDelete() {
},
});
}
defineExpose({
// Exposed for tests + parent screens that want to drive pagination/filters.
page,
pageSize,
goToPage,
activeFilterCount,
filteredItems,
pagedItems,
totalPages,
});
</script>
<template>
<div class="p-6 text-wp-text">
<div class="wp-page p-6 text-wp-text">
<ConfirmDialog />
<div class="flex items-end justify-between mb-4">
<div>
<h1 class="text-xl font-semibold m-0">{{ title }}</h1>
<p class="text-sm text-wp-text2 m-0 mt-1">{{ subtitle }}</p>
<div class="wp-page__header">
<div class="wp-page__title-wrap">
<h1 class="wp-page__title">{{ title }}</h1>
<p class="wp-page__subtitle">{{ subtitle }}</p>
</div>
<div class="wp-page__actions">
<slot name="toolbar-actions" />
<Button
:label="newLabel" icon="pi pi-plus" severity="primary"
@click="$router.push(newRoute)"
/>
</div>
<Button :label="newLabel" icon="pi pi-plus" severity="primary" @click="$router.push(newRoute)" />
</div>
<div class="flex gap-2 mb-3 items-center flex-wrap">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="search" placeholder="Search by name…" class="w-64" />
</IconField>
<!-- Toolbar -->
<div class="wp-toolbar">
<InputGroup class="wp-toolbar__search">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="search" placeholder="Search by name, id, tag…" />
</InputGroup>
<Button
:label="activeFilterCount ? `Filters (${activeFilterCount})` : 'Filters'"
icon="pi pi-filter"
:severity="activeFilterCount ? 'primary' : 'secondary'"
outlined size="small"
:severity="filterPanelOpen ? 'primary' : 'secondary'"
:outlined="filterPanelOpen"
text
size="small"
aria-label="Filters"
@click="filterPanelOpen = !filterPanelOpen"
/>
>
<span class="flex items-center gap-1">
<i class="pi pi-filter" />
<span>Filters</span>
<span v-if="activeFilterCount > 0" class="wp-filter-count">{{ activeFilterCount }}</span>
</span>
</Button>
<Select
:model-value="filter.sortBy ?? 'updated-desc'"
:options="SORT_OPTIONS" option-label="label" option-value="value"
@@ -159,35 +260,43 @@ function confirmBulkDelete() {
</span>
</template>
</Select>
<span class="ml-auto text-xs text-wp-text2">{{ items.length }} item(s)</span>
<span class="wp-toolbar__count">
{{ filteredItems.length }} / {{ items.length }} items
</span>
</div>
<div v-if="filterPanelOpen" class="filter-panel mb-3">
<slot name="filter-panel" :filter="filter" :emit-fetch="() => emit('fetch')" />
<!-- Filter panel -->
<div v-if="filterPanelOpen" class="wp-filter-panel">
<slot
name="filter-panel"
:filter="filter"
:emit-fetch="() => emit('fetch')"
/>
<div v-if="extraFilters?.length" class="wp-filter-extras">
<span class="wp-filter-extras__label">Syntax</span>
<div class="wp-filter-extras__chips">
<button
v-for="f in extraFilters" :key="f.key" type="button"
class="wp-extra-chip"
:data-active="activeExtras.includes(f.key) ? '' : null"
@click="activeExtras.includes(f.key)
? removeExtra(f.key)
: (activeExtras = [...activeExtras, f.key])"
>
{{ f.label }}
<span class="wp-extra-chip__count">
{{ items.filter((x) => f.check(x as unknown as T)).length }}
</span>
</button>
</div>
</div>
<div v-if="hasActiveFilters" class="wp-filter-panel__footer">
<Button label="Clear filters" icon="pi pi-times" text size="small" @click="clearFilters" />
</div>
</div>
<div v-if="activeFilterCount" class="flex gap-2 mb-3 items-center flex-wrap">
<span class="text-xs text-wp-text2">Active:</span>
<span v-if="filter.q" class="filter-chip filter-chip--search">
name: {{ filter.q }}
<i class="pi pi-times" @click="search = ''" />
</span>
<span v-if="filter.favorites" class="filter-chip filter-chip--favorite">
favorites only
<i class="pi pi-times" @click="filter.favorites = false; emit('fetch')" />
</span>
<span v-if="filter.category" class="filter-chip filter-chip--category">
category: {{ filter.category }}
<i class="pi pi-times" @click="filter.category = undefined; emit('fetch')" />
</span>
<span v-for="t in filter.tags" :key="t" class="filter-chip filter-chip--tag">
tag: {{ t }}
<i class="pi pi-times" @click="filter.tags = filter.tags!.filter((x) => x !== t); emit('fetch')" />
</span>
<Button label="Clear all" text size="small" @click="clearFilters" />
</div>
<div v-if="selected.length" class="flex justify-between items-center bulk-bar mb-3">
<!-- Bulk-select bar (above active-filter chips when present) -->
<div v-if="selected.length" class="bulk-bar">
<span class="text-sm">
<strong class="text-wp-accent">{{ selected.length }}</strong> selected
</span>
@@ -200,11 +309,63 @@ function confirmBulkDelete() {
</div>
</div>
<!-- Active-filter chips row -->
<div v-if="hasActiveFilters" class="wp-active-filters">
<span class="text-xs text-wp-text2">Active:</span>
<span v-if="filter.q" class="filter-chip filter-chip--search">
name: {{ filter.q }}
<button type="button" class="filter-chip__close" aria-label="Clear search" @click="search = ''">
<i class="pi pi-times" />
</button>
</span>
<span v-if="filter.favorites" class="filter-chip filter-chip--favorite">
Favorites only
<button
type="button" class="filter-chip__close" aria-label="Clear favorites filter"
@click="filter.favorites = false; emit('fetch')"
>
<i class="pi pi-times" />
</button>
</span>
<span v-if="filter.category" class="filter-chip filter-chip--category">
category: {{ filter.category }}
<button
type="button" class="filter-chip__close" aria-label="Clear category filter"
@click="filter.category = undefined; emit('fetch')"
>
<i class="pi pi-times" />
</button>
</span>
<span v-for="t in filter.tags ?? []" :key="t" class="filter-chip filter-chip--tag">
tag: {{ t }}
<button
type="button" class="filter-chip__close"
:aria-label="`Remove tag ${t}`"
@click="removeTag(t)"
>
<i class="pi pi-times" />
</button>
</span>
<span
v-for="k in activeExtras" :key="k"
class="filter-chip filter-chip--extra"
>
{{ extraFilters?.find((f) => f.key === k)?.label ?? k }}
<button
type="button" class="filter-chip__close"
:aria-label="`Remove ${k} filter`"
@click="removeExtra(k)"
>
<i class="pi pi-times" />
</button>
</span>
<Button label="Clear all" text size="small" class="ml-1" @click="clearFilters" />
</div>
<DataTable
v-model:selection="selected"
v-model:expandedRows="expandedRows"
:value="sortedItems" :loading="loading" data-key="id"
:rows="20" :paginator="sortedItems.length > 20"
:value="pagedItems" :loading="loading" data-key="id"
:row-class="rowClass"
>
<slot name="columns" />
@@ -212,22 +373,194 @@ function confirmBulkDelete() {
<slot name="expansion" v-bind="slotProps" />
</template>
<template #empty>
<div class="text-center p-8">
<img :src="faviconUrl" alt="" class="mx-auto mb-3 opacity-70" style="width:64px;height:64px" />
<p class="text-wp-text2 mb-3">{{ emptyMessage }}</p>
<Button :label="newLabel" icon="pi pi-plus" severity="primary" @click="$router.push(newRoute)" />
<div class="wp-empty">
<template v-if="hasActiveFilters">
<div class="wp-empty__icon">
<i class="pi pi-search" />
</div>
<p class="text-wp-text font-semibold m-0">No matches</p>
<p class="text-wp-text2 m-0 mt-1">Try clearing filters, or change the search query.</p>
<Button label="Clear filters" icon="pi pi-times" text size="small" class="mt-2" @click="clearFilters" />
</template>
<template v-else>
<img :src="faviconUrl" alt="" class="wp-empty__logo" />
<p class="text-wp-text2 mb-3">{{ emptyMessage }}</p>
<Button :label="newLabel" icon="pi pi-plus" severity="primary" @click="$router.push(newRoute)" />
</template>
</div>
</template>
</DataTable>
<!-- Pagination footer -->
<div v-if="showPagination" class="wp-pagination">
<span class="wp-pagination__range">
{{ pageStart + 1 }}–{{ pageEnd }} of {{ filteredItems.length }}
</span>
<div class="wp-spacer" />
<span class="wp-pagination__per-page-label">Per page</span>
<Select
v-model="pageSize"
:options="PAGE_SIZE_OPTIONS" option-label="label" option-value="value"
size="small" class="wp-pagination__per-page"
aria-label="Items per page"
/>
<div class="wp-pager">
<Button
icon="pi pi-angle-double-left" text size="small"
aria-label="First page"
:disabled="safePage === 1"
@click="goToPage(1)"
/>
<Button
icon="pi pi-angle-left" text size="small"
aria-label="Previous page"
:disabled="safePage === 1"
@click="goToPage(safePage - 1)"
/>
<span class="wp-pager__label">Page {{ safePage }} / {{ totalPages }}</span>
<Button
icon="pi pi-angle-right" text size="small"
aria-label="Next page"
:disabled="safePage === totalPages"
@click="goToPage(safePage + 1)"
/>
<Button
icon="pi pi-angle-double-right" text size="small"
aria-label="Last page"
:disabled="safePage === totalPages"
@click="goToPage(totalPages)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.filter-panel {
.wp-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.wp-page__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.wp-page__title-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.wp-page__title {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
.wp-page__subtitle {
color: var(--wp-text-muted);
font-size: 12.5px;
margin: 0;
max-width: 720px;
}
.wp-page__actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.wp-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wp-toolbar__search {
flex: 1;
min-width: 240px;
max-width: 440px;
}
.wp-toolbar__search :deep(.p-inputtext) {
height: 38px;
}
.wp-toolbar__count {
font-size: 11.5px;
color: var(--wp-text-dim);
margin-left: auto;
}
.wp-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10.5px;
background: var(--wp-accent);
color: #fff;
margin-left: 2px;
}
.wp-filter-panel {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.wp-filter-panel__footer {
display: flex;
justify-content: flex-end;
}
.wp-filter-extras__label {
display: block;
font-size: 11px;
color: var(--wp-text-muted);
margin-bottom: 6px;
}
.wp-filter-extras__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wp-extra-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 11px;
font-size: 11.5px;
border: 1px solid var(--wp-border);
background: var(--wp-bg2);
color: var(--wp-text-muted);
cursor: pointer;
transition: all 0.12s ease;
}
.wp-extra-chip:hover {
background: var(--wp-bg3);
color: var(--wp-text);
}
.wp-extra-chip[data-active] {
background: var(--wp-accent-glow);
border-color: var(--wp-accent);
color: var(--wp-accent);
}
.wp-extra-chip__count {
font-size: 10.5px;
color: var(--wp-text-dim);
margin-left: 2px;
}
.wp-active-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.filter-chip {
display: inline-flex;
@@ -243,14 +576,105 @@ function confirmBulkDelete() {
.filter-chip--favorite { color: var(--wp-amber); border-color: var(--wp-amber); }
.filter-chip--category { color: var(--wp-violet); border-color: var(--wp-violet); background: var(--wp-violet-bg); }
.filter-chip--tag { color: var(--wp-teal); border-color: var(--wp-teal); background: var(--wp-teal-bg); }
.filter-chip i { cursor: pointer; opacity: 0.7; }
.filter-chip i:hover { opacity: 1; }
.filter-chip--extra { color: var(--wp-accent); border-color: var(--wp-accent); background: var(--wp-accent-glow); }
.filter-chip__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
border-radius: 999px;
}
.filter-chip__close:hover { opacity: 1; }
.filter-chip__close:focus-visible {
outline: 1px solid currentColor;
outline-offset: 1px;
}
.filter-chip__close .pi { font-size: 10px; }
.bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--wp-accent-glow);
border: 1px solid var(--wp-accent);
border-radius: var(--wp-radius);
padding: 8px 12px;
}
.wp-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 32px 16px;
text-align: center;
}
.wp-empty__icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 999px;
background: var(--wp-bg3);
color: var(--wp-text-muted);
font-size: 18px;
margin-bottom: 6px;
}
.wp-empty__logo {
width: 64px;
height: 64px;
margin-bottom: 12px;
opacity: 0.7;
}
.wp-pagination {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
}
.wp-spacer { flex: 1; }
.wp-pagination__range {
font-size: 12px;
color: var(--wp-text-dim);
font-variant-numeric: tabular-nums;
}
.wp-pagination__per-page-label {
font-size: 11.5px;
color: var(--wp-text-dim);
}
.wp-pagination__per-page {
width: 92px;
}
.wp-pager {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: 7px;
padding: 2px;
}
.wp-pager__label {
padding: 0 10px;
font-size: 11.5px;
color: var(--wp-text-muted);
font-variant-numeric: tabular-nums;
min-width: 90px;
text-align: center;
}
:deep(.wp-row-favorite) {
border-left: 2px solid var(--wp-amber);
}
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-ZcA3OFc-.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-D_KNk_uE.css">
<script type="module" crossorigin src="/wp/assets/manager-5RQ_dRj0.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DSYIIUG3.css">
</head>
<body>
<div id="app"></div>