feat(spa): categories color picker (hex + swatch + presets) + sticky-header scroll

This commit is contained in:
DumiFlex
2026-04-27 04:21:24 +03:00
parent 02a526a53c
commit f18be1eaff
4 changed files with 817 additions and 68 deletions
+105
View File
@@ -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(/^#/) }),
);
});
});
+73
View File
@@ -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);
});
});
+233
View File
@@ -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>
+406 -68
View File
@@ -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>