diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5c17237f..9d27b34d 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -20,7 +20,7 @@ jobs:
run: cargo test
typescript_tests:
- name: Typescript tests
+ name: TypeScript tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
diff --git a/lib/angular-ui/src/lib/atoms/badge/badge.component.ts b/lib/angular-ui/src/lib/atoms/badge/badge.component.ts
index 07eb6a03..ede2666c 100644
--- a/lib/angular-ui/src/lib/atoms/badge/badge.component.ts
+++ b/lib/angular-ui/src/lib/atoms/badge/badge.component.ts
@@ -1,12 +1,25 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { defineCustomElement } from '@cg/ui/dist/components/cg-badge';
import { DefineCustomElement } from '../../define-custom-element';
+import { defineCustomElementComponent } from '../../custom-element-component';
+
+type CustomElement = HTMLCgBadgeElement;
@DefineCustomElement(defineCustomElement)
@Component({
selector: 'cg-badge',
changeDetection: ChangeDetectionStrategy.OnPush,
- template: ``,
+ template: ``,
})
-export class BadgeComponent {}
+export class BadgeComponent extends defineCustomElementComponent(
+ defineCustomElement,
+) {
+ public readonly theme = input('primary');
+
+ constructor() {
+ super();
+
+ this.elemProxyEffect(this.theme, 'theme');
+ }
+}
diff --git a/lib/angular-ui/src/lib/atoms/card/card.component.ts b/lib/angular-ui/src/lib/atoms/card/card.component.ts
index 187fdc2f..d72e57f4 100644
--- a/lib/angular-ui/src/lib/atoms/card/card.component.ts
+++ b/lib/angular-ui/src/lib/atoms/card/card.component.ts
@@ -1,12 +1,15 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { defineCustomElement } from '@cg/ui/dist/components/cg-card';
-import { DefineCustomElement } from '../../define-custom-element';
+import { defineCustomElementComponent } from '../../custom-element-component';
+
+type CustomElement = HTMLCgCardElement;
-@DefineCustomElement(defineCustomElement)
@Component({
selector: 'cg-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: ``,
})
-export class CardComponent {}
+export class CardComponent extends defineCustomElementComponent(
+ defineCustomElement,
+) {}
diff --git a/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.spec.ts b/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.spec.ts
index 36282712..762f57fc 100644
--- a/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.spec.ts
+++ b/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.spec.ts
@@ -13,6 +13,7 @@ describe('IconBtnComponent', () => {
fixture = TestBed.createComponent(IconBtnComponent);
component = fixture.componentInstance;
+ fixture.componentRef.setInput('ariaLabel', 'test');
fixture.detectChanges();
});
diff --git a/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.ts b/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.ts
index 439d4f6c..335d3244 100644
--- a/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.ts
+++ b/lib/angular-ui/src/lib/atoms/icon-btn/icon-btn.component.ts
@@ -1,12 +1,33 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { defineCustomElement } from '@cg/ui/dist/components/cg-icon-btn';
-import { DefineCustomElement } from '../../define-custom-element';
+import { defineCustomElementComponent } from '../../custom-element-component';
+
+type CustomElement = HTMLCgIconBtnElement;
-@DefineCustomElement(defineCustomElement)
@Component({
selector: 'cg-icon-btn',
changeDetection: ChangeDetectionStrategy.OnPush,
template: ``,
})
-export class IconBtnComponent {}
+export class IconBtnComponent extends defineCustomElementComponent(
+ defineCustomElement,
+) {
+ public readonly type = input('button');
+ public readonly disabled = input(false);
+ public readonly ariaLabel = input.required();
+ public readonly ariaHasPopup = input();
+ public readonly ariaExpanded = input();
+ public readonly ariaControls = input();
+
+ constructor() {
+ super();
+
+ this.elemProxyEffect(this.type, 'type');
+ this.elemProxyEffect(this.disabled, 'disabled');
+ this.elemProxyEffect(this.ariaLabel, 'ariaLabel');
+ this.elemProxyEffect(this.ariaHasPopup, 'ariaHasPopup');
+ this.elemProxyEffect(this.ariaExpanded, 'ariaExpanded');
+ this.elemProxyEffect(this.ariaControls, 'ariaControls');
+ }
+}
diff --git a/lib/ui/package.json b/lib/ui/package.json
index 657cd2c6..f8883911 100644
--- a/lib/ui/package.json
+++ b/lib/ui/package.json
@@ -22,6 +22,7 @@
},
"dependencies": {
"@cg/styles": "workspace:*",
+ "@cg/utils": "workspace:*",
"@stencil/core": "^4.23.1"
},
"devDependencies": {
diff --git a/lib/ui/src/coercion/coerce-boolean.ts b/lib/ui/src/coercion/coerce-boolean.ts
deleted file mode 100644
index 543fc0b2..00000000
--- a/lib/ui/src/coercion/coerce-boolean.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-export type LooseBoolean =
- | boolean
- | 'true'
- | 'false'
- | 1
- | 0
- | '1'
- | '0'
- | ''
- | null
- | undefined;
-
-const falseValues: LooseBoolean[] = ['false', false, '0', 0];
-const trueValues: LooseBoolean[] = ['', 'true', true, '1', 1, null, undefined];
-
-export function coerceBoolean(value: LooseBoolean): boolean {
- if (falseValues.includes(value)) {
- return false;
- }
-
- if (trueValues.includes(value)) {
- return true;
- }
-
- throw new Error(`Invalid boolean value: "${value}"`);
-}
diff --git a/lib/ui/src/coercion/index.ts b/lib/ui/src/coercion/index.ts
deleted file mode 100644
index f549d45a..00000000
--- a/lib/ui/src/coercion/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './coerce-boolean';
-export * from './coerce-theme';
diff --git a/lib/ui/src/components/atoms/badge/badge.e2e.ts b/lib/ui/src/components/atoms/badge/badge.e2e.ts
index fc32b3d3..73d3bc43 100644
--- a/lib/ui/src/components/atoms/badge/badge.e2e.ts
+++ b/lib/ui/src/components/atoms/badge/badge.e2e.ts
@@ -1,5 +1,6 @@
import { expect } from '@playwright/test';
import { test } from '@stencil/playwright';
+import { waitForError } from '../../../e2e-utils';
test.describe('cg-badge', () => {
const content = 'Badge content';
@@ -32,19 +33,14 @@ test.describe('cg-badge', () => {
});
test('should throw error for an invalid theme', async ({ page }) => {
- let errorThrown = false;
- page.on('pageerror', error => {
- if (error.message === 'Invalid theme: "garbage"') {
- errorThrown = true;
- }
- });
-
- await page.setContent(`
-
- ${content}
-
- `);
-
- expect(errorThrown).toBe(true);
+ await waitForError(
+ page,
+ 'Invalid theme provided: "garbage"',
+ page.setContent(`
+
+ ${content}
+
+ `),
+ );
});
});
diff --git a/lib/ui/src/components/atoms/badge/badge.tsx b/lib/ui/src/components/atoms/badge/badge.tsx
index f9ca741c..ef269f84 100644
--- a/lib/ui/src/components/atoms/badge/badge.tsx
+++ b/lib/ui/src/components/atoms/badge/badge.tsx
@@ -1,6 +1,6 @@
import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core';
import { Theme } from '../../../types';
-import { coerceTheme } from '../../../coercion';
+import { coerceTheme } from '../../../utils';
@Component({
tag: 'cg-badge',
diff --git a/lib/ui/src/components/atoms/focus-ring/focus-ring.e2e.ts b/lib/ui/src/components/atoms/focus-ring/focus-ring.e2e.ts
index f56bdd09..8a5e21dc 100644
--- a/lib/ui/src/components/atoms/focus-ring/focus-ring.e2e.ts
+++ b/lib/ui/src/components/atoms/focus-ring/focus-ring.e2e.ts
@@ -73,7 +73,7 @@ test.describe('cg-focus-ring', () => {
test('should throw error for an invalid theme', async ({ page }) => {
let errorThrown = false;
page.on('pageerror', error => {
- if (error.message === 'Invalid theme: "garbage"') {
+ if (error.message === 'Invalid theme provided: "garbage"') {
errorThrown = true;
}
});
diff --git a/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx b/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx
index 470178cd..19390b55 100644
--- a/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx
+++ b/lib/ui/src/components/atoms/focus-ring/focus-ring.tsx
@@ -1,6 +1,6 @@
import { Component, ComponentInterface, Host, Prop, h } from '@stencil/core';
import { Theme } from '../../../types';
-import { coerceTheme } from '../../../coercion';
+import { coerceTheme } from '../../../utils';
@Component({
tag: 'cg-focus-ring',
diff --git a/lib/ui/src/components/atoms/icon-btn/icon-btn.e2e.ts b/lib/ui/src/components/atoms/icon-btn/icon-btn.e2e.ts
new file mode 100644
index 00000000..84bf55a0
--- /dev/null
+++ b/lib/ui/src/components/atoms/icon-btn/icon-btn.e2e.ts
@@ -0,0 +1,85 @@
+import { test } from '@stencil/playwright';
+import { expect } from '@playwright/test';
+import { waitForError } from '../../../e2e-utils';
+
+test.describe('cg-icon-btn', () => {
+ const compLoc = 'cg-icon-btn';
+ const iconLoc = 'cg-profile-icon';
+ const focusRingLoc = 'cg-focus-ring';
+ const buttonLoc = 'button';
+
+ test('should render button with default attributes', async ({ page }) => {
+ await page.setContent(`
+
+
+
+ `);
+
+ const component = page.locator(compLoc);
+
+ await expect(component).toHaveAttribute('type', 'button');
+ await expect(component).toHaveAttribute('aria-label', 'test');
+ await expect(component).not.toHaveAttribute('disabled');
+ await expect(component).not.toHaveAttribute('aria-haspopup');
+ await expect(component).not.toHaveAttribute('aria-expanded');
+ await expect(component).not.toHaveAttribute('aria-controls');
+
+ await expect(component.locator(iconLoc)).toBeVisible();
+
+ const focusRing = component.locator(focusRingLoc);
+ const innerButton = component.locator(buttonLoc);
+
+ await expect(focusRing).not.toHaveAttribute('is-focused');
+ await innerButton.focus();
+ await expect(focusRing).toHaveAttribute('is-focused');
+ await innerButton.blur();
+ await expect(focusRing).not.toHaveAttribute('is-focused');
+ });
+
+ test('should render button with custom attributes', async ({ page }) => {
+ await page.setContent(`
+
+
+
+ `);
+
+ const component = page.locator(compLoc);
+
+ await expect(component).toHaveAttribute('type', 'submit');
+ await expect(component).toHaveAttribute('aria-label', 'test');
+ await expect(component).toHaveAttribute('disabled');
+ await expect(component).toHaveAttribute('aria-haspopup', 'menu');
+ await expect(component).toHaveAttribute('aria-expanded');
+ await expect(component).toHaveAttribute('aria-controls', 'menu-id');
+
+ await expect(component.locator(iconLoc)).toBeVisible();
+
+ const focusRing = component.locator(focusRingLoc);
+ const innerButton = component.locator(buttonLoc);
+
+ await expect(focusRing).not.toHaveAttribute('is-focused');
+ await innerButton.focus();
+ await expect(focusRing).not.toHaveAttribute('is-focused');
+ await innerButton.blur();
+ await expect(focusRing).not.toHaveAttribute('is-focused');
+ });
+
+ test.fixme('should throw error for missing aria-label', async ({ page }) => {
+ await waitForError(
+ page,
+ 'Error: Empty string provided where a non-empty string was expected',
+ page.setContent(`
+
+
+
+ `),
+ );
+ });
+});
diff --git a/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts b/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts
index ffb06e05..3a0d0e04 100644
--- a/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts
+++ b/lib/ui/src/components/atoms/icon-btn/icon-btn.stories.ts
@@ -16,8 +16,8 @@ const meta: Meta = {
disabled: false,
},
render: args => `
-
-
+
+
`,
};
diff --git a/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx b/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx
index 73a56533..ca895e4d 100644
--- a/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx
+++ b/lib/ui/src/components/atoms/icon-btn/icon-btn.tsx
@@ -1,5 +1,6 @@
import { Component, ComponentInterface, Prop, State, h } from '@stencil/core';
import { AriaHasPopup, ButtonType } from '../../../types';
+import { assertStringNonEmpty } from '../../../utils';
@Component({
tag: 'cg-icon-btn',
@@ -11,10 +12,17 @@ export class IconBtnComponent implements ComponentInterface {
public type: ButtonType = 'button';
@Prop({ reflect: true })
- public disabled?: boolean;
+ public disabled: boolean = false;
@Prop({ reflect: true, attribute: 'aria-label' })
- public ariaLabel!: string;
+ public get ariaLabel(): string {
+ return this.#ariaLabel;
+ }
+ public set ariaLabel(value: string) {
+ assertStringNonEmpty(value);
+ this.#ariaLabel = value;
+ }
+ #ariaLabel!: string;
@Prop({ reflect: true, attribute: 'aria-haspopup' })
public ariaHasPopup?: AriaHasPopup;
@@ -47,6 +55,9 @@ export class IconBtnComponent implements ComponentInterface {
);
}
+ public componentWillLoad(): void {
+ assertStringNonEmpty(this.ariaLabel);
+ }
private onFocused(): void {
this.isFocused = true;
diff --git a/lib/ui/src/e2e-utils/index.ts b/lib/ui/src/e2e-utils/index.ts
new file mode 100644
index 00000000..a83dc7a4
--- /dev/null
+++ b/lib/ui/src/e2e-utils/index.ts
@@ -0,0 +1 @@
+export * from './wait-for-error';
diff --git a/lib/ui/src/e2e-utils/wait-for-error.ts b/lib/ui/src/e2e-utils/wait-for-error.ts
new file mode 100644
index 00000000..4f5431a3
--- /dev/null
+++ b/lib/ui/src/e2e-utils/wait-for-error.ts
@@ -0,0 +1,14 @@
+import { Page } from '@playwright/test';
+
+export async function waitForError>(
+ page: Page,
+ errorMessage: string,
+ doFn: T,
+): Promise {
+ const [error] = await Promise.all([
+ page.waitForEvent('pageerror', error => error.message === errorMessage),
+ doFn,
+ ]);
+
+ return error;
+}
diff --git a/lib/ui/src/utils/assert-string-has-value.ts b/lib/ui/src/utils/assert-string-has-value.ts
new file mode 100644
index 00000000..f3b8bcbb
--- /dev/null
+++ b/lib/ui/src/utils/assert-string-has-value.ts
@@ -0,0 +1,11 @@
+import { isNil } from '@cg/utils';
+
+export function assertStringNonEmpty(value: string | undefined | null): string {
+ if (isNil(value) || value.trim() === '') {
+ throw new Error(
+ 'Empty string provided where a non-empty string was expected',
+ );
+ }
+
+ return value;
+}
diff --git a/lib/ui/src/coercion/coerce-theme.ts b/lib/ui/src/utils/coerce-theme.ts
similarity index 82%
rename from lib/ui/src/coercion/coerce-theme.ts
rename to lib/ui/src/utils/coerce-theme.ts
index b282a945..99c471f3 100644
--- a/lib/ui/src/coercion/coerce-theme.ts
+++ b/lib/ui/src/utils/coerce-theme.ts
@@ -2,7 +2,7 @@ import { Theme } from '../types';
export function coerceTheme(theme: unknown): Theme {
if (!isTheme(theme)) {
- throw new Error(`Invalid theme: "${theme}"`);
+ throw new Error(`Invalid theme provided: "${theme}"`);
}
return theme;
diff --git a/lib/ui/src/utils/index.ts b/lib/ui/src/utils/index.ts
new file mode 100644
index 00000000..3bcd96ef
--- /dev/null
+++ b/lib/ui/src/utils/index.ts
@@ -0,0 +1,2 @@
+export * from './assert-string-has-value';
+export * from './coerce-theme';
diff --git a/lib/utils/package.json b/lib/utils/package.json
new file mode 100644
index 00000000..fe11205d
--- /dev/null
+++ b/lib/utils/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@cg/utils",
+ "private": true,
+ "scripts": {
+ "build": "tsc"
+ },
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "main": "dist/index.js"
+}
diff --git a/lib/utils/src/index.ts b/lib/utils/src/index.ts
new file mode 100644
index 00000000..59bf2020
--- /dev/null
+++ b/lib/utils/src/index.ts
@@ -0,0 +1 @@
+export * from './nil';
diff --git a/lib/utils/src/nil.ts b/lib/utils/src/nil.ts
new file mode 100644
index 00000000..ebd4bfa9
--- /dev/null
+++ b/lib/utils/src/nil.ts
@@ -0,0 +1,9 @@
+export function isNil(
+ value: T | null | undefined,
+): value is null | undefined {
+ return value === null || value === undefined;
+}
+
+export function isNotNil(value: T | null | undefined): value is T {
+ return !isNil(value);
+}
diff --git a/lib/utils/tsconfig.json b/lib/utils/tsconfig.json
new file mode 100644
index 00000000..df130e2a
--- /dev/null
+++ b/lib/utils/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": ["./**/*.ts"],
+ "compilerOptions": {
+ "outDir": "dist",
+ "declaration": true,
+ "module": "ESNext",
+ "target": "ESNext"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 20456365..a714d876 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -226,6 +226,9 @@ importers:
'@cg/styles':
specifier: workspace:*
version: link:../styles
+ '@cg/utils':
+ specifier: workspace:*
+ version: link:../utils
'@stencil/core':
specifier: ^4.23.1
version: 4.23.1
@@ -255,6 +258,8 @@ importers:
specifier: ^8.4.7
version: 8.4.7(prettier@3.4.2)
+ lib/utils: {}
+
src/backend/integration:
devDependencies:
'@cg/backend':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 9f7ea5c9..7f4176af 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,6 +4,7 @@ packages:
- lib/nns-utils
- lib/styles
- lib/ui
+ - lib/utils
- src/backend/integration
- src/docs
- src/frontend
diff --git a/src/frontend/src/app/core/layout/secondary-navbar/secondary-navbar.component.ts b/src/frontend/src/app/core/layout/secondary-navbar/secondary-navbar.component.ts
index e82578f3..a2cf428a 100644
--- a/src/frontend/src/app/core/layout/secondary-navbar/secondary-navbar.component.ts
+++ b/src/frontend/src/app/core/layout/secondary-navbar/secondary-navbar.component.ts
@@ -148,7 +148,7 @@ import { UserAuthService } from '~core/services';
} @else {
-
+
}