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 { - + }