feat(spa): categories color picker (hex + swatch + presets) + sticky-header scroll
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PrimeVue from "primevue/config";
|
||||
import ToastService from "primevue/toastservice";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
categories: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
modules: {
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
},
|
||||
},
|
||||
// Re-export ApiError so the SFC's `import { ApiError } from "../api/client"`
|
||||
// resolves under the mock.
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import Categories from "../views/Categories.vue";
|
||||
|
||||
const apiCat = api.categories as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiCat).forEach((fn) => fn.mockReset());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("Categories.vue", () => {
|
||||
it("renders the page header and 'New category' card", async () => {
|
||||
apiCat.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Categories");
|
||||
expect(wrap.text()).toContain("New category");
|
||||
// Empty-state row.
|
||||
expect(wrap.text()).toContain("No categories yet.");
|
||||
});
|
||||
|
||||
it("renders existing rows from the mocked store", async () => {
|
||||
apiCat.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: "cat_a", name: "Style", color: "#a78bfa", icon: null, sort_order: 0 },
|
||||
{ id: "cat_b", name: "Lighting", color: "#22d3ee", icon: null, sort_order: 1 },
|
||||
],
|
||||
});
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="cat-row-cat_a"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="cat-row-cat_b"]').exists()).toBe(true);
|
||||
expect(wrap.text()).toContain("Style");
|
||||
expect(wrap.text()).toContain("Lighting");
|
||||
});
|
||||
|
||||
it("derives module counts from the moduleStore", async () => {
|
||||
apiCat.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: "cat_a", name: "Style", color: "#a78bfa", icon: null, sort_order: 0 },
|
||||
],
|
||||
});
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: "wc_1", type: "wildcard", category_id: "cat_a", name: "x", tags: [] },
|
||||
{ id: "wc_2", type: "wildcard", category_id: "cat_a", name: "y", tags: [] },
|
||||
{ id: "wc_3", type: "wildcard", category_id: null, name: "z", tags: [] },
|
||||
],
|
||||
total: 3,
|
||||
});
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.get('[data-test="cat-count-cat_a"]').text()).toBe("2");
|
||||
});
|
||||
|
||||
it("Add button calls api.categories.create with name + color", async () => {
|
||||
apiCat.list.mockResolvedValue({ items: [] });
|
||||
apiCat.create.mockResolvedValue({
|
||||
id: "cat_new", name: "Mood", color: "#fbbf24", icon: null, sort_order: 0,
|
||||
});
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [PrimeVue, ToastService] },
|
||||
});
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="new-cat-name"]').setValue("Mood");
|
||||
await wrap.get('[data-test="add-category-btn"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiCat.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Mood", color: expect.stringMatching(/^#/) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import PrimeVue from "primevue/config";
|
||||
import ColorPicker from "../components/ColorPicker.vue";
|
||||
|
||||
function makeWrapper(modelValue = "#7c3aed") {
|
||||
return mount(ColorPicker, {
|
||||
props: { modelValue },
|
||||
global: { plugins: [PrimeVue] },
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover(wrapper: ReturnType<typeof makeWrapper>) {
|
||||
await wrapper.get('[data-test="color-swatch"]').trigger("click");
|
||||
}
|
||||
|
||||
describe("ColorPicker.vue", () => {
|
||||
it("renders the swatch with the bound color", () => {
|
||||
const wrap = makeWrapper("#ff0000");
|
||||
const swatch = wrap.get('[data-test="color-swatch"]');
|
||||
const style = swatch.attributes("style") ?? "";
|
||||
// jsdom normalizes hex to rgb(...) in style strings.
|
||||
expect(style.toLowerCase()).toMatch(/rgb\(255,\s*0,\s*0\)|#ff0000/);
|
||||
});
|
||||
|
||||
it("clicking a preset emits update:modelValue with that hex", async () => {
|
||||
const wrap = makeWrapper("#000000");
|
||||
await openPopover(wrap);
|
||||
const presets = wrap.findAll('[data-test="color-preset"]');
|
||||
expect(presets.length).toBe(12);
|
||||
await presets[0].trigger("click");
|
||||
const events = wrap.emitted("update:modelValue");
|
||||
expect(events).toBeTruthy();
|
||||
// First default preset is "#7c3aed".
|
||||
expect(events?.[0]).toEqual(["#7c3aed"]);
|
||||
});
|
||||
|
||||
it("invalid hex input does NOT emit update:modelValue", async () => {
|
||||
const wrap = makeWrapper("#000000");
|
||||
await openPopover(wrap);
|
||||
const hexInput = wrap.get('[data-test="color-hex-input"]');
|
||||
await hexInput.setValue("abc");
|
||||
expect(wrap.emitted("update:modelValue")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("valid hex input emits update:modelValue with that hex", async () => {
|
||||
const wrap = makeWrapper("#000000");
|
||||
await openPopover(wrap);
|
||||
const hexInput = wrap.get('[data-test="color-hex-input"]');
|
||||
await hexInput.setValue("#ff0000");
|
||||
const events = wrap.emitted("update:modelValue");
|
||||
expect(events).toBeTruthy();
|
||||
expect(events?.[events!.length - 1]).toEqual(["#ff0000"]);
|
||||
});
|
||||
|
||||
it("uses provided ariaLabel on the swatch button", () => {
|
||||
const wrap = mount(ColorPicker, {
|
||||
props: { modelValue: "#abcdef", ariaLabel: "Pick brand color" },
|
||||
global: { plugins: [PrimeVue] },
|
||||
});
|
||||
expect(wrap.get('[data-test="color-swatch"]').attributes("aria-label"))
|
||||
.toBe("Pick brand color");
|
||||
});
|
||||
|
||||
it("supports custom presets prop", async () => {
|
||||
const wrap = mount(ColorPicker, {
|
||||
props: { modelValue: "#000000", presets: ["#111111", "#222222"] },
|
||||
global: { plugins: [PrimeVue] },
|
||||
});
|
||||
await openPopover(wrap);
|
||||
expect(wrap.findAll('[data-test="color-preset"]').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import InputText from "primevue/inputtext";
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
presets?: string[];
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
presets: () => [
|
||||
"#7c3aed", "#a78bfa", "#22d3ee", "#34d399",
|
||||
"#fbbf24", "#f472b6", "#fb7185", "#ef4444",
|
||||
"#6366f1", "#10b981", "#f59e0b", "#8b5cf6",
|
||||
],
|
||||
ariaLabel: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const open = ref(false);
|
||||
const root = ref<HTMLDivElement | null>(null);
|
||||
const hexDraft = ref(props.modelValue);
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
hexDraft.value = v;
|
||||
});
|
||||
|
||||
const swatchColor = computed(() => (HEX_RE.test(props.modelValue) ? props.modelValue : "#ffffff"));
|
||||
const nativeColor = computed(() => (HEX_RE.test(props.modelValue) ? props.modelValue : "#a78bfa"));
|
||||
|
||||
const swatchAriaLabel = computed(
|
||||
() => props.ariaLabel ?? `Pick color, current ${props.modelValue}`,
|
||||
);
|
||||
|
||||
function toggleOpen(event: Event) {
|
||||
event.stopPropagation();
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
hexDraft.value = props.modelValue;
|
||||
// Listen for outside clicks once the popover is shown.
|
||||
setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
}, 0);
|
||||
} else {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}
|
||||
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const el = root.value;
|
||||
if (el && !el.contains(e.target as Node)) close();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreset(preset: string) {
|
||||
emit("update:modelValue", preset);
|
||||
close();
|
||||
}
|
||||
|
||||
function onHexInput(value: string) {
|
||||
hexDraft.value = value;
|
||||
if (HEX_RE.test(value)) {
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
}
|
||||
|
||||
function onNativeInput(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
if (HEX_RE.test(value)) {
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
});
|
||||
|
||||
function isActivePreset(preset: string): boolean {
|
||||
return preset.toLowerCase() === props.modelValue.toLowerCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="wp-color-picker" @keydown="handleKeydown">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-color-picker__swatch"
|
||||
:style="{ background: swatchColor }"
|
||||
:aria-label="swatchAriaLabel"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="dialog"
|
||||
data-test="color-swatch"
|
||||
@click="toggleOpen"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
class="wp-color-picker__popover"
|
||||
role="dialog"
|
||||
:aria-label="swatchAriaLabel"
|
||||
>
|
||||
<div class="wp-color-picker__row">
|
||||
<InputText
|
||||
:model-value="hexDraft"
|
||||
maxlength="7"
|
||||
aria-label="Hex color value"
|
||||
class="wp-color-picker__hex font-mono"
|
||||
data-test="color-hex-input"
|
||||
@update:model-value="(v) => onHexInput(String(v ?? ''))"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
:value="nativeColor"
|
||||
aria-label="Native color picker"
|
||||
class="wp-color-picker__native"
|
||||
data-test="color-native"
|
||||
@input="onNativeInput"
|
||||
>
|
||||
</div>
|
||||
<div class="wp-color-picker__palette" role="listbox" aria-label="Preset colors">
|
||||
<button
|
||||
v-for="preset in props.presets"
|
||||
:key="preset"
|
||||
type="button"
|
||||
class="wp-color-picker__chip"
|
||||
:style="{ background: preset }"
|
||||
:title="preset"
|
||||
:data-active="isActivePreset(preset)"
|
||||
:aria-label="`Use preset ${preset}`"
|
||||
data-test="color-preset"
|
||||
@click="selectPreset(preset)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-color-picker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wp-color-picker__swatch {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--wp-border-strong);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
transition: transform 0.08s, box-shadow 0.12s;
|
||||
}
|
||||
.wp-color-picker__swatch:hover { transform: scale(1.05); }
|
||||
.wp-color-picker__swatch:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--wp-bg-1), 0 0 0 4px var(--wp-accent-500);
|
||||
}
|
||||
|
||||
.wp-color-picker__popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid var(--wp-border-strong);
|
||||
border-radius: var(--wp-radius);
|
||||
box-shadow: var(--wp-shadow-lg);
|
||||
padding: 10px;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wp-color-picker__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wp-color-picker__hex {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wp-color-picker__native {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wp-color-picker__palette {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wp-color-picker__chip {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--wp-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.08s, box-shadow 0.12s;
|
||||
}
|
||||
.wp-color-picker__chip:hover { transform: scale(1.08); }
|
||||
.wp-color-picker__chip[data-active="true"] {
|
||||
box-shadow: 0 0 0 2px var(--wp-bg-2), 0 0 0 4px var(--wp-accent-500);
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import DataTable from "primevue/datatable";
|
||||
import Column from "primevue/column";
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import Card from "primevue/card";
|
||||
import Button from "primevue/button";
|
||||
import InputText from "primevue/inputtext";
|
||||
import ColorPicker from "primevue/colorpicker";
|
||||
import Tag from "primevue/tag";
|
||||
import Dialog from "primevue/dialog";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { ApiError } from "../api/client";
|
||||
import type { CategoryRow } from "../api/types";
|
||||
import ColorPicker from "../components/ColorPicker.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
|
||||
const store = useCategoryStore();
|
||||
const moduleStore = useModuleStore();
|
||||
const toast = useToast();
|
||||
const newName = ref("");
|
||||
const newColor = ref("a970ff");
|
||||
|
||||
onMounted(async () => { await store.fetchAll(); });
|
||||
const newName = ref("");
|
||||
const newColor = ref("#a78bfa");
|
||||
|
||||
// Inline-edit state for the name column.
|
||||
const editingId = ref<string | null>(null);
|
||||
const editingName = ref("");
|
||||
|
||||
// Edit-modal state for the pencil action.
|
||||
const modalOpen = ref(false);
|
||||
const modalRow = ref<CategoryRow | null>(null);
|
||||
const modalName = ref("");
|
||||
const modalColor = ref("#a78bfa");
|
||||
|
||||
// Module counts keyed by category id, derived from the loaded module list.
|
||||
const countsByCategory = computed(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const m of moduleStore.items) {
|
||||
if (m.category_id) {
|
||||
map.set(m.category_id, (map.get(m.category_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([store.fetchAll(), moduleStore.fetchAll()]);
|
||||
});
|
||||
|
||||
function moduleCount(row: CategoryRow): number {
|
||||
return countsByCategory.value.get(row.id) ?? 0;
|
||||
}
|
||||
|
||||
// CategoryRow doesn't formally include updated_at, but the backend may
|
||||
// include it; expose a helper that reads it defensively for the column.
|
||||
function rowUpdatedAt(row: CategoryRow): string | undefined {
|
||||
return (row as CategoryRow & { updated_at?: string }).updated_at;
|
||||
}
|
||||
|
||||
function reportError(e: unknown, summary: string) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e);
|
||||
toast.add({ severity: "error", summary, detail: msg, life: 4000 });
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const name = newName.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await store.create({ name, color: `#${newColor.value}` });
|
||||
await store.create({ name, color: newColor.value });
|
||||
newName.value = "";
|
||||
newColor.value = "#a78bfa";
|
||||
toast.add({ severity: "success", summary: "Created", life: 2000 });
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e);
|
||||
toast.add({ severity: "error", summary: "Failed", detail: msg, life: 4000 });
|
||||
reportError(e, "Failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,82 +78,377 @@ async function remove(row: CategoryRow) {
|
||||
await store.remove(row.id);
|
||||
toast.add({ severity: "success", summary: "Deleted", life: 2000 });
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e);
|
||||
toast.add({ severity: "error", summary: "Delete failed", detail: msg, life: 4000 });
|
||||
reportError(e, "Delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function changeColor(row: CategoryRow, value: string) {
|
||||
if (value === row.color) return;
|
||||
try {
|
||||
await store.update(row.id, { color: value });
|
||||
} catch (e) {
|
||||
reportError(e, "Update failed");
|
||||
}
|
||||
}
|
||||
|
||||
function startInlineEdit(row: CategoryRow) {
|
||||
editingId.value = row.id;
|
||||
editingName.value = row.name;
|
||||
// Focus the input on the next tick once it has rendered.
|
||||
nextTick(() => {
|
||||
const el = document.querySelector<HTMLInputElement>(
|
||||
`[data-test="cat-name-input-${row.id}"]`,
|
||||
);
|
||||
el?.focus();
|
||||
el?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function cancelInlineEdit() {
|
||||
editingId.value = null;
|
||||
editingName.value = "";
|
||||
}
|
||||
|
||||
async function commitInlineEdit(row: CategoryRow) {
|
||||
const name = editingName.value.trim();
|
||||
if (!name || name === row.name) {
|
||||
cancelInlineEdit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.update(row.id, { name });
|
||||
toast.add({ severity: "success", summary: "Renamed", life: 1500 });
|
||||
} catch (e) {
|
||||
reportError(e, "Rename failed");
|
||||
} finally {
|
||||
cancelInlineEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(row: CategoryRow) {
|
||||
modalRow.value = row;
|
||||
modalName.value = row.name;
|
||||
modalColor.value = row.color ?? "#a78bfa";
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
modalOpen.value = false;
|
||||
modalRow.value = null;
|
||||
}
|
||||
|
||||
async function saveEditModal() {
|
||||
if (!modalRow.value) return;
|
||||
const id = modalRow.value.id;
|
||||
const name = modalName.value.trim();
|
||||
if (!name) {
|
||||
toast.add({ severity: "warn", summary: "Name is required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.update(id, { name, color: modalColor.value });
|
||||
toast.add({ severity: "success", summary: "Saved", life: 1500 });
|
||||
closeEditModal();
|
||||
} catch (e) {
|
||||
reportError(e, "Save failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 text-wp-text max-w-3xl">
|
||||
<h1 class="text-xl font-semibold m-0">Categories</h1>
|
||||
<p class="text-sm text-wp-text2 m-0 mt-1 mb-6">
|
||||
Tag groups for filtering modules. Color appears on category chips in the DataTable.
|
||||
</p>
|
||||
<div class="categories-page">
|
||||
<header class="categories-page__header">
|
||||
<h1 class="text-xl font-semibold m-0 text-wp-text">Categories</h1>
|
||||
<p class="text-sm text-wp-text2 m-0 mt-1">
|
||||
Group modules into categories. Each category has a name and a color used as a chip in lists.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="flex gap-2 mb-6 items-end">
|
||||
<div class="flex-1">
|
||||
<label for="cat-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="cat-name" v-model="newName" placeholder="Style" class="w-full" @keydown.enter="add" />
|
||||
</div>
|
||||
<div class="color-input-group">
|
||||
<ColorPicker v-model="newColor" class="color-input-group__picker" />
|
||||
<InputText
|
||||
v-model="newColor" placeholder="hex" maxlength="6"
|
||||
aria-label="Hex color code"
|
||||
class="color-input-group__hex font-mono text-xs"
|
||||
@keydown.enter="add"
|
||||
/>
|
||||
</div>
|
||||
<Button label="Add" icon="pi pi-plus" severity="primary" data-test="add-category-btn" @click="add" />
|
||||
<Card class="categories-page__new">
|
||||
<template #title>
|
||||
<span class="text-sm font-medium">New category</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="categories-new-row">
|
||||
<InputText
|
||||
id="cat-name"
|
||||
v-model="newName"
|
||||
placeholder="Style"
|
||||
class="flex-1"
|
||||
aria-label="New category name"
|
||||
data-test="new-cat-name"
|
||||
@keydown.enter="add"
|
||||
/>
|
||||
<ColorPicker
|
||||
v-model="newColor"
|
||||
aria-label="New category color"
|
||||
/>
|
||||
<Button
|
||||
label="Add"
|
||||
icon="pi pi-plus"
|
||||
severity="primary"
|
||||
data-test="add-category-btn"
|
||||
@click="add"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<div class="categories-page__table-wrap">
|
||||
<table class="categories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="categories-table__col-color">
|
||||
Color
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th class="categories-table__col-count">
|
||||
Modules
|
||||
</th>
|
||||
<th class="categories-table__col-updated">
|
||||
Updated
|
||||
</th>
|
||||
<th class="categories-table__col-actions">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="store.items.length === 0">
|
||||
<td colspan="5" class="categories-table__empty">
|
||||
No categories yet.
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="row in store.items"
|
||||
:key="row.id"
|
||||
class="wp-cat-row"
|
||||
:data-test="`cat-row-${row.id}`"
|
||||
>
|
||||
<td>
|
||||
<ColorPicker
|
||||
:model-value="row.color || '#a78bfa'"
|
||||
:aria-label="`Edit color for ${row.name}`"
|
||||
@update:model-value="(v) => changeColor(row, v)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="editingId === row.id">
|
||||
<InputText
|
||||
v-model="editingName"
|
||||
:data-test="`cat-name-input-${row.id}`"
|
||||
aria-label="Edit category name"
|
||||
class="w-full"
|
||||
@keydown.enter="commitInlineEdit(row)"
|
||||
@keydown.esc="cancelInlineEdit"
|
||||
@blur="commitInlineEdit(row)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="categories-table__name"
|
||||
:data-test="`cat-name-${row.id}`"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="`Edit ${row.name}`"
|
||||
@dblclick="startInlineEdit(row)"
|
||||
@keydown.enter.prevent="startInlineEdit(row)"
|
||||
>
|
||||
<Tag
|
||||
:value="row.name"
|
||||
:style="{ background: row.color || 'var(--wp-bg-3)', color: '#fff' }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<span class="categories-table__count" :data-test="`cat-count-${row.id}`">
|
||||
{{ moduleCount(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-wp-text2 text-xs">
|
||||
<RelativeDate
|
||||
v-if="rowUpdatedAt(row)"
|
||||
:value="rowUpdatedAt(row)!"
|
||||
/>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="categories-table__actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
:aria-label="`Edit ${row.name}`"
|
||||
:data-test="`cat-edit-${row.id}`"
|
||||
@click="openEditModal(row)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
:aria-label="`Delete ${row.name}`"
|
||||
:data-test="`cat-delete-${row.id}`"
|
||||
@click="remove(row)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DataTable :value="store.items" data-key="id" :loading="store.loading">
|
||||
<Column field="name" header="Name" />
|
||||
<Column field="color" header="Color" header-style="width:9rem">
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2 align-middle"
|
||||
:style="{ background: data.color || 'transparent', border: '1px solid var(--wp-border)' }"
|
||||
aria-hidden="true"
|
||||
<Dialog
|
||||
v-model:visible="modalOpen"
|
||||
modal
|
||||
header="Edit category"
|
||||
:style="{ width: '24rem' }"
|
||||
:draggable="false"
|
||||
@hide="closeEditModal"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1" for="modal-cat-name">Name</label>
|
||||
<InputText
|
||||
id="modal-cat-name"
|
||||
v-model="modalName"
|
||||
class="w-full"
|
||||
data-test="modal-cat-name"
|
||||
@keydown.enter="saveEditModal"
|
||||
/>
|
||||
<span class="font-mono text-xs">{{ data.color || "—" }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Actions" header-style="width:6rem">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger"
|
||||
aria-label="Delete category" @click="remove(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<div class="text-center p-6 text-wp-text2">No categories yet.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Color</label>
|
||||
<ColorPicker v-model="modalColor" aria-label="Edit category color" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" text @click="closeEditModal" />
|
||||
<Button
|
||||
label="Save"
|
||||
icon="pi pi-check"
|
||||
severity="primary"
|
||||
data-test="modal-save"
|
||||
@click="saveEditModal"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.color-input-group {
|
||||
.categories-page {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 1.25rem;
|
||||
gap: 1rem;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
|
||||
.categories-page__header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.categories-page__new {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.categories-page__new :deep(.p-card-body) {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.categories-page__new :deep(.p-card-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.categories-new-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.categories-new-row > .flex-1 { flex: 1 1 auto; }
|
||||
|
||||
/* Sticky-header scroll wrap. The container claims the remaining space so
|
||||
* the header + new-category card stay anchored when categories grow.
|
||||
*/
|
||||
.categories-page__table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
overflow: hidden;
|
||||
height: 2rem;
|
||||
background: var(--wp-bg-1);
|
||||
}
|
||||
.color-input-group__picker :deep(.p-colorpicker-preview) {
|
||||
width: 2rem;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
.categories-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.color-input-group__hex {
|
||||
width: 6rem;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
outline: none !important;
|
||||
background: transparent;
|
||||
.categories-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--wp-bg-2);
|
||||
border-bottom: 1px solid var(--wp-border-strong);
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--wp-text-muted);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.categories-table tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.categories-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.categories-table__col-color { width: 80px; }
|
||||
.categories-table__col-count { width: 6rem; }
|
||||
.categories-table__col-updated { width: 8rem; }
|
||||
.categories-table__col-actions { width: 7rem; text-align: right; }
|
||||
|
||||
.categories-table__col-actions { text-align: right; }
|
||||
.categories-table__actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.categories-table__count {
|
||||
display: inline-block;
|
||||
min-width: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
text-align: center;
|
||||
background: var(--wp-bg-3);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
|
||||
.categories-table__empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--wp-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.categories-table__name {
|
||||
cursor: text;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
}
|
||||
.categories-table__name:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--wp-accent-500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.color-input-group__hex:focus { box-shadow: none !important; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user