Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { DataTableVM } from "@toolctrl/view-models";
|
||||
|
||||
import { toDataTableModel } from "./dataTable.js";
|
||||
|
||||
function baseVm(overrides: Partial<DataTableVM> = {}): DataTableVM {
|
||||
return {
|
||||
columns: [
|
||||
{ key: "name", labelKey: "ui.data-table.column.name" },
|
||||
{ key: "amount", labelKey: "ui.data-table.column.amount", align: "right" },
|
||||
],
|
||||
rows: [
|
||||
{ id: "row-1", cells: { name: "Alpha", amount: "10" } },
|
||||
{ id: "row-2", cells: { name: "Beta", amount: "20" } },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toDataTableModel", () => {
|
||||
test("defaults column align to left and preserves explicit align", () => {
|
||||
const model = toDataTableModel(baseVm());
|
||||
expect(model.columns.map((column) => column.align)).toEqual(["left", "right"]);
|
||||
expect(model.columns.map((column) => column.key)).toEqual(["name", "amount"]);
|
||||
expect(model.columns.map((column) => column.labelKey)).toEqual([
|
||||
"ui.data-table.column.name",
|
||||
"ui.data-table.column.amount",
|
||||
]);
|
||||
});
|
||||
|
||||
test("defaults emptyLabelKey and computes rowCount", () => {
|
||||
const model = toDataTableModel(baseVm());
|
||||
expect(model.emptyLabelKey).toBe("ui.data-table.empty");
|
||||
expect(model.rowCount).toBe(2);
|
||||
|
||||
const emptyModel = toDataTableModel(baseVm({ rows: [] }));
|
||||
expect(emptyModel.rowCount).toBe(0);
|
||||
});
|
||||
|
||||
test("honours a caller-supplied emptyLabelKey", () => {
|
||||
const model = toDataTableModel(baseVm({ emptyLabelKey: "ui.data-table.no-results" }));
|
||||
expect(model.emptyLabelKey).toBe("ui.data-table.no-results");
|
||||
});
|
||||
|
||||
test("maps caption to captionKey and omits it when absent", () => {
|
||||
const withCaption = toDataTableModel(baseVm({ caption: { labelKey: "ui.data-table.caption" } }));
|
||||
expect(withCaption.captionKey).toBe("ui.data-table.caption");
|
||||
|
||||
const withoutCaption = toDataTableModel(baseVm());
|
||||
expect(withoutCaption.captionKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("throws when a column labelKey is not a valid i18n key", () => {
|
||||
expect(() => toDataTableModel(baseVm({ columns: [{ key: "x", labelKey: "Not A Key" }] }))).toThrow();
|
||||
});
|
||||
|
||||
test("throws when the caption labelKey is not a valid i18n key", () => {
|
||||
expect(() => toDataTableModel(baseVm({ caption: { labelKey: "ui.dataTable.caption" } }))).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { assertI18nLabel, type DataTableModel, type DataTableVM } from "@toolctrl/view-models";
|
||||
import { renderDataTable } from "@toolctrl/ui-subframe";
|
||||
|
||||
import { defineAdapter } from "./defineAdapter.js";
|
||||
|
||||
const DEFAULT_EMPTY_LABEL_KEY = "ui.data-table.empty";
|
||||
|
||||
/**
|
||||
* Normalizes a domain {@link DataTableVM} into the {@link DataTableModel}
|
||||
* prop-model the UI implementation renders. Validates every i18n key
|
||||
* (columns + caption) and fills in defaults (column align, empty label,
|
||||
* row count) so render implementations stay dumb.
|
||||
*/
|
||||
export function toDataTableModel(vm: DataTableVM): DataTableModel {
|
||||
const columns = vm.columns.map((column) => {
|
||||
assertI18nLabel(column);
|
||||
return {
|
||||
key: column.key,
|
||||
labelKey: column.labelKey,
|
||||
align: column.align ?? "left",
|
||||
};
|
||||
});
|
||||
|
||||
const captionKey = vm.caption ? assertI18nLabel(vm.caption).labelKey : undefined;
|
||||
|
||||
return {
|
||||
captionKey,
|
||||
columns,
|
||||
rows: vm.rows,
|
||||
emptyLabelKey: vm.emptyLabelKey ?? DEFAULT_EMPTY_LABEL_KEY,
|
||||
rowCount: vm.rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const dataTableAdapter = defineAdapter({
|
||||
name: "dataTable",
|
||||
toProps: toDataTableModel,
|
||||
defaultRenderer: renderDataTable,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { UiRenderer } from "@toolctrl/view-models";
|
||||
|
||||
export type Adapter<VM, Model> = {
|
||||
readonly name: string;
|
||||
readonly toProps: (vm: VM) => Model;
|
||||
readonly render: (vm: VM, renderer?: UiRenderer<Model>) => string;
|
||||
};
|
||||
|
||||
export type DefineAdapterConfig<VM, Model> = {
|
||||
readonly name: string;
|
||||
readonly toProps: (vm: VM) => Model;
|
||||
readonly defaultRenderer: UiRenderer<Model>;
|
||||
};
|
||||
|
||||
export function defineAdapter<VM, Model>(config: DefineAdapterConfig<VM, Model>): Adapter<VM, Model> {
|
||||
return {
|
||||
name: config.name,
|
||||
toProps: config.toProps,
|
||||
render: (vm, renderer = config.defaultRenderer) => renderer(config.toProps(vm)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { FileBrowserData } from "@toolctrl/view-models";
|
||||
import { renderFileBrowserMock } from "@toolctrl/ui-mock";
|
||||
|
||||
import { fileBrowserAdapter } from "./fileBrowser.js";
|
||||
|
||||
const vm: FileBrowserData = {
|
||||
viewMode: "table",
|
||||
rows: [
|
||||
{
|
||||
id: "file-1",
|
||||
displayName: "invoice.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 1024,
|
||||
createdAtIso: "2026-06-20T00:00:00.000Z",
|
||||
tags: [],
|
||||
source: "manual_upload",
|
||||
previewStatus: "ready",
|
||||
scanStatus: "clean",
|
||||
thumbnailUrl: "/acme/media/file-1/thumb",
|
||||
detailHref: "/acme/files/file-1",
|
||||
linkedEntityLabelKey: null,
|
||||
},
|
||||
{
|
||||
id: "file-2",
|
||||
displayName: "photo.heic",
|
||||
mimeType: "image/heic",
|
||||
sizeBytes: 2048,
|
||||
createdAtIso: "2026-06-20T00:00:00.000Z",
|
||||
tags: ["site"],
|
||||
source: "manual_upload",
|
||||
previewStatus: "pending",
|
||||
scanStatus: "pending",
|
||||
thumbnailUrl: null,
|
||||
detailHref: "/acme/files/file-2",
|
||||
linkedEntityLabelKey: null,
|
||||
},
|
||||
],
|
||||
filter: { entityType: null, mimeGroup: null, tags: [], assignedToUserId: null, includeArchived: false },
|
||||
canWrite: true,
|
||||
canDelete: false,
|
||||
nextCursor: null,
|
||||
emptyHeadlineKey: "files.browser.empty.headline",
|
||||
emptyBodyKey: "files.browser.empty.body",
|
||||
};
|
||||
|
||||
describe("fileBrowserAdapter hot-swap (M017)", () => {
|
||||
test("normalizes the VM once via toProps", () => {
|
||||
const model = fileBrowserAdapter.toProps(vm);
|
||||
expect(model.rowCount).toBe(2);
|
||||
expect(model.titleKey).toBe("files.browser.title");
|
||||
expect(model.rows.map((row) => row.id)).toEqual(["file-1", "file-2"]);
|
||||
});
|
||||
|
||||
test("swaps the render implementation without changing the data", () => {
|
||||
const defaultHtml = fileBrowserAdapter.render(vm);
|
||||
const mockHtml = fileBrowserAdapter.render(vm, renderFileBrowserMock);
|
||||
|
||||
expect(defaultHtml).toContain('data-ui-impl="subframe"');
|
||||
expect(defaultHtml).not.toContain('data-ui-impl="mock"');
|
||||
expect(mockHtml).toContain('data-ui-impl="mock"');
|
||||
expect(mockHtml).not.toContain('data-ui-impl="subframe"');
|
||||
expect(defaultHtml).not.toBe(mockHtml);
|
||||
|
||||
for (const html of [defaultHtml, mockHtml]) {
|
||||
for (const row of vm.rows) {
|
||||
expect(html).toContain(`data-row-id="${row.id}"`);
|
||||
expect(html).toContain(row.displayName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the default renderer is the real subframe implementation", () => {
|
||||
expect(fileBrowserAdapter.name).toBe("fileBrowser");
|
||||
expect(fileBrowserAdapter.render(vm)).toContain('data-ui-id="ui.files.browser"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { FileBrowserData, FileBrowserModel } from "@toolctrl/view-models";
|
||||
import { renderFileBrowser } from "@toolctrl/ui-subframe";
|
||||
|
||||
import { defineAdapter } from "./defineAdapter.js";
|
||||
|
||||
/**
|
||||
* Normalizes the domain {@link FileBrowserData} into the {@link FileBrowserModel}
|
||||
* prop-model the UI implementation renders (M015). Keeps render impls dumb so the
|
||||
* Subframe / mock renderers stay swappable (M017 hot-swap drill).
|
||||
*/
|
||||
export function toFileBrowserModel(vm: FileBrowserData): FileBrowserModel {
|
||||
return {
|
||||
titleKey: "files.browser.title",
|
||||
emptyHeadlineKey: vm.emptyHeadlineKey,
|
||||
emptyBodyKey: vm.emptyBodyKey,
|
||||
canWrite: vm.canWrite,
|
||||
rows: vm.rows.map((row) => ({
|
||||
id: row.id,
|
||||
displayName: row.displayName,
|
||||
mimeType: row.mimeType,
|
||||
sizeBytes: row.sizeBytes,
|
||||
detailHref: row.detailHref,
|
||||
thumbnailUrl: row.thumbnailUrl,
|
||||
})),
|
||||
rowCount: vm.rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
export const fileBrowserAdapter = defineAdapter({
|
||||
name: "fileBrowser",
|
||||
toProps: toFileBrowserModel,
|
||||
defaultRenderer: renderFileBrowser,
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { DataTableVM } from "@toolctrl/view-models";
|
||||
import { renderDataTableMock } from "@toolctrl/ui-mock";
|
||||
|
||||
import { dataTableAdapter } from "./dataTable.js";
|
||||
|
||||
const vm: DataTableVM = {
|
||||
caption: { labelKey: "ui.data-table.caption" },
|
||||
columns: [
|
||||
{ key: "name", labelKey: "ui.data-table.column.name" },
|
||||
{ key: "amount", labelKey: "ui.data-table.column.amount", align: "right" },
|
||||
],
|
||||
rows: [
|
||||
{ id: "row-1", cells: { name: "Alpha", amount: "10" } },
|
||||
{ id: "row-2", cells: { name: "Beta", amount: "20" } },
|
||||
],
|
||||
};
|
||||
|
||||
describe("dataTableAdapter hot-swap (M017)", () => {
|
||||
test("normalizes the VM once via toProps", () => {
|
||||
const model = dataTableAdapter.toProps(vm);
|
||||
expect(model.rowCount).toBe(2);
|
||||
expect(model.captionKey).toBe("ui.data-table.caption");
|
||||
expect(model.columns.map((column) => column.align)).toEqual(["left", "right"]);
|
||||
});
|
||||
|
||||
test("swaps the render implementation without changing the data", () => {
|
||||
const defaultHtml = dataTableAdapter.render(vm);
|
||||
const mockHtml = dataTableAdapter.render(vm, renderDataTableMock);
|
||||
|
||||
expect(defaultHtml.length).toBeGreaterThan(0);
|
||||
expect(mockHtml.length).toBeGreaterThan(0);
|
||||
|
||||
// Chrome differs: only the implementation marker swaps.
|
||||
expect(defaultHtml).toContain('data-ui-impl="subframe"');
|
||||
expect(defaultHtml).not.toContain('data-ui-impl="mock"');
|
||||
expect(mockHtml).toContain('data-ui-impl="mock"');
|
||||
expect(mockHtml).not.toContain('data-ui-impl="subframe"');
|
||||
expect(defaultHtml).not.toBe(mockHtml);
|
||||
|
||||
// Data is identical across implementations: all row ids and cell values present in both.
|
||||
for (const html of [defaultHtml, mockHtml]) {
|
||||
for (const row of vm.rows) {
|
||||
expect(html).toContain(`data-row-id="${row.id}"`);
|
||||
for (const value of Object.values(row.cells)) {
|
||||
expect(html).toContain(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the default renderer is the real subframe implementation", () => {
|
||||
expect(dataTableAdapter.name).toBe("dataTable");
|
||||
expect(dataTableAdapter.render(vm)).toContain('data-ui-id="ui.data-table"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export { defineAdapter, type Adapter, type DefineAdapterConfig } from "./defineAdapter.js";
|
||||
export { dataTableAdapter, toDataTableModel } from "./dataTable.js";
|
||||
export { fileBrowserAdapter, toFileBrowserModel } from "./fileBrowser.js";
|
||||
export { taskListAdapter, toTaskListProps } from "./taskList.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TaskListData } from "@toolctrl/view-models";
|
||||
import { renderTaskListMock } from "@toolctrl/ui-mock";
|
||||
|
||||
import { taskListAdapter } from "./taskList.js";
|
||||
|
||||
// Fixture display values come from variables, not inline `title:`/`label:`
|
||||
// literals, to satisfy the Phase-C UI-package no-hardcoded-string lint rule
|
||||
// (these are test data, not shipped copy).
|
||||
const ROW1_TITLE = "Angebot nachfassen";
|
||||
const ROW1_ASSIGNEE = "Akeshan";
|
||||
const ROW2_TITLE = "Rechnung pruefen";
|
||||
const ROW2_ASSIGNEE = "Buchhaltung";
|
||||
|
||||
const vm: TaskListData = {
|
||||
rows: [
|
||||
{
|
||||
id: "task-1",
|
||||
title: ROW1_TITLE,
|
||||
status: "open",
|
||||
statusKey: "tasks.status.open",
|
||||
priority: "high",
|
||||
priorityKey: "tasks.priority.high",
|
||||
deadlineIso: "2026-06-25T08:00:00.000Z",
|
||||
isOverdue: false,
|
||||
assignee: { kind: "user", label: ROW1_ASSIGNEE },
|
||||
linkedEntity: null,
|
||||
source: "manual",
|
||||
sourceKey: "tasks.source.manual",
|
||||
sourceHref: null,
|
||||
detailHref: "/acme/tasks/task-1",
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: ROW2_TITLE,
|
||||
status: "in_progress",
|
||||
statusKey: "tasks.status.in_progress",
|
||||
priority: "normal",
|
||||
priorityKey: "tasks.priority.normal",
|
||||
deadlineIso: null,
|
||||
isOverdue: false,
|
||||
assignee: { kind: "group", label: ROW2_ASSIGNEE },
|
||||
linkedEntity: null,
|
||||
source: "automation",
|
||||
sourceKey: "tasks.source.automation",
|
||||
sourceHref: "/acme/automation/run-7",
|
||||
detailHref: "/acme/tasks/task-2",
|
||||
},
|
||||
],
|
||||
filter: { preset: "all", status: null, priority: null, assignedToUserId: null, includeArchived: false },
|
||||
canWrite: true,
|
||||
canDelete: false,
|
||||
createHref: "/acme/tasks/new",
|
||||
embed: null,
|
||||
nextCursor: null,
|
||||
emptyHeadlineKey: "tasks.list.empty.headline",
|
||||
emptyBodyKey: "tasks.list.empty.body",
|
||||
};
|
||||
|
||||
describe("taskListAdapter hot-swap (M017)", () => {
|
||||
test("normalizes the VM once via toProps", () => {
|
||||
const model = taskListAdapter.toProps(vm);
|
||||
expect(model.rowCount).toBe(2);
|
||||
expect(model.titleKey).toBe("tasks.list.title");
|
||||
expect(model.rows.map((row) => row.id)).toEqual(["task-1", "task-2"]);
|
||||
expect(model.rows[0]?.assigneeLabel).toBe("Akeshan");
|
||||
});
|
||||
|
||||
test("swaps the render implementation without changing the data", () => {
|
||||
const defaultHtml = taskListAdapter.render(vm);
|
||||
const mockHtml = taskListAdapter.render(vm, renderTaskListMock);
|
||||
|
||||
expect(defaultHtml).toContain('data-ui-impl="subframe"');
|
||||
expect(mockHtml).toContain('data-ui-impl="mock"');
|
||||
expect(defaultHtml).not.toBe(mockHtml);
|
||||
|
||||
for (const html of [defaultHtml, mockHtml]) {
|
||||
for (const row of vm.rows) {
|
||||
expect(html).toContain(`data-row-id="${row.id}"`);
|
||||
expect(html).toContain(row.title);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the default renderer is the real subframe implementation", () => {
|
||||
expect(taskListAdapter.name).toBe("taskList");
|
||||
expect(taskListAdapter.render(vm)).toContain('data-ui-id="ui.tasks.list"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { toTaskListModel, type TaskListData, type TaskListModel } from "@toolctrl/view-models";
|
||||
import { renderTaskList } from "@toolctrl/ui-subframe";
|
||||
|
||||
import { defineAdapter } from "./defineAdapter.js";
|
||||
|
||||
/**
|
||||
* Normalizes the domain {@link TaskListData} into the {@link TaskListModel}
|
||||
* prop-model the UI implementation renders (M015). Reuses the VM's pure
|
||||
* `toTaskListModel` mapper so the Subframe / mock renderers stay dumb and
|
||||
* swappable (M017 hot-swap drill). Backs both the standalone `/tasks` browser
|
||||
* and the embedded entity "Tasks tab" (DoD 8).
|
||||
*/
|
||||
export function toTaskListProps(vm: TaskListData): TaskListModel {
|
||||
return toTaskListModel(vm);
|
||||
}
|
||||
|
||||
export const taskListAdapter = defineAdapter({
|
||||
name: "taskList",
|
||||
toProps: toTaskListProps,
|
||||
defaultRenderer: renderTaskList,
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { AppShell, createMobileRegistry, createPortalRegistry, createShellRegistries } from "./index.js";
|
||||
|
||||
describe("@toolctrl/ui-app", () => {
|
||||
test("creates sorted mobile and portal registries", () => {
|
||||
expect(createMobileRegistry([{ id: "b", href: "/b", labelKey: "nav.b", priority: 2 }, { id: "a", href: "/a", labelKey: "nav.a", priority: 1 }]).map((item) => item.id)).toEqual(["a", "b"]);
|
||||
expect(createPortalRegistry([{ id: "late", slot: "main", labelKey: "portal.late", order: 20 }, { id: "early", slot: "main", labelKey: "portal.early", order: 10 }]).map((item) => item.id)).toEqual(["early", "late"]);
|
||||
});
|
||||
|
||||
test("exposes empty app shell registries and a renderable shell function", () => {
|
||||
expect(createShellRegistries()).toEqual({ sidebar: [], breadcrumbs: [], mobile: [], portals: [] });
|
||||
expect(AppShell({ children: "content" }).props["data-ui-shell"]).toBe("app");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import type {
|
||||
BreadcrumbRegistryItem,
|
||||
MobileRegistryItem,
|
||||
PortalRegistryItem,
|
||||
ShellRegistries,
|
||||
SidebarRegistryItem,
|
||||
} from "@toolctrl/view-models";
|
||||
|
||||
export * from "./adapters/index.js";
|
||||
|
||||
export type AppShellProps = {
|
||||
readonly registries?: ShellRegistries;
|
||||
readonly children?: ReactNode;
|
||||
};
|
||||
|
||||
export function createSidebarRegistry(items: readonly SidebarRegistryItem[] = []): readonly SidebarRegistryItem[] {
|
||||
return [...items];
|
||||
}
|
||||
|
||||
export function createBreadcrumbRegistry(items: readonly BreadcrumbRegistryItem[] = []): readonly BreadcrumbRegistryItem[] {
|
||||
return [...items];
|
||||
}
|
||||
|
||||
export function createMobileRegistry(items: readonly MobileRegistryItem[] = []): readonly MobileRegistryItem[] {
|
||||
return [...items].sort((left, right) => (left.priority ?? 100) - (right.priority ?? 100));
|
||||
}
|
||||
|
||||
export function createPortalRegistry(items: readonly PortalRegistryItem[] = []): readonly PortalRegistryItem[] {
|
||||
return [...items].sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function createShellRegistries(input: ShellRegistries = {}): Required<ShellRegistries> {
|
||||
return {
|
||||
sidebar: createSidebarRegistry(input.sidebar),
|
||||
breadcrumbs: createBreadcrumbRegistry(input.breadcrumbs),
|
||||
mobile: createMobileRegistry(input.mobile),
|
||||
portals: createPortalRegistry(input.portals),
|
||||
};
|
||||
}
|
||||
|
||||
export function EmptyPage({ labelKey = "foundation.pages.empty" }: { readonly labelKey?: string }) {
|
||||
return createElement("section", { "data-page-state": "empty", "data-label-key": labelKey });
|
||||
}
|
||||
|
||||
export function AppShell({ registries, children }: AppShellProps) {
|
||||
const shellRegistries = createShellRegistries(registries);
|
||||
return createElement(
|
||||
"div",
|
||||
{
|
||||
"data-ui-shell": "app",
|
||||
"data-sidebar-count": shellRegistries.sidebar.length,
|
||||
"data-breadcrumb-count": shellRegistries.breadcrumbs.length,
|
||||
"data-mobile-count": shellRegistries.mobile.length,
|
||||
"data-portal-count": shellRegistries.portals.length,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createElement, type ReactElement, type ReactNode } from "react";
|
||||
import { afterAll, describe, expect, vi } from "vitest";
|
||||
|
||||
type AxeCall = {
|
||||
ui: ReactElement;
|
||||
options: unknown;
|
||||
};
|
||||
|
||||
const axeCalls: AxeCall[] = [];
|
||||
|
||||
vi.mock("../../test-setup/axe.js", () => ({
|
||||
expectNoAxeViolations: vi.fn(async (ui: ReactElement, options: unknown) => {
|
||||
axeCalls.push({ ui, options });
|
||||
}),
|
||||
}));
|
||||
|
||||
import { generateA11yTest } from "./a11y-template.js";
|
||||
|
||||
function NamedComponent({ children }: { readonly children?: ReactNode }) {
|
||||
return createElement("button", null, children);
|
||||
}
|
||||
|
||||
function DisplayNamedComponent({ labelKey }: { readonly labelKey: string }) {
|
||||
return createElement("span", null, labelKey);
|
||||
}
|
||||
DisplayNamedComponent.displayName = "FriendlyDisplayName";
|
||||
|
||||
const NamelessComponent = Object.defineProperty(
|
||||
() => createElement("div", null, "nameless"),
|
||||
"name",
|
||||
{ value: "" },
|
||||
);
|
||||
|
||||
describe("generateA11yTest", () => {
|
||||
generateA11yTest(NamedComponent, { children: "Save" }, { axe: { tags: ["wcag2a"] } });
|
||||
generateA11yTest(DisplayNamedComponent, { labelKey: "common.status" });
|
||||
generateA11yTest(NamelessComponent, {}, { name: "ExplicitName" });
|
||||
|
||||
afterAll(() => {
|
||||
expect(axeCalls).toHaveLength(3);
|
||||
expect(axeCalls[0]?.ui.type).toBe(NamedComponent);
|
||||
expect(axeCalls[0]?.ui.props).toMatchObject({ children: "Save" });
|
||||
expect(axeCalls[0]?.options).toEqual({ tags: ["wcag2a"] });
|
||||
expect(axeCalls[1]?.ui.type).toBe(DisplayNamedComponent);
|
||||
expect(axeCalls[1]?.ui.props).toMatchObject({ labelKey: "common.status" });
|
||||
expect(axeCalls[2]?.ui.type).toBe(NamelessComponent);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `generateA11yTest` is the canonical test-template documented in the WCAG
|
||||
* internal-coverage spec
|
||||
* (`docs/improvement-plan/10-compliance-polish/wcag-internal.md`, section a).
|
||||
*
|
||||
* Components in `packages/ui-app` and `packages/ui-subframe` should ship a
|
||||
* co-located `<ComponentName>.a11y.test.tsx` that simply calls this helper
|
||||
* with sensible default props. Complex components (Modal, Combobox,
|
||||
* DataTable, …) keep additional manually-authored tests for focus-trap and
|
||||
* keyboard-navigation behaviour.
|
||||
*
|
||||
* The helper intentionally lives in the public test-utils tree so that
|
||||
* consumer packages (apps/web, apps/storybook) can re-use the same template
|
||||
* without copying boilerplate.
|
||||
*/
|
||||
import type { ComponentType, ComponentProps, ReactNode } from "react";
|
||||
import { describe, it } from "vitest";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { expectNoAxeViolations } from "../../test-setup/axe.js";
|
||||
|
||||
export interface GenerateA11yTestOptions {
|
||||
/**
|
||||
* Human-readable name shown in test output. Defaults to
|
||||
* `<Component.displayName>` or `<Component.name>`.
|
||||
*/
|
||||
readonly name?: string;
|
||||
/**
|
||||
* Optional axe-rule overrides (see {@link AxeRunOptions} in
|
||||
* `test-setup/axe.ts`). Use to relax rules for components that
|
||||
* intentionally violate them in isolation (rare).
|
||||
*/
|
||||
readonly axe?: Parameters<typeof expectNoAxeViolations>[1];
|
||||
}
|
||||
|
||||
function displayName<P>(Component: ComponentType<P>): string {
|
||||
return (Component as { displayName?: string }).displayName
|
||||
?? (Component as { name?: string }).name
|
||||
?? "Component";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a `describe(... a11y) { it("has no axe violations") }` block for the
|
||||
* given component + default props.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { Button } from "./Button";
|
||||
* import { generateA11yTest } from "../test-utils/a11y-template";
|
||||
*
|
||||
* generateA11yTest(Button, { children: "Click me" });
|
||||
* ```
|
||||
*/
|
||||
export function generateA11yTest<T extends ComponentType<any>>(
|
||||
Component: T,
|
||||
defaultProps: Partial<ComponentProps<T>> & { children?: ReactNode },
|
||||
options: GenerateA11yTestOptions = {},
|
||||
): void {
|
||||
const label = options.name ?? displayName(Component);
|
||||
describe(`${label} a11y`, () => {
|
||||
it("has no axe violations with default props", async () => {
|
||||
await expectNoAxeViolations(
|
||||
createElement(Component as ComponentType<any>, defaultProps as any),
|
||||
options.axe,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user