diff --git a/libs/components/grids/package.json b/libs/components/grids/package.json
index 0aa237b7a9..525aa8f5c2 100644
--- a/libs/components/grids/package.json
+++ b/libs/components/grids/package.json
@@ -16,6 +16,7 @@
},
"homepage": "https://github.com/blackbaud/skyux#readme",
"peerDependencies": {
+ "@angular/cdk": "^20.2.3",
"@angular/common": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
diff --git a/libs/components/grids/project.json b/libs/components/grids/project.json
index 6073c160cf..6f86473ffb 100644
--- a/libs/components/grids/project.json
+++ b/libs/components/grids/project.json
@@ -4,6 +4,7 @@
"projectType": "library",
"sourceRoot": "libs/components/grids/src",
"prefix": "sky",
+ "tags": ["component", "npm"],
"targets": {
"build": {
"executor": "@nx/angular:package",
@@ -19,7 +20,21 @@
"tsConfig": "libs/components/grids/tsconfig.lib.json"
}
},
- "defaultConfiguration": "production"
+ "defaultConfiguration": "production",
+ "dependsOn": [
+ "^build",
+ {
+ "projects": ["core"],
+ "target": "build"
+ }
+ ],
+ "inputs": [
+ "buildInputs",
+ "^buildInputs",
+ "{workspaceRoot}/libs/components/grids/testing/src/**/*",
+ "!{workspaceRoot}/libs/components/grids/testing/src/**/*.spec.ts",
+ "!{workspaceRoot}/libs/components/grids/testing/src/**/fixtures/**/*"
+ ]
},
"test": {
"executor": "@angular-devkit/build-angular:karma",
@@ -61,6 +76,5 @@
]
}
}
- },
- "tags": ["component", "npm"]
+ }
}
diff --git a/libs/components/grids/testing/eslint.config.js b/libs/components/grids/testing/eslint.config.js
new file mode 100644
index 0000000000..b4c331fb6a
--- /dev/null
+++ b/libs/components/grids/testing/eslint.config.js
@@ -0,0 +1,5 @@
+const prettier = require('eslint-config-prettier');
+const baseConfig = require('../../../../eslint-base.config');
+const overrides = require('../../../../eslint-overrides.config');
+
+module.exports = [...baseConfig, ...overrides, prettier];
diff --git a/libs/components/grids/testing/karma.conf.js b/libs/components/grids/testing/karma.conf.js
new file mode 100644
index 0000000000..a3e45fc4a4
--- /dev/null
+++ b/libs/components/grids/testing/karma.conf.js
@@ -0,0 +1,19 @@
+// Karma configuration file, see link for more information
+// https://karma-runner.github.io/1.0/config/configuration-file.html
+
+const { join } = require('path');
+const getBaseKarmaConfig = require('../../../../karma.conf');
+
+module.exports = function (config) {
+ const baseConfig = getBaseKarmaConfig();
+ config.set({
+ ...baseConfig,
+ coverageReporter: {
+ ...baseConfig.coverageReporter,
+ dir: join(
+ __dirname,
+ '../../../../coverage/libs/components/grids/testing',
+ ),
+ },
+ });
+};
diff --git a/libs/components/grids/testing/ng-package.json b/libs/components/grids/testing/ng-package.json
new file mode 100644
index 0000000000..fbafcc4448
--- /dev/null
+++ b/libs/components/grids/testing/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ }
+}
diff --git a/libs/components/grids/testing/project.json b/libs/components/grids/testing/project.json
new file mode 100644
index 0000000000..402803673c
--- /dev/null
+++ b/libs/components/grids/testing/project.json
@@ -0,0 +1,47 @@
+{
+ "name": "grids-testing",
+ "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "library",
+ "sourceRoot": "libs/components/grids/testing/src",
+ "prefix": "sky",
+ "tags": ["testing"],
+ "targets": {
+ "test": {
+ "executor": "@angular-devkit/build-angular:karma",
+ "options": {
+ "tsConfig": "libs/components/grids/testing/tsconfig.spec.json",
+ "karmaConfig": "libs/components/grids/testing/karma.conf.js",
+ "codeCoverage": true,
+ "codeCoverageExclude": ["**/fixtures/**"],
+ "styles": [
+ "libs/components/theme/src/lib/styles/sky.scss",
+ "libs/components/theme/src/lib/styles/themes/modern/styles.scss"
+ ],
+ "polyfills": [
+ "zone.js",
+ "zone.js/testing",
+ "libs/components/packages/src/polyfills.js"
+ ],
+ "inlineStyleLanguage": "scss",
+ "stylePreprocessorOptions": {
+ "includePaths": ["{workspaceRoot}"]
+ }
+ },
+ "configurations": {
+ "ci": {
+ "browsers": "ChromeHeadlessNoSandbox",
+ "codeCoverage": true,
+ "progress": false,
+ "sourceMap": true,
+ "watch": false
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "options": {
+ "lintFilePatterns": ["{projectRoot}/src/**/*.ts"]
+ }
+ }
+ }
+}
diff --git a/libs/components/grids/testing/src/modules/grid/grid-harness-filters.ts b/libs/components/grids/testing/src/modules/grid/grid-harness-filters.ts
new file mode 100644
index 0000000000..34fe93c7b1
--- /dev/null
+++ b/libs/components/grids/testing/src/modules/grid/grid-harness-filters.ts
@@ -0,0 +1,7 @@
+import { SkyHarnessFilters } from '@skyux/core/testing';
+
+/**
+ * A set of criteria that can be used to filter a list of `SkyGridHarness` instances.
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type
+export interface SkyGridHarnessFilters extends SkyHarnessFilters {}
diff --git a/libs/components/grids/testing/src/modules/grid/grid-harness.spec.ts b/libs/components/grids/testing/src/modules/grid/grid-harness.spec.ts
new file mode 100644
index 0000000000..b36af81156
--- /dev/null
+++ b/libs/components/grids/testing/src/modules/grid/grid-harness.spec.ts
@@ -0,0 +1,188 @@
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { expect } from '@skyux-sdk/testing';
+import { SkyGridModule } from '@skyux/grids';
+
+import { SkyGridHarness } from './grid-harness';
+
+//#region Test Component
+@Component({
+ selector: 'sky-grid-test',
+ template: `
+
+
+
+
+
+
+
+
+ `,
+ standalone: false,
+})
+class TestComponent {
+ public data = [
+ { id: '1', name: 'John', age: 30 },
+ { id: '2', name: 'Jane', age: 25 },
+ { id: '3', name: 'Bob', age: 35 },
+ ];
+
+ public otherData = [{ id: '4', name: 'Alice', age: 28 }];
+
+ public enableMultiselect = false;
+ public selectedRowIds: string[] = [];
+ public rowHighlightedId: string | undefined;
+}
+//#endregion Test Component
+
+describe('Grid harness', () => {
+ async function setupTest(options: { dataSkyId?: string } = {}): Promise<{
+ gridHarness: SkyGridHarness;
+ fixture: ComponentFixture;
+ loader: HarnessLoader;
+ }> {
+ await TestBed.configureTestingModule({
+ declarations: [TestComponent],
+ imports: [SkyGridModule],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const loader = TestbedHarnessEnvironment.loader(fixture);
+
+ let gridHarness: SkyGridHarness;
+
+ if (options.dataSkyId) {
+ gridHarness = await loader.getHarness(
+ SkyGridHarness.with({
+ dataSkyId: options.dataSkyId,
+ }),
+ );
+ } else {
+ gridHarness = await loader.getHarness(SkyGridHarness);
+ }
+
+ fixture.detectChanges();
+
+ return { gridHarness, fixture, loader };
+ }
+
+ it('should get the grid from its data-sky-id', async () => {
+ const { gridHarness } = await setupTest({
+ dataSkyId: 'other-grid',
+ });
+ const rowCount = await gridHarness.getRowCount();
+ expect(rowCount).toBe(1);
+ });
+
+ it('should get the column count', async () => {
+ const { gridHarness } = await setupTest();
+ const columnCount = await gridHarness.getColumnCount();
+ expect(columnCount).toBe(2);
+ });
+
+ it('should get the column headings', async () => {
+ const { gridHarness } = await setupTest();
+ const headings = await gridHarness.getColumnHeadings();
+ expect(headings.length).toBe(2);
+ });
+
+ it('should get the row count', async () => {
+ const { gridHarness } = await setupTest();
+ const rowCount = await gridHarness.getRowCount();
+ expect(rowCount).toBe(3);
+ });
+
+ it('should get cell text', async () => {
+ const { gridHarness } = await setupTest();
+ const cellText = await gridHarness.getCellText(0, 0);
+ expect(cellText).toBe('John');
+ });
+
+ it('should throw error for invalid row index when getting cell text', async () => {
+ const { gridHarness } = await setupTest();
+ await expectAsync(gridHarness.getCellText(10, 0)).toBeRejectedWithError(
+ 'Row index 10 is out of bounds. Grid has 3 rows.',
+ );
+ });
+
+ it('should throw error for invalid column index when getting cell text', async () => {
+ const { gridHarness } = await setupTest();
+ await expectAsync(gridHarness.getCellText(0, 10)).toBeRejectedWithError(
+ 'Column index 10 is out of bounds. Row has 2 columns.',
+ );
+ });
+
+ it('should check if multiselect is enabled', async () => {
+ const { gridHarness, fixture } = await setupTest();
+ expect(await gridHarness.hasMultiselect()).toBe(false);
+
+ fixture.componentInstance.enableMultiselect = true;
+ fixture.detectChanges();
+
+ expect(await gridHarness.hasMultiselect()).toBe(true);
+ });
+
+ it('should check if row is selected', async () => {
+ const { gridHarness, fixture } = await setupTest();
+ fixture.componentInstance.enableMultiselect = true;
+ fixture.componentInstance.selectedRowIds = ['1'];
+ fixture.detectChanges();
+
+ expect(await gridHarness.isRowSelected(0)).toBe(true);
+ expect(await gridHarness.isRowSelected(1)).toBe(false);
+ });
+
+ it('should throw error for invalid row index when checking selection', async () => {
+ const { gridHarness } = await setupTest();
+ await expectAsync(gridHarness.isRowSelected(10)).toBeRejectedWithError(
+ 'Row index 10 is out of bounds. Grid has 3 rows.',
+ );
+ });
+
+ it('should check if row is highlighted', async () => {
+ const { gridHarness, fixture } = await setupTest();
+ fixture.componentInstance.rowHighlightedId = '2';
+ fixture.detectChanges();
+
+ expect(await gridHarness.isRowHighlighted(0)).toBe(false);
+ expect(await gridHarness.isRowHighlighted(1)).toBe(true);
+ });
+
+ it('should throw error for invalid row index when checking highlight', async () => {
+ const { gridHarness } = await setupTest();
+ await expectAsync(gridHarness.isRowHighlighted(10)).toBeRejectedWithError(
+ 'Row index 10 is out of bounds. Grid has 3 rows.',
+ );
+ });
+
+ it('should click a row', async () => {
+ const { gridHarness } = await setupTest();
+ await gridHarness.clickRow(0);
+ });
+
+ it('should throw error for invalid row index when clicking', async () => {
+ const { gridHarness } = await setupTest();
+ await expectAsync(gridHarness.clickRow(10)).toBeRejectedWithError(
+ 'Row index 10 is out of bounds. Grid has 3 rows.',
+ );
+ });
+
+ it('should click a column header', async () => {
+ const { gridHarness } = await setupTest();
+ await gridHarness.clickColumnHeader(0);
+ });
+
+ it('should throw error for invalid column index when clicking header', async () => {
+ const { gridHarness } = await setupTest();
+ await expectAsync(gridHarness.clickColumnHeader(10)).toBeRejectedWithError(
+ 'Column index 10 is out of bounds. Grid has 2 columns.',
+ );
+ });
+});
diff --git a/libs/components/grids/testing/src/modules/grid/grid-harness.ts b/libs/components/grids/testing/src/modules/grid/grid-harness.ts
new file mode 100644
index 0000000000..a1dbff1b51
--- /dev/null
+++ b/libs/components/grids/testing/src/modules/grid/grid-harness.ts
@@ -0,0 +1,154 @@
+import { HarnessPredicate } from '@angular/cdk/testing';
+import { SkyComponentHarness } from '@skyux/core/testing';
+
+import { SkyGridHarnessFilters } from './grid-harness-filters';
+
+/**
+ * Harness for interacting with a grid component in tests.
+ * @deprecated `SkyGridComponent` and its features are deprecated. Use the data grid instead.
+ */
+export class SkyGridHarness extends SkyComponentHarness {
+ /**
+ * @internal
+ */
+ public static hostSelector = 'sky-grid';
+
+ #getRows = this.locatorForAll('tbody tr.sky-grid-row');
+ #getHeaderCells = this.locatorForAll('th.sky-grid-heading');
+
+ /**
+ * Gets a `HarnessPredicate` that can be used to search for a
+ * `SkyGridHarness` that meets certain criteria.
+ */
+ public static with(
+ filters: SkyGridHarnessFilters,
+ ): HarnessPredicate {
+ return SkyGridHarness.getDataSkyIdPredicate(filters);
+ }
+
+ /**
+ * Gets the number of columns in the grid.
+ */
+ public async getColumnCount(): Promise {
+ const headers = await this.#getHeaderCells();
+ return headers.length;
+ }
+
+ /**
+ * Gets the column headings from the grid.
+ */
+ public async getColumnHeadings(): Promise {
+ const headers = await this.#getHeaderCells();
+ const headings: string[] = [];
+ for (const header of headers) {
+ const textElement = await header.getAttribute('sky-cmp-id');
+ if (textElement) {
+ const headerText = await header.text();
+ headings.push(headerText.trim());
+ }
+ }
+ return headings;
+ }
+
+ /**
+ * Gets the number of rows in the grid.
+ */
+ public async getRowCount(): Promise {
+ const rows = await this.#getRows();
+ return rows.length;
+ }
+
+ /**
+ * Gets the text content of a specific cell.
+ * @param rowIndex The zero-based row index.
+ * @param columnIndex The zero-based column index.
+ */
+ public async getCellText(
+ rowIndex: number,
+ columnIndex: number,
+ ): Promise {
+ const rows = await this.#getRows();
+ if (rowIndex >= rows.length) {
+ throw new Error(
+ `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`,
+ );
+ }
+ const allCells = await this.locatorForAll(
+ 'tbody tr.sky-grid-row td.sky-grid-cell',
+ )();
+ const columnCount = await this.getColumnCount();
+ const cellIndex = rowIndex * columnCount + columnIndex;
+ if (columnIndex >= columnCount) {
+ throw new Error(
+ `Column index ${columnIndex} is out of bounds. Row has ${columnCount} columns.`,
+ );
+ }
+ return (await allCells[cellIndex].text()).trim();
+ }
+
+ /**
+ * Checks if the grid has multiselect enabled.
+ */
+ public async hasMultiselect(): Promise {
+ const multiselectCells = await this.locatorForAll(
+ '.sky-grid-multiselect-cell',
+ )();
+ return multiselectCells.length > 0;
+ }
+
+ /**
+ * Checks if a specific row is selected (for multiselect grids).
+ * @param rowIndex The zero-based row index.
+ */
+ public async isRowSelected(rowIndex: number): Promise {
+ const rows = await this.#getRows();
+ if (rowIndex >= rows.length) {
+ throw new Error(
+ `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`,
+ );
+ }
+ return await rows[rowIndex].hasClass('sky-grid-multiselect-selected-row');
+ }
+
+ /**
+ * Checks if a specific row is highlighted.
+ * @param rowIndex The zero-based row index.
+ */
+ public async isRowHighlighted(rowIndex: number): Promise {
+ const rows = await this.#getRows();
+ if (rowIndex >= rows.length) {
+ throw new Error(
+ `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`,
+ );
+ }
+ return await rows[rowIndex].hasClass('sky-grid-row-highlight');
+ }
+
+ /**
+ * Clicks on a specific row.
+ * @param rowIndex The zero-based row index.
+ */
+ public async clickRow(rowIndex: number): Promise {
+ const rows = await this.#getRows();
+ if (rowIndex >= rows.length) {
+ throw new Error(
+ `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`,
+ );
+ }
+ await rows[rowIndex].click();
+ }
+
+ /**
+ * Clicks on a column header to sort by that column.
+ * @param columnIndex The zero-based column index.
+ */
+ public async clickColumnHeader(columnIndex: number): Promise {
+ const headers = await this.#getHeaderCells();
+ if (columnIndex >= headers.length) {
+ throw new Error(
+ `Column index ${columnIndex} is out of bounds. Grid has ${headers.length} columns.`,
+ );
+ }
+ await headers[columnIndex].click();
+ }
+}
diff --git a/libs/components/grids/testing/src/public-api.ts b/libs/components/grids/testing/src/public-api.ts
new file mode 100644
index 0000000000..f2267d638d
--- /dev/null
+++ b/libs/components/grids/testing/src/public-api.ts
@@ -0,0 +1,2 @@
+export { SkyGridHarness } from './modules/grid/grid-harness';
+export { SkyGridHarnessFilters } from './modules/grid/grid-harness-filters';
diff --git a/libs/components/grids/testing/tsconfig.spec.json b/libs/components/grids/testing/tsconfig.spec.json
new file mode 100644
index 0000000000..aad2a9f430
--- /dev/null
+++ b/libs/components/grids/testing/tsconfig.spec.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.spec.json",
+ "compilerOptions": {
+ "outDir": "../../../../out-tsc/spec",
+ "types": ["jasmine", "node"]
+ },
+ "include": ["**/*.spec.ts", "**/*.d.ts"]
+}
diff --git a/libs/components/list-builder/package.json b/libs/components/list-builder/package.json
index 4d414c095d..fefefdc623 100644
--- a/libs/components/list-builder/package.json
+++ b/libs/components/list-builder/package.json
@@ -16,6 +16,7 @@
},
"homepage": "https://github.com/blackbaud/skyux#readme",
"peerDependencies": {
+ "@angular/cdk": "^20.2.3",
"@angular/common": "^20.3.0",
"@angular/core": "^20.3.0",
"@skyux/core": "0.0.0-PLACEHOLDER",
diff --git a/libs/components/list-builder/project.json b/libs/components/list-builder/project.json
index b7d214052f..07b27d03ec 100644
--- a/libs/components/list-builder/project.json
+++ b/libs/components/list-builder/project.json
@@ -4,6 +4,7 @@
"projectType": "library",
"sourceRoot": "libs/components/list-builder/src",
"prefix": "sky",
+ "tags": ["component", "npm"],
"targets": {
"build": {
"executor": "@nx/angular:package",
@@ -19,7 +20,21 @@
"tsConfig": "libs/components/list-builder/tsconfig.lib.json"
}
},
- "defaultConfiguration": "production"
+ "defaultConfiguration": "production",
+ "dependsOn": [
+ "^build",
+ {
+ "projects": ["core"],
+ "target": "build"
+ }
+ ],
+ "inputs": [
+ "buildInputs",
+ "^buildInputs",
+ "{workspaceRoot}/libs/components/list-builder/testing/src/**/*",
+ "!{workspaceRoot}/libs/components/list-builder/testing/src/**/*.spec.ts",
+ "!{workspaceRoot}/libs/components/list-builder/testing/src/**/fixtures/**/*"
+ ]
},
"test": {
"executor": "@angular-devkit/build-angular:karma",
@@ -61,6 +76,5 @@
]
}
}
- },
- "tags": ["component", "npm"]
+ }
}
diff --git a/libs/components/list-builder/testing/eslint.config.js b/libs/components/list-builder/testing/eslint.config.js
new file mode 100644
index 0000000000..b4c331fb6a
--- /dev/null
+++ b/libs/components/list-builder/testing/eslint.config.js
@@ -0,0 +1,5 @@
+const prettier = require('eslint-config-prettier');
+const baseConfig = require('../../../../eslint-base.config');
+const overrides = require('../../../../eslint-overrides.config');
+
+module.exports = [...baseConfig, ...overrides, prettier];
diff --git a/libs/components/list-builder/testing/karma.conf.js b/libs/components/list-builder/testing/karma.conf.js
new file mode 100644
index 0000000000..ffa1db843c
--- /dev/null
+++ b/libs/components/list-builder/testing/karma.conf.js
@@ -0,0 +1,19 @@
+// Karma configuration file, see link for more information
+// https://karma-runner.github.io/1.0/config/configuration-file.html
+
+const { join } = require('path');
+const getBaseKarmaConfig = require('../../../../karma.conf');
+
+module.exports = function (config) {
+ const baseConfig = getBaseKarmaConfig();
+ config.set({
+ ...baseConfig,
+ coverageReporter: {
+ ...baseConfig.coverageReporter,
+ dir: join(
+ __dirname,
+ '../../../../coverage/libs/components/list-builder/testing',
+ ),
+ },
+ });
+};
diff --git a/libs/components/list-builder/testing/ng-package.json b/libs/components/list-builder/testing/ng-package.json
new file mode 100644
index 0000000000..fbafcc4448
--- /dev/null
+++ b/libs/components/list-builder/testing/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ }
+}
diff --git a/libs/components/list-builder/testing/project.json b/libs/components/list-builder/testing/project.json
new file mode 100644
index 0000000000..14fbb8958d
--- /dev/null
+++ b/libs/components/list-builder/testing/project.json
@@ -0,0 +1,47 @@
+{
+ "name": "list-builder-testing",
+ "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "library",
+ "sourceRoot": "libs/components/list-builder/testing/src",
+ "prefix": "sky",
+ "tags": ["testing"],
+ "targets": {
+ "test": {
+ "executor": "@angular-devkit/build-angular:karma",
+ "options": {
+ "tsConfig": "libs/components/list-builder/testing/tsconfig.spec.json",
+ "karmaConfig": "libs/components/list-builder/testing/karma.conf.js",
+ "codeCoverage": true,
+ "codeCoverageExclude": ["**/fixtures/**"],
+ "styles": [
+ "libs/components/theme/src/lib/styles/sky.scss",
+ "libs/components/theme/src/lib/styles/themes/modern/styles.scss"
+ ],
+ "polyfills": [
+ "zone.js",
+ "zone.js/testing",
+ "libs/components/packages/src/polyfills.js"
+ ],
+ "inlineStyleLanguage": "scss",
+ "stylePreprocessorOptions": {
+ "includePaths": ["{workspaceRoot}"]
+ }
+ },
+ "configurations": {
+ "ci": {
+ "browsers": "ChromeHeadlessNoSandbox",
+ "codeCoverage": true,
+ "progress": false,
+ "sourceMap": true,
+ "watch": false
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "options": {
+ "lintFilePatterns": ["{projectRoot}/src/**/*.ts"]
+ }
+ }
+ }
+}
diff --git a/libs/components/list-builder/testing/src/modules/list/list-harness-filters.ts b/libs/components/list-builder/testing/src/modules/list/list-harness-filters.ts
new file mode 100644
index 0000000000..33b12b57b6
--- /dev/null
+++ b/libs/components/list-builder/testing/src/modules/list/list-harness-filters.ts
@@ -0,0 +1,7 @@
+import { SkyHarnessFilters } from '@skyux/core/testing';
+
+/**
+ * A set of criteria that can be used to filter a list of `SkyListHarness` instances.
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type
+export interface SkyListHarnessFilters extends SkyHarnessFilters {}
diff --git a/libs/components/list-builder/testing/src/modules/list/list-harness.spec.ts b/libs/components/list-builder/testing/src/modules/list/list-harness.spec.ts
new file mode 100644
index 0000000000..6fdc78d6b0
--- /dev/null
+++ b/libs/components/list-builder/testing/src/modules/list/list-harness.spec.ts
@@ -0,0 +1,73 @@
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { expect } from '@skyux-sdk/testing';
+import { SkyListModule } from '@skyux/list-builder';
+
+import { SkyListHarness } from './list-harness';
+
+//#region Test Component
+@Component({
+ selector: 'sky-list-test',
+ template: `
+
+
+ `,
+ standalone: false,
+})
+class TestComponent {
+ public data = [
+ { id: '1', name: 'Item 1' },
+ { id: '2', name: 'Item 2' },
+ ];
+
+ public otherData = [{ id: '3', name: 'Item 3' }];
+}
+//#endregion Test Component
+
+describe('List harness', () => {
+ async function setupTest(options: { dataSkyId?: string } = {}): Promise<{
+ listHarness: SkyListHarness;
+ fixture: ComponentFixture;
+ loader: HarnessLoader;
+ }> {
+ await TestBed.configureTestingModule({
+ declarations: [TestComponent],
+ imports: [SkyListModule],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const loader = TestbedHarnessEnvironment.loader(fixture);
+
+ let listHarness: SkyListHarness;
+
+ if (options.dataSkyId) {
+ listHarness = await loader.getHarness(
+ SkyListHarness.with({
+ dataSkyId: options.dataSkyId,
+ }),
+ );
+ } else {
+ listHarness = await loader.getHarness(SkyListHarness);
+ }
+
+ fixture.detectChanges();
+
+ return { listHarness, fixture, loader };
+ }
+
+ it('should get the list from its data-sky-id', async () => {
+ const { listHarness } = await setupTest({
+ dataSkyId: 'other-list',
+ });
+ const id = await listHarness.getId();
+ expect(id).toBeTruthy();
+ });
+
+ it('should get the list id', async () => {
+ const { listHarness } = await setupTest();
+ const id = await listHarness.getId();
+ expect(id).toContain('sky-list-cmp-');
+ });
+});
diff --git a/libs/components/list-builder/testing/src/modules/list/list-harness.ts b/libs/components/list-builder/testing/src/modules/list/list-harness.ts
new file mode 100644
index 0000000000..713feb4e1b
--- /dev/null
+++ b/libs/components/list-builder/testing/src/modules/list/list-harness.ts
@@ -0,0 +1,33 @@
+import { HarnessPredicate } from '@angular/cdk/testing';
+import { SkyComponentHarness } from '@skyux/core/testing';
+
+import { SkyListHarnessFilters } from './list-harness-filters';
+
+/**
+ * Harness for interacting with a list component in tests.
+ * @deprecated `SkyListComponent` and its features are deprecated. Use the data manager instead.
+ */
+export class SkyListHarness extends SkyComponentHarness {
+ /**
+ * @internal
+ */
+ public static hostSelector = 'sky-list';
+
+ /**
+ * Gets a `HarnessPredicate` that can be used to search for a
+ * `SkyListHarness` that meets certain criteria.
+ */
+ public static with(
+ filters: SkyListHarnessFilters,
+ ): HarnessPredicate {
+ return SkyListHarness.getDataSkyIdPredicate(filters);
+ }
+
+ /**
+ * Gets the list's unique ID.
+ */
+ public async getId(): Promise {
+ const host = await this.host();
+ return await host.getAttribute('id');
+ }
+}
diff --git a/libs/components/list-builder/testing/src/public-api.ts b/libs/components/list-builder/testing/src/public-api.ts
new file mode 100644
index 0000000000..ed3902d2e0
--- /dev/null
+++ b/libs/components/list-builder/testing/src/public-api.ts
@@ -0,0 +1,2 @@
+export { SkyListHarness } from './modules/list/list-harness';
+export { SkyListHarnessFilters } from './modules/list/list-harness-filters';
diff --git a/libs/components/list-builder/testing/tsconfig.spec.json b/libs/components/list-builder/testing/tsconfig.spec.json
new file mode 100644
index 0000000000..aad2a9f430
--- /dev/null
+++ b/libs/components/list-builder/testing/tsconfig.spec.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.spec.json",
+ "compilerOptions": {
+ "outDir": "../../../../out-tsc/spec",
+ "types": ["jasmine", "node"]
+ },
+ "include": ["**/*.spec.ts", "**/*.d.ts"]
+}
diff --git a/libs/components/text-editor/package.json b/libs/components/text-editor/package.json
index fb5b65b463..93ebd658bc 100644
--- a/libs/components/text-editor/package.json
+++ b/libs/components/text-editor/package.json
@@ -16,6 +16,7 @@
},
"homepage": "https://github.com/blackbaud/skyux#readme",
"peerDependencies": {
+ "@angular/cdk": "^20.2.3",
"@angular/common": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
diff --git a/libs/components/text-editor/project.json b/libs/components/text-editor/project.json
index 41a3b38ab4..a2b7d95488 100644
--- a/libs/components/text-editor/project.json
+++ b/libs/components/text-editor/project.json
@@ -4,6 +4,7 @@
"projectType": "library",
"sourceRoot": "libs/components/text-editor/src",
"prefix": "sky",
+ "tags": ["component", "npm"],
"targets": {
"build": {
"executor": "@nx/angular:package",
@@ -19,7 +20,21 @@
"tsConfig": "libs/components/text-editor/tsconfig.lib.json"
}
},
- "defaultConfiguration": "production"
+ "defaultConfiguration": "production",
+ "dependsOn": [
+ "^build",
+ {
+ "projects": ["core"],
+ "target": "build"
+ }
+ ],
+ "inputs": [
+ "buildInputs",
+ "^buildInputs",
+ "{workspaceRoot}/libs/components/text-editor/testing/src/**/*",
+ "!{workspaceRoot}/libs/components/text-editor/testing/src/**/*.spec.ts",
+ "!{workspaceRoot}/libs/components/text-editor/testing/src/**/fixtures/**/*"
+ ]
},
"test": {
"executor": "@angular-devkit/build-angular:karma",
@@ -61,6 +76,5 @@
]
}
}
- },
- "tags": ["component", "npm"]
+ }
}
diff --git a/libs/components/text-editor/testing/eslint.config.js b/libs/components/text-editor/testing/eslint.config.js
new file mode 100644
index 0000000000..b4c331fb6a
--- /dev/null
+++ b/libs/components/text-editor/testing/eslint.config.js
@@ -0,0 +1,5 @@
+const prettier = require('eslint-config-prettier');
+const baseConfig = require('../../../../eslint-base.config');
+const overrides = require('../../../../eslint-overrides.config');
+
+module.exports = [...baseConfig, ...overrides, prettier];
diff --git a/libs/components/text-editor/testing/karma.conf.js b/libs/components/text-editor/testing/karma.conf.js
new file mode 100644
index 0000000000..3138520932
--- /dev/null
+++ b/libs/components/text-editor/testing/karma.conf.js
@@ -0,0 +1,19 @@
+// Karma configuration file, see link for more information
+// https://karma-runner.github.io/1.0/config/configuration-file.html
+
+const { join } = require('path');
+const getBaseKarmaConfig = require('../../../../karma.conf');
+
+module.exports = function (config) {
+ const baseConfig = getBaseKarmaConfig();
+ config.set({
+ ...baseConfig,
+ coverageReporter: {
+ ...baseConfig.coverageReporter,
+ dir: join(
+ __dirname,
+ '../../../../coverage/libs/components/text-editor/testing',
+ ),
+ },
+ });
+};
diff --git a/libs/components/text-editor/testing/ng-package.json b/libs/components/text-editor/testing/ng-package.json
new file mode 100644
index 0000000000..fbafcc4448
--- /dev/null
+++ b/libs/components/text-editor/testing/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ }
+}
diff --git a/libs/components/text-editor/testing/project.json b/libs/components/text-editor/testing/project.json
new file mode 100644
index 0000000000..c58aefb7a7
--- /dev/null
+++ b/libs/components/text-editor/testing/project.json
@@ -0,0 +1,47 @@
+{
+ "name": "text-editor-testing",
+ "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "library",
+ "sourceRoot": "libs/components/text-editor/testing/src",
+ "prefix": "sky",
+ "tags": ["testing"],
+ "targets": {
+ "test": {
+ "executor": "@angular-devkit/build-angular:karma",
+ "options": {
+ "tsConfig": "libs/components/text-editor/testing/tsconfig.spec.json",
+ "karmaConfig": "libs/components/text-editor/testing/karma.conf.js",
+ "codeCoverage": true,
+ "codeCoverageExclude": ["**/fixtures/**"],
+ "styles": [
+ "libs/components/theme/src/lib/styles/sky.scss",
+ "libs/components/theme/src/lib/styles/themes/modern/styles.scss"
+ ],
+ "polyfills": [
+ "zone.js",
+ "zone.js/testing",
+ "libs/components/packages/src/polyfills.js"
+ ],
+ "inlineStyleLanguage": "scss",
+ "stylePreprocessorOptions": {
+ "includePaths": ["{workspaceRoot}"]
+ }
+ },
+ "configurations": {
+ "ci": {
+ "browsers": "ChromeHeadlessNoSandbox",
+ "codeCoverage": true,
+ "progress": false,
+ "sourceMap": true,
+ "watch": false
+ }
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "options": {
+ "lintFilePatterns": ["{projectRoot}/src/**/*.ts"]
+ }
+ }
+ }
+}
diff --git a/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness-filters.ts b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness-filters.ts
new file mode 100644
index 0000000000..ab6684f400
--- /dev/null
+++ b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness-filters.ts
@@ -0,0 +1,7 @@
+import { SkyHarnessFilters } from '@skyux/core/testing';
+
+/**
+ * A set of criteria that can be used to filter a list of `SkyTextEditorHarness` instances.
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type
+export interface SkyTextEditorHarnessFilters extends SkyHarnessFilters {}
diff --git a/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.spec.ts b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.spec.ts
new file mode 100644
index 0000000000..37541c63ef
--- /dev/null
+++ b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.spec.ts
@@ -0,0 +1,156 @@
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+ FormControl,
+ FormGroup,
+ ReactiveFormsModule,
+ Validators,
+} from '@angular/forms';
+import { expect } from '@skyux-sdk/testing';
+import { SkyTextEditorModule } from '@skyux/text-editor';
+
+import { SkyTextEditorHarness } from './text-editor-harness';
+
+//#region Test Component
+@Component({
+ selector: 'sky-text-editor-test',
+ template: `
+
+ `,
+ standalone: false,
+})
+class TestComponent {
+ public form = new FormGroup({
+ content: new FormControl(''),
+ otherContent: new FormControl(''),
+ noLabelContent: new FormControl(''),
+ requiredContent: new FormControl('', Validators.required),
+ });
+}
+//#endregion Test Component
+
+describe('Text editor harness', () => {
+ async function setupTest(options: { dataSkyId?: string } = {}): Promise<{
+ textEditorHarness: SkyTextEditorHarness;
+ fixture: ComponentFixture;
+ loader: HarnessLoader;
+ }> {
+ await TestBed.configureTestingModule({
+ declarations: [TestComponent],
+ imports: [SkyTextEditorModule, ReactiveFormsModule],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(TestComponent);
+ const loader = TestbedHarnessEnvironment.loader(fixture);
+
+ let textEditorHarness: SkyTextEditorHarness;
+
+ if (options.dataSkyId) {
+ textEditorHarness = await loader.getHarness(
+ SkyTextEditorHarness.with({
+ dataSkyId: options.dataSkyId,
+ }),
+ );
+ } else {
+ textEditorHarness = await loader.getHarness(SkyTextEditorHarness);
+ }
+
+ fixture.detectChanges();
+
+ return { textEditorHarness, fixture, loader };
+ }
+
+ it('should get the text editor from its data-sky-id', async () => {
+ const { textEditorHarness } = await setupTest({
+ dataSkyId: 'other-editor',
+ });
+ const labelText = await textEditorHarness.getLabelText();
+ expect(labelText).toBe('Other');
+ });
+
+ it('should get the label text', async () => {
+ const { textEditorHarness } = await setupTest();
+ const labelText = await textEditorHarness.getLabelText();
+ expect(labelText).toBe('Description');
+ });
+
+ it('should return undefined when no label is present', async () => {
+ const { textEditorHarness } = await setupTest({
+ dataSkyId: 'no-label-editor',
+ });
+ const labelText = await textEditorHarness.getLabelText();
+ expect(labelText).toBeUndefined();
+ });
+
+ it('should get the hint text', async () => {
+ const { textEditorHarness } = await setupTest();
+ const hintText = await textEditorHarness.getHintText();
+ expect(hintText).toBe('Enter a description');
+ });
+
+ it('should return undefined when no hint text is present', async () => {
+ const { textEditorHarness } = await setupTest({
+ dataSkyId: 'other-editor',
+ });
+ const hintText = await textEditorHarness.getHintText();
+ expect(hintText).toBeUndefined();
+ });
+
+ it('should check if the text editor is disabled', async () => {
+ const { textEditorHarness, fixture } = await setupTest();
+ expect(await textEditorHarness.isDisabled()).toBe(false);
+
+ fixture.componentInstance.form.get('content')?.disable();
+ fixture.detectChanges();
+
+ expect(await textEditorHarness.isDisabled()).toBe(true);
+ });
+
+ it('should check if the text editor has errors', async () => {
+ const { textEditorHarness } = await setupTest({
+ dataSkyId: 'required-editor',
+ });
+ expect(await textEditorHarness.hasErrors()).toBe(false);
+ });
+
+ it('should check if the text editor is focused', async () => {
+ const { textEditorHarness } = await setupTest();
+ expect(await textEditorHarness.isFocused()).toBe(false);
+ });
+
+ it('should check if the label is marked as required', async () => {
+ const { textEditorHarness } = await setupTest({
+ dataSkyId: 'required-editor',
+ });
+ expect(await textEditorHarness.isRequired()).toBe(true);
+ });
+
+ it('should return false for isRequired when no label is present', async () => {
+ const { textEditorHarness } = await setupTest({
+ dataSkyId: 'no-label-editor',
+ });
+ expect(await textEditorHarness.isRequired()).toBe(false);
+ });
+});
diff --git a/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.ts b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.ts
new file mode 100644
index 0000000000..aeda3d3068
--- /dev/null
+++ b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.ts
@@ -0,0 +1,86 @@
+import { HarnessPredicate } from '@angular/cdk/testing';
+import { SkyComponentHarness } from '@skyux/core/testing';
+
+import { SkyTextEditorHarnessFilters } from './text-editor-harness-filters';
+
+/**
+ * Harness for interacting with a text editor component in tests.
+ */
+export class SkyTextEditorHarness extends SkyComponentHarness {
+ /**
+ * @internal
+ */
+ public static hostSelector = 'sky-text-editor';
+
+ #getWrapper = this.locatorFor('.sky-text-editor');
+ #getLabelElement = this.locatorForOptional('.sky-control-label');
+ #getHintText = this.locatorForOptional('.sky-text-editor-hint-text');
+
+ /**
+ * Gets a `HarnessPredicate` that can be used to search for a
+ * `SkyTextEditorHarness` that meets certain criteria.
+ */
+ public static with(
+ filters: SkyTextEditorHarnessFilters,
+ ): HarnessPredicate {
+ return SkyTextEditorHarness.getDataSkyIdPredicate(filters);
+ }
+
+ /**
+ * Gets the label text for the text editor.
+ */
+ public async getLabelText(): Promise {
+ const label = await this.#getLabelElement();
+ if (label) {
+ return (await label.text()).trim();
+ }
+ return undefined;
+ }
+
+ /**
+ * Gets the hint text for the text editor.
+ */
+ public async getHintText(): Promise {
+ const hintText = await this.#getHintText();
+ if (hintText) {
+ const text = (await hintText.text()).trim();
+ return text || undefined;
+ }
+ return undefined;
+ }
+
+ /**
+ * Checks if the text editor is disabled.
+ */
+ public async isDisabled(): Promise {
+ const wrapper = await this.#getWrapper();
+ return await wrapper.hasClass('sky-text-editor-disabled');
+ }
+
+ /**
+ * Checks if the text editor has validation errors.
+ */
+ public async hasErrors(): Promise {
+ const wrapper = await this.#getWrapper();
+ return await wrapper.hasClass('sky-text-editor-invalid');
+ }
+
+ /**
+ * Checks if the text editor is focused.
+ */
+ public async isFocused(): Promise {
+ const wrapper = await this.#getWrapper();
+ return await wrapper.hasClass('sky-text-editor-wrapper-focused');
+ }
+
+ /**
+ * Checks if the label is marked as required.
+ */
+ public async isRequired(): Promise {
+ const label = await this.#getLabelElement();
+ if (label) {
+ return await label.hasClass('sky-control-label-required');
+ }
+ return false;
+ }
+}
diff --git a/libs/components/text-editor/testing/src/public-api.ts b/libs/components/text-editor/testing/src/public-api.ts
new file mode 100644
index 0000000000..5bef80f2db
--- /dev/null
+++ b/libs/components/text-editor/testing/src/public-api.ts
@@ -0,0 +1,2 @@
+export { SkyTextEditorHarness } from './modules/text-editor/text-editor-harness';
+export { SkyTextEditorHarnessFilters } from './modules/text-editor/text-editor-harness-filters';
diff --git a/libs/components/text-editor/testing/tsconfig.spec.json b/libs/components/text-editor/testing/tsconfig.spec.json
new file mode 100644
index 0000000000..aad2a9f430
--- /dev/null
+++ b/libs/components/text-editor/testing/tsconfig.spec.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.spec.json",
+ "compilerOptions": {
+ "outDir": "../../../../out-tsc/spec",
+ "types": ["jasmine", "node"]
+ },
+ "include": ["**/*.spec.ts", "**/*.d.ts"]
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index d382b60fcd..4ea0e32f97 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -89,6 +89,9 @@
"libs/components/forms/testing/src/public-api.ts"
],
"@skyux/grids": ["libs/components/grids/src/index.ts"],
+ "@skyux/grids/testing": [
+ "libs/components/grids/testing/src/public-api.ts"
+ ],
"@skyux/help-inline": ["libs/components/help-inline/src/index.ts"],
"@skyux/help-inline/testing": [
"libs/components/help-inline/testing/src/public-api.ts"
@@ -124,6 +127,9 @@
"@skyux/list-builder-view-grids/testing": [
"libs/components/list-builder-view-grids/testing/src/public-api.ts"
],
+ "@skyux/list-builder/testing": [
+ "libs/components/list-builder/testing/src/public-api.ts"
+ ],
"@skyux/lists": ["libs/components/lists/src/index.ts"],
"@skyux/lists/testing": [
"libs/components/lists/testing/src/public-api.ts"
@@ -183,6 +189,9 @@
"@skyux/tabs": ["libs/components/tabs/src/index.ts"],
"@skyux/tabs/testing": ["libs/components/tabs/testing/src/public-api.ts"],
"@skyux/text-editor": ["libs/components/text-editor/src/index.ts"],
+ "@skyux/text-editor/testing": [
+ "libs/components/text-editor/testing/src/public-api.ts"
+ ],
"@skyux/theme": ["libs/components/theme/src/index.ts"],
"@skyux/tiles": ["libs/components/tiles/src/index.ts"],
"@skyux/tiles/testing": [