diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..6cd6c1bd5 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Added + +- Kanban boards grouped by status now tint columns with each status's configured color, including swimlane boards. diff --git a/src/bases/KanbanView.ts b/src/bases/KanbanView.ts index 3581fe337..9ed43fc03 100644 --- a/src/bases/KanbanView.ts +++ b/src/bases/KanbanView.ts @@ -80,6 +80,7 @@ import { shouldRenderKanbanColumn, } from "./kanbanGrouping"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; +import { isSupportedColorValue, normalizeThemeColor } from "../utils/themeColors"; import { processVaultFrontMatter } from "../services/VaultMutationService"; const tasknotesLogger = createTaskNotesLogger({ tag: "Bases/KanbanView" }); @@ -1369,6 +1370,35 @@ export class KanbanView extends BasesViewBase { ); } + private applyStatusColor( + element: HTMLElement, + groupKey: string, + groupByPropertyId: string | null, + modifierClass: string + ): void { + if (!groupByPropertyId || !this.isStatusGroupingProperty(groupByPropertyId)) return; + + const status = this.findStatusConfigForGroupKey(groupKey); + if (!status || !isSupportedColorValue(status.color)) return; + + const color = normalizeThemeColor(status.color); + if (!color || !this.isRenderableStatusColor(element, color)) return; + + element.addClass(modifierClass); + element.style.setProperty("--tn-kanban-status-color", color); + } + + private isRenderableStatusColor(element: HTMLElement, color: string): boolean { + const css = element.ownerDocument.defaultView?.CSS; + if (css && typeof css.supports === "function") { + return css.supports("color", color); + } + + const probe = element.ownerDocument.win.createSpan(); + probe.style.color = color; + return probe.style.color !== ""; + } + private isUnknownStatusGroup(groupKey: string, groupByPropertyId: string | null): boolean { if (!groupByPropertyId || !this.isStatusGroupingProperty(groupByPropertyId)) { return false; @@ -1554,6 +1584,12 @@ export class KanbanView extends BasesViewBase { }); headerCell.setAttribute("draggable", "true"); headerCell.setAttribute("data-column-key", columnKey); + this.applyStatusColor( + headerCell, + columnKey, + groupByPropertyId, + "kanban-view__column-header-cell--status-colored" + ); const isUnknownStatusColumn = this.isUnknownStatusGroup(columnKey, groupByPropertyId); if (isUnknownStatusColumn) { this.markUnknownStatusColumnHeader(headerCell, columnKey); @@ -1629,6 +1665,12 @@ export class KanbanView extends BasesViewBase { "data-swimlane": swimLaneKey, }, }); + this.applyStatusColor( + cell, + columnKey, + groupByPropertyId, + "kanban-view__swimlane-column--status-colored" + ); if (isUnknownStatusColumn) { this.markUnknownStatusColumn(cell, columnKey); } @@ -1693,6 +1735,12 @@ export class KanbanView extends BasesViewBase { column.className = "kanban-view__column"; column.style.width = `${this.columnWidth}px`; column.setAttribute("data-group", groupKey); + this.applyStatusColor( + column, + groupKey, + groupByPropertyId, + "kanban-view__column--status-colored" + ); const isUnknownStatusColumn = this.isUnknownStatusGroup(groupKey, groupByPropertyId); if (isUnknownStatusColumn) { this.markUnknownStatusColumn(column, groupKey); diff --git a/styles/bases-views.css b/styles/bases-views.css index 0a12253a2..236fdbc40 100644 --- a/styles/bases-views.css +++ b/styles/bases-views.css @@ -527,6 +527,11 @@ body.is-mobile .tn-bases-kanban-column { border-right: 1px solid var(--tn-border-color); } +.kanban-view__column-header-cell--status-colored { + background: color-mix(in srgb, var(--tn-kanban-status-color) 12%, var(--tn-bg-secondary)); + border-right-color: color-mix(in srgb, var(--tn-kanban-status-color) 38%, var(--tn-border-color)); +} + .kanban-view__column-header-cell:active { cursor: grabbing; } @@ -569,6 +574,11 @@ body.is-mobile .tn-bases-kanban-column { overflow: hidden; } +.kanban-view__swimlane-column--status-colored { + background: color-mix(in srgb, var(--tn-kanban-status-color) 6%, var(--tn-bg-primary)); + border-right-color: color-mix(in srgb, var(--tn-kanban-status-color) 38%, var(--tn-border-color)); +} + .kanban-view__swimlane-column.kanban-view__column--unknown-status { border-left: 1px dashed var(--tn-text-faint); background: var(--tn-bg-secondary); diff --git a/styles/kanban-view.css b/styles/kanban-view.css index f439a51e9..e10e9d505 100644 --- a/styles/kanban-view.css +++ b/styles/kanban-view.css @@ -185,7 +185,7 @@ ================================================ */ /* Column */ -.tasknotes-plugin .kanban-view__column { + .tasknotes-plugin .kanban-view__column { min-width: 200px; max-width: 500px; flex-shrink: 0; @@ -199,6 +199,11 @@ transition: border-color var(--tn-transition-fast), box-shadow var(--tn-transition-fast), background-color var(--tn-transition-fast); } +.tasknotes-plugin .kanban-view__column--status-colored { + background: color-mix(in srgb, var(--tn-kanban-status-color) 6%, var(--tn-bg-primary)); + border-color: color-mix(in srgb, var(--tn-kanban-status-color) 38%, var(--tn-border-color)); +} + .tasknotes-plugin .kanban-view__column--dragover { border-color: var(--tn-interactive-accent); box-shadow: var(--tn-shadow-medium); diff --git a/tests/unit/bases/KanbanView.statusColors.test.ts b/tests/unit/bases/KanbanView.statusColors.test.ts new file mode 100644 index 000000000..ee077a4b8 --- /dev/null +++ b/tests/unit/bases/KanbanView.statusColors.test.ts @@ -0,0 +1,223 @@ +import { KanbanView } from "../../../src/bases/KanbanView"; +import { StatusManager } from "../../../src/services/StatusManager"; +import type { StatusConfig } from "../../../src/types"; + +const OPEN_STATUS: StatusConfig = { + id: "open", + value: "open", + label: "Open", + color: "#7c3aed", + icon: "lucide-circle", + isCompleted: false, + order: 1, + autoArchive: false, + autoArchiveDelay: 5, +}; + +const THEME_COLOR_STATUS: StatusConfig = { + ...OPEN_STATUS, + id: "theme-color", + value: "theme-color", + label: "Theme color", + color: "blue", + order: 2, +}; + +const FUNCTION_COLOR_STATUS: StatusConfig = { + ...OPEN_STATUS, + id: "function-color", + value: "function-color", + label: "Function color", + color: "rgb(10 20 30)", + order: 3, +}; + +const INVALID_COLOR_STATUS: StatusConfig = { + ...OPEN_STATUS, + id: "invalid-color", + value: "invalid-color", + label: "Invalid color", + color: "rgb(", + order: 4, +}; + +const STATUSES = [ + OPEN_STATUS, + THEME_COLOR_STATUS, + FUNCTION_COLOR_STATUS, + INVALID_COLOR_STATUS, +]; + +function makePlugin() { + return { + app: { + metadataCache: { + getFirstLinkpathDest: () => null, + getFileCache: () => undefined, + }, + vault: { + getAbstractFileByPath: () => null, + }, + workspace: { + getLeaf: () => ({ openFile: jest.fn() }), + openLinkText: jest.fn(), + }, + }, + fieldMapper: { + toUserField: (field: string) => field, + isRecognizedProperty: () => true, + }, + statusManager: new StatusManager(STATUSES, "open"), + priorityManager: { + getAllPriorities: () => [], + normalizePriorityValue: (value: string) => value, + }, + i18n: { + translate: (key: string) => (key === "views.kanban.noTasks" ? "No tasks" : key), + }, + settings: { + customStatuses: STATUSES, + fieldMapping: { + sortOrder: "sort_order", + }, + }, + }; +} + +function makeView(): KanbanView { + const view = new KanbanView({}, document.createElement("div"), makePlugin() as any); + (view as any).config = { + get: jest.fn(() => undefined), + getOrder: jest.fn(() => []), + getDisplayName: jest.fn(() => undefined), + }; + return view; +} + +function expectStatusColor( + element: Element | null, + modifierClass: string, + color = OPEN_STATUS.color +): void { + expect(element).not.toBeNull(); + expect(element?.classList.contains(modifierClass)).toBe(true); + expect((element as HTMLElement).style.getPropertyValue("--tn-kanban-status-color")).toBe( + color + ); +} + +function expectNoStatusColor(element: HTMLElement, modifierClass: string): void { + expect(element.classList.contains(modifierClass)).toBe(false); + expect(element.style.getPropertyValue("--tn-kanban-status-color")).toBe(""); +} + +describe("Kanban status colors", () => { + const originalCss = window.CSS; + + beforeAll(() => { + Object.defineProperty(window, "CSS", { + configurable: true, + value: { + supports: (_property: string, value: string) => value !== INVALID_COLOR_STATUS.color, + }, + }); + }); + + afterAll(() => { + Object.defineProperty(window, "CSS", { + configurable: true, + value: originalCss, + }); + }); + + it("marks only configured status-grouped flat columns with their status color", async () => { + const view = makeView(); + + const statusColumn = await (view as any).createColumn("open", [], [], "status"); + const projectColumn = await (view as any).createColumn("open", [], [], "projects"); + const unknownStatusColumn = await (view as any).createColumn( + "external-status", + [], + [], + "status" + ); + const themeColorColumn = await (view as any).createColumn( + "theme-color", + [], + [], + "status" + ); + const functionColorColumn = await (view as any).createColumn( + "function-color", + [], + [], + "status" + ); + const invalidColorColumn = await (view as any).createColumn( + "invalid-color", + [], + [], + "status" + ); + + expectStatusColor(statusColumn, "kanban-view__column--status-colored"); + expectStatusColor( + themeColorColumn, + "kanban-view__column--status-colored", + "var(--color-blue)" + ); + expectStatusColor( + functionColorColumn, + "kanban-view__column--status-colored", + FUNCTION_COLOR_STATUS.color + ); + expectNoStatusColor(projectColumn, "kanban-view__column--status-colored"); + expectNoStatusColor(unknownStatusColumn, "kanban-view__column--status-colored"); + expectNoStatusColor(invalidColorColumn, "kanban-view__column--status-colored"); + }); + + it("marks configured status swimlane headers and matching cells", async () => { + const view = makeView(); + const board = document.createElement("div"); + (view as any).boardEl = board; + (view as any).getVisibleProperties = jest.fn(() => []); + (view as any).setupColumnHeaderDragHandlers = jest.fn(); + (view as any).setupSwimLaneCellDragDrop = jest.fn(); + (view as any).renderEmptyCellHint = jest.fn(); + (view as any).createAddTaskButton = jest.fn(); + + await (view as any).renderSwimLaneTable( + new Map([ + [ + "todo", + new Map([ + ["open", []], + ["external-status", []], + ]), + ], + ]), + ["open", "external-status"], + new Map(), + "status" + ); + + expectStatusColor( + board.querySelector('[data-column-key="open"]'), + "kanban-view__column-header-cell--status-colored" + ); + expectStatusColor( + board.querySelector('[data-column="open"]'), + "kanban-view__swimlane-column--status-colored" + ); + expectNoStatusColor( + board.querySelector( + '[data-column-key="external-status"]' + ) as HTMLElement, + "kanban-view__column-header-cell--status-colored" + ); + expectNoStatusColor( + board.querySelector('[data-column="external-status"]') as HTMLElement, + "kanban-view__swimlane-column--status-colored" + ); + }); +});