From af9b82c32de05e871268b6bea0ca1c17222e534a Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 4 May 2026 17:42:30 +0200 Subject: [PATCH 01/12] Fix a11y issues in dialog component --- playwright/cps-accessibility.spec.ts | 26 +- .../src/app/api-data/cps-dialog.json | 66 +++- .../src/app/api-data/types_map.json | 1 + .../dialog-content.component.scss | 8 +- .../dialog-page/dialog-page.component.html | 2 +- .../dialog-page/dialog-page.component.ts | 11 +- .../cps-tooltip/cps-tooltip.directive.ts | 12 +- .../services/cps-dialog/cps-dialog.service.ts | 4 +- .../cps-confirmation.component.html | 4 +- .../cps-confirmation.component.scss | 6 +- .../cps-dialog/cps-dialog.component.html | 54 ++- .../cps-dialog/cps-dialog.component.scss | 105 ++++-- .../cps-dialog/cps-dialog.component.ts | 334 ++++++++++++++++-- .../cps-dialog/utils/cps-dialog-config.ts | 59 +++- 14 files changed, 547 insertions(+), 145 deletions(-) diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index f05acecd..c08e2fce 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -37,19 +37,19 @@ const components: ComponentEntry[] = [ // await page.locator('cps-datepicker .cps-icon').first().click(); // } // }, - // { - // route: '/dialog', - // name: 'Dialog', - // selector: '[role="dialog"]', - // setup: async (page) => { - // await page.waitForSelector('.example-content'); - // await page - // .locator('.example-content cps-button') - // .filter({ hasText: /dialog/i }) - // .first() - // .click(); - // } - // }, + { + route: '/dialog', + name: 'Dialog', + selector: '[role="dialog"]', + setup: async (page) => { + await page.waitForSelector('.example-content'); + await page + .locator('.example-content cps-button') + .filter({ hasText: /dialog/i }) + .first() + .click(); + } + }, // { route: '/divider', name: 'Divider', selector: 'cps-divider' }, // { // route: '/expansion-panel', diff --git a/projects/composition/src/app/api-data/cps-dialog.json b/projects/composition/src/app/api-data/cps-dialog.json index 116441c1..a088560d 100644 --- a/projects/composition/src/app/api-data/cps-dialog.json +++ b/projects/composition/src/app/api-data/cps-dialog.json @@ -124,49 +124,71 @@ "optional": true, "readonly": false, "type": "string", - "description": "Identifies the element (or elements) that labels the element it is applied to." + "description": "Identifies the element that labels the element it is applied to. Takes precedence over ariaLabel." }, { - "name": "width", + "name": "ariaLabel", "optional": true, "readonly": false, "type": "string", - "description": "Width of the dialog." + "description": "Defines a string value that labels the dialog for assistive technologies when no visible title or ariaLabelledBy is present." }, { - "name": "height", + "name": "ariaDescribedBy", "optional": true, "readonly": false, "type": "string", - "description": "Height of the dialog." + "description": "Identifies the element that describes the dialog content for assistive technologies." + }, + { + "name": "autoFocus", + "optional": true, + "readonly": false, + "type": "string | boolean", + "default": "true", + "description": "Defines which element receives focus when the dialog opens.\n- 'dialog' - focuses the dialog container\n- 'first-tabbable' / true - focuses the first tabbable element\n- string (a CSS selector) - focuses the first matching element\n- false - disables auto-focus" + }, + { + "name": "width", + "optional": true, + "readonly": false, + "type": "string | number", + "description": "Width of the dialog, a number denoting pixels or a string." + }, + { + "name": "height", + "optional": true, + "readonly": false, + "type": "string | number", + "description": "Height of the dialog, a number denoting pixels or a string." }, { "name": "minWidth", "optional": true, "readonly": false, - "type": "string", - "description": "Min-width of the dialog." + "type": "string | number", + "description": "Min-width of the dialog, a number denoting pixels or a string." }, { "name": "minHeight", "optional": true, "readonly": false, - "type": "string", - "description": "Min-height of the dialog." + "type": "string | number", + "description": "Min-height of the dialog, a number denoting pixels or a string." }, { "name": "maxWidth", "optional": true, "readonly": false, - "type": "string", - "description": "Max-width of the dialog." + "type": "string | number", + "description": "Max-width of the dialog, a number denoting pixels or a string." }, { "name": "maxHeight", "optional": true, "readonly": false, - "type": "string", - "description": "Max-height of the dialog." + "type": "string | number", + "description": "Max-height of the dialog, a number denoting pixels or a string." }, { "name": "closeOnEscape", @@ -284,15 +306,15 @@ "name": "minX", "optional": true, "readonly": false, - "type": "number", - "description": "Minimum value for the left coordinate of dialog in dragging." + "type": "string | number", + "description": "Minimum value for the left coordinate of dialog in dragging, a number denoting pixels or a string." }, { "name": "minY", "optional": true, "readonly": false, - "type": "number", - "description": "Minimum value for the top coordinate of dialog in dragging." + "type": "string | number", + "description": "Minimum value for the top coordinate of dialog in dragging, a number denoting pixels or a string." }, { "name": "maximizable", @@ -319,6 +341,16 @@ } ] }, + "types": { + "description": "Defines the custom types used by the component or service.", + "values": [ + { + "name": "CpsDialogAutoFocusTarget", + "value": "\"dialog\" | \"first-tabbable\"", + "description": "Defines the auto-focus target when the dialog opens.\n- 'dialog' - focuses the dialog container itself\n- 'first-tabbable' - focuses the first tabbable element inside the dialog" + } + ] + }, "classes": { "description": "Defines classes exposed by the component or service.", "values": [ diff --git a/projects/composition/src/app/api-data/types_map.json b/projects/composition/src/app/api-data/types_map.json index ef7d8084..69d282f4 100644 --- a/projects/composition/src/app/api-data/types_map.json +++ b/projects/composition/src/app/api-data/types_map.json @@ -31,6 +31,7 @@ "CpsTooltipPosition": "tooltip", "CpsTooltipOpenOn": "tooltip", "CpsDialogConfig": "dialog", + "CpsDialogAutoFocusTarget": "dialog", "CpsDialogRef": "dialog", "CpsNotificationConfig": "notification", "CpsNotificationAppearance": "notification", diff --git a/projects/composition/src/app/components/dialog-content/dialog-content.component.scss b/projects/composition/src/app/components/dialog-content/dialog-content.component.scss index 721a97a0..ac637686 100644 --- a/projects/composition/src/app/components/dialog-content/dialog-content.component.scss +++ b/projects/composition/src/app/components/dialog-content/dialog-content.component.scss @@ -3,11 +3,11 @@ display: flex; flex-direction: column; align-items: center; - padding: 12px; + padding: 0.75rem; &-icon { - width: 40px; - height: 40px; + width: 2.5rem; + height: 2.5rem; } &-title { @@ -15,7 +15,7 @@ } &-button { - margin-top: 24px; + margin-top: 1.5rem; } } } diff --git a/projects/composition/src/app/pages/dialog-page/dialog-page.component.html b/projects/composition/src/app/pages/dialog-page/dialog-page.component.html index fb87edd6..5a9fcfa7 100644 --- a/projects/composition/src/app/pages/dialog-page/dialog-page.component.html +++ b/projects/composition/src/app/pages/dialog-page/dialog-page.component.html @@ -6,7 +6,7 @@ (clicked)="openConfirmationDialog()"> ; private _hideTimeout?: ReturnType; private _ariaTarget?: HTMLElement; - - private readonly _tooltipId = generateUniqueId('cps-tooltip'); private _rootFontSizePx = 16; private window: Window; @@ -167,6 +164,7 @@ export class CpsTooltipDirective implements OnInit, OnDestroy { // Visual appearance is delayed by tooltipOpenDelay. onFocus(): void { if (this.tooltipOpenOn() === 'hover' || this.tooltipOpenOn() === 'focus') { + if (!this._document.activeElement?.matches(':focus-visible')) return; this._ariaTarget = this._resolveAriaTarget(); clearTimeout(this._hideTimeout); clearTimeout(this._showTimeout); @@ -271,9 +269,11 @@ export class CpsTooltipDirective implements OnInit, OnDestroy { this._popup.classList.add('cps-tooltip'); this._popup.style.maxWidth = convertSize(this.tooltipMaxWidth()); this._popup.setAttribute('role', 'tooltip'); - this._popup.id = this._tooltipId; this._document.body.appendChild(this._popup); - this._ariaTarget?.setAttribute('aria-describedby', this._tooltipId); + this._ariaTarget?.setAttribute( + 'aria-description', + this._popup.textContent ?? '' + ); }; private _positionAndShow = (): void => { @@ -309,7 +309,7 @@ export class CpsTooltipDirective implements OnInit, OnDestroy { const popup = this._popup; this._popup = undefined; - this._ariaTarget?.removeAttribute('aria-describedby'); + this._ariaTarget?.removeAttribute('aria-description'); this._ariaTarget = undefined; if (destroyImmediately) { diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts index 739478d8..1a141953 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts @@ -71,8 +71,8 @@ export class CpsDialogService implements OnDestroy { if (!config.headerTitle) config.headerTitle = 'Confirm the action'; if (!config.headerIcon) config.headerIcon = 'warning'; if (!config.headerIconColor) config.headerIconColor = 'calm'; - if (!config.minWidth) config.minWidth = '400px'; - if (!config.maxWidth) config.maxWidth = '600px'; + if (!config.minWidth) config.minWidth = '25rem'; + if (!config.maxWidth) config.maxWidth = '37.5rem'; const dialogRef = this.appendDialogComponentToBody(config); const instance = this.dialogComponentRefMap.get(dialogRef)?.instance; diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.html b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.html index 3f68a7fb..0d1a2fac 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.html +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.html @@ -5,13 +5,13 @@ type="outlined" label="No" (clicked)="close(false)" - color="prepared"> + color="calm"> + color="calm"> diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.scss b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.scss index 3332651f..1845e734 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.scss +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.scss @@ -3,10 +3,10 @@ display: flex; flex-direction: column; align-items: center; - padding: 8px; + padding: 0.5rem; &-subtitle { - font-size: 16px; + font-size: 1rem; font-weight: normal; font-family: 'Source Sans Pro', sans-serif; color: var(--cps-color-text-dark); @@ -16,7 +16,7 @@ width: 100%; display: flex; justify-content: space-around; - padding-top: 28px; + padding-top: 1.75rem; } } } diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html index 4357b8e5..937a7790 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html @@ -42,18 +42,17 @@ (@animation.start)="onAnimationStart($event)" (@animation.done)="onAnimationEnd($event)" role="dialog" - [style.width]="config.width" - [style.height]="config.height" - [style.minWidth]="config.minWidth" - [style.minHeight]="config.minHeight" - [style.maxWidth]="maximized ? '' : config.maxWidth" - [style.maxHeight]="maximized ? '' : config.maxHeight"> - @if (resizable && !maximized) { -
- } + [attr.aria-modal]="config.modal !== false ? 'true' : null" + tabindex="-1" + [attr.aria-labelledby]="config.ariaLabelledBy || null" + [attr.aria-label]="ariaLabel" + [attr.aria-describedby]="config.ariaDescribedBy || null" + [style.width]="cvtSize(config.width)" + [style.height]="cvtSize(config.height)" + [style.minWidth]="cvtSize(config.minWidth)" + [style.minHeight]="cvtSize(config.minHeight)" + [style.maxWidth]="maximized ? '' : cvtSize(config.maxWidth)" + [style.maxHeight]="maximized ? '' : cvtSize(config.maxHeight)"> @if (config.showHeader !== false) {
+ [attr.tabindex]="draggable && !maximized ? '0' : null" + [attr.role]="draggable && !maximized ? 'button' : null" + [attr.aria-label]="draggable && !maximized ? 'Move dialog' : null" + [attr.aria-description]=" + draggable && !maximized + ? 'Use arrow keys to move the dialog.' + : null + " + (mousedown)="initDrag($event)" + (keydown)="onHeaderKeydown($event)" + (keyup)="onHeaderKeyup($event)">
@if (config.headerIcon) { @@ -94,7 +103,7 @@ [icon]="maximized ? 'minimize' : 'maximize'" [ariaLabel]="maximized ? 'Minimize dialog' : 'Maximize dialog'" size="small" - width="32" + width="2rem" color="graphite" type="borderless" (clicked)="toggleMaximized()"> @@ -106,7 +115,7 @@ icon="close-x-2" ariaLabel="Close dialog" size="small" - width="32" + width="2rem" color="graphite" type="borderless" (clicked)="hide()"> @@ -122,6 +131,21 @@ [class]="config.contentStyleClass || ''">
+ @if (resizable && !maximized) { +
+ +
+ }
}
diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss index bc2d0e6b..6299a371 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss @@ -1,6 +1,10 @@ +@use '../../../../../../../styles/mixins' as *; + $cps-overlay-background: rgba(0, 0, 0, 0.2); $header-drag-hover-background: var(--cps-color-highlight-hover); $header-drag-active-background: var(--cps-color-highlight-active); +$resizable-handle-hover-background: var(--cps-color-highlight-hover); +$resizable-handle-active-background: var(--cps-color-highlight-active); $animation-duration: 150ms; .cps-dialog-mask { @@ -43,7 +47,7 @@ $animation-duration: 150ms; } .cps-dialog-blurred-overlay { - backdrop-filter: blur(6px); + backdrop-filter: blur(0.375rem); } @keyframes cps-dialog-overlay-enter-animation { @@ -54,6 +58,7 @@ $animation-duration: 150ms; background-color: $cps-overlay-background; } } + @keyframes cps-dialog-overlay-leave-animation { from { background-color: $cps-overlay-background; @@ -66,21 +71,22 @@ $animation-duration: 150ms; @keyframes cps-dialog-blurred-overlay-enter-animation { from { background-color: transparent; - backdrop-filter: blur(0px); + backdrop-filter: blur(0); } to { background-color: $cps-overlay-background; - backdrop-filter: blur(6px); + backdrop-filter: blur(0.375rem); } } + @keyframes cps-dialog-blurred-overlay-leave-animation { from { background-color: $cps-overlay-background; - backdrop-filter: blur(6px); + backdrop-filter: blur(0.375rem); } to { background-color: transparent; - backdrop-filter: blur(0px); + backdrop-filter: blur(0); } } @@ -91,12 +97,16 @@ $animation-duration: 150ms; pointer-events: auto; transform: scale(1); position: relative; - border-radius: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; + box-shadow: 0 0.0625rem 0.1875rem rgba(0, 0, 0, 0.3); border: 0 none; font-family: 'Source Sans Pro', sans-serif; font-size: 1rem; font-weight: normal; + + &:focus-visible { + @include focus-ring(0.0625rem, 0.125rem, inherit); + } } .cps-dialog { @@ -106,23 +116,23 @@ $animation-duration: 150ms; background: #ffffff; color: var(--cps-color-text-dark); padding: 1rem; - border-top-right-radius: 4px; - border-top-left-radius: 4px; + border-top-right-radius: 0.25rem; + border-top-left-radius: 0.25rem; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; &.cps-dialog-header-left-bordered { - border-left: 4px solid var(--cps-color-surprise); + border-left: 0.25rem solid var(--cps-color-surprise); } &.cps-dialog-header-bottom-bordered { - border-bottom: 1px solid var(--cps-color-line-mid); + border-bottom: 0.0625rem solid var(--cps-color-line-mid); } .cps-dialog-header-left { display: flex; align-items: center; .cps-dialog-header-icon { - margin-right: 8px; + margin-right: 0.5rem; display: flex; } .cps-dialog-header-title { @@ -131,11 +141,11 @@ $animation-duration: 150ms; font-size: 1.25rem; } .cps-dialog-header-info-circle { - margin-left: 8px; + margin-left: 0.5rem; cursor: default; display: flex; cps-info-circle { - margin-top: 2px; + margin-top: 0.125rem; } } } @@ -149,8 +159,8 @@ $animation-duration: 150ms; } .cps-dialog .cps-dialog-content:last-of-type { - border-bottom-right-radius: 4px; - border-bottom-left-radius: 4px; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; } .cps-dialog-content { @@ -165,9 +175,18 @@ $animation-duration: 150ms; .cps-dialog-draggable { .cps-dialog-header { cursor: move; + overflow: visible; &:hover { background-color: $header-drag-hover-background; } + &:focus-visible { + background-color: $header-drag-hover-background; + @include focus-ring(0.125rem, 0.25rem, inherit); + } + &.cps-dialog-header-moving, + &.cps-dialog-header-moving:focus-visible { + background-color: $header-drag-active-background; + } .cps-dialog-header-left { .cps-dialog-header-title { cursor: unset; @@ -190,7 +209,7 @@ $animation-duration: 150ms; .cps-dialog-bottom-left .cps-dialog, .cps-dialog-bottom-right .cps-dialog { margin: 0.75rem; - transform: translate3d(0px, 0px, 0px); + transform: translate3d(0, 0, 0); } .cps-dialog-maximized { @@ -199,8 +218,8 @@ $animation-duration: 150ms; transform: none; width: 100vw !important; height: 100vh !important; - top: 0px !important; - left: 0px !important; + top: 0 !important; + left: 0 !important; max-height: 100%; height: 100%; } @@ -248,22 +267,46 @@ $animation-duration: 150ms; position: absolute; display: block; cursor: nwse-resize; - width: 12px; - height: 12px; + width: 1.5rem; + height: 1.5rem; right: 0; bottom: 0; - overflow: hidden; - &::after { - content: ''; + overflow: visible; + &:focus-visible { + background-color: $resizable-handle-hover-background; + } + &.cps-dialog-resizable-handle-resizing, + &.cps-dialog-resizable-handle-resizing:focus-visible { + background-color: $resizable-handle-active-background; + } + @include focus-ring(0.125rem, 0.25rem, 0); + position: absolute; + &:not(:focus-visible) { + &::before, + &::after { + display: none; + } + } + .cps-dialog-resizable-handle-grip { position: absolute; + inset: 0; + overflow: hidden; display: block; - width: 40px; - height: 0; - box-shadow: 0 0px 0 1px var(--cps-color-calm), - 0 7px 0 1px var(--cps-color-calm), 0 14px 0 1px var(--cps-color-calm); - transform: translate(-50%, -50%) rotate(-45deg) scale(0.5); - top: 50%; - left: 50%; + &::after { + content: ''; + position: absolute; + display: block; + width: 3.125rem; + height: 0; + box-shadow: + 0 0 0 0.0625rem var(--cps-color-calm), + 0 0.4375rem 0 0.0625rem var(--cps-color-calm), + 0 0.875rem 0 0.0625rem var(--cps-color-calm), + 0 1.3125rem 0 0.0625rem var(--cps-color-calm); + transform: translate(-50%, -50%) rotate(-45deg) scale(0.5); + top: 65%; + left: 65%; + } } } diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index 4a6cc711..302cdd7e 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -19,6 +19,7 @@ import { Inject, NgZone, OnDestroy, + OnInit, PLATFORM_ID, Renderer2, Type, @@ -29,13 +30,17 @@ import { import { SharedModule } from 'primeng/api'; import { DomHandler } from 'primeng/dom'; import { ZIndexUtils } from 'primeng/utils'; +import { PrimeNG } from 'primeng/config'; +import { + convertSize, + parseSize +} from '../../../../../utils/internal/size-utils'; import { CpsDialogContentDirective } from '../../directives/cps-dialog-content.directive'; import { CpsDialogConfig } from '../../../utils/cps-dialog-config'; import { CpsDialogRef } from '../../../utils/cps-dialog-ref'; import { CpsButtonComponent } from '../../../../../components/cps-button/cps-button.component'; import { CpsInfoCircleComponent } from '../../../../../components/cps-info-circle/cps-info-circle.component'; import { CpsIconComponent } from '../../../../../components/cps-icon/cps-icon.component'; -import { PrimeNG } from 'primeng/config'; const showAnimation = animation([ style({ transform: '{{transform}}', opacity: 0 }), @@ -49,6 +54,8 @@ const hideAnimation = animation([ type Nullable = T | null | undefined; type VoidListener = () => void | null | undefined; +const MIN_DRAG_VISIBLE_REM = 3; + @Component({ selector: 'cps-dialog', imports: [ @@ -70,7 +77,7 @@ type VoidListener = () => void | null | undefined; changeDetection: ChangeDetectionStrategy.Default, encapsulation: ViewEncapsulation.None }) -export class CpsDialogComponent implements AfterViewInit, OnDestroy { +export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { visible = true; componentRef: Nullable>; @@ -116,6 +123,10 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { documentDragEndListener!: VoidListener | null; + private _focusTrapListener: VoidListener | null = null; + + private _previouslyFocusedElement: HTMLElement | null = null; + _openStateChanged = new EventEmitter(); _dragStarted = new EventEmitter(); _dragEnded = new EventEmitter(); @@ -123,12 +134,17 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { _resizeEnded = new EventEmitter(); _maximizedStateChanged = new EventEmitter(); + get ariaLabel(): string | null { + if (this.config.ariaLabelledBy) return null; + return this.config.ariaLabel || this.config.headerTitle || null; + } + get minX(): number { - return this.config.minX ? this.config.minX : 0; + return this._toPx(this.config.minX); } get minY(): number { - return this.config.minY ? this.config.minY : 0; + return this._toPx(this.config.minY); } get keepInViewport(): boolean { @@ -181,6 +197,18 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { public primeNG: PrimeNG ) {} + ngOnInit(): void { + if ( + !this.config.ariaLabel?.trim() && + !this.config.ariaLabelledBy?.trim() && + !this.config.headerTitle?.trim() + ) { + console.warn( + 'CpsDialogComponent: dialog has no accessible name. Provide ariaLabel, ariaLabelledBy, or headerTitle.' + ); + } + } + ngAfterViewInit() { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.loadChildComponent(this.childComponentType!); @@ -191,6 +219,11 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { this._cdRef.detectChanges(); } + ngOnDestroy() { + this.onContainerDestroy(); + this.componentRef?.destroy(); + } + loadChildComponent(componentType: Type) { const viewContainerRef = this.insertionPoint?.viewContainerRef; viewContainerRef?.clear(); @@ -218,6 +251,8 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { case 'visible': this.container = event.element; this.wrapper = (this.container as HTMLDivElement).parentElement; + this._previouslyFocusedElement = this.document + .activeElement as HTMLElement; this.moveOnTop(); if (this.parent) { this.unbindGlobalListeners(); @@ -227,7 +262,6 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { if (this.config.modal !== false) { this.enableModality(); } - this.focus(); break; case 'void': @@ -249,6 +283,7 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { this._dialogRef.destroy(); } else { this._openStateChanged.emit(); + this.focus(); } } @@ -263,6 +298,9 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { this.disableModality(); } this.container = null; + + this._previouslyFocusedElement?.focus(); + this._previouslyFocusedElement = null; } close() { @@ -315,16 +353,166 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { } focus() { - const focusable = DomHandler.getFocusableElements( - this.container as HTMLDivElement + const autoFocus = this.config.autoFocus ?? true; + if (autoFocus === false) return; + + const containerEl = this.container as HTMLDivElement | null; + if (!containerEl) return; + + this.zone.runOutsideAngular(() => { + setTimeout(() => { + if (autoFocus === 'dialog') { + containerEl.focus(); + return; + } + + if (typeof autoFocus === 'string' && autoFocus !== 'first-tabbable') { + const target = containerEl.querySelector(autoFocus); + if (target) { + target.focus(); + return; + } + } + + // 'first-tabbable' or true (default) + const focusable: HTMLElement[] = + DomHandler.getFocusableElements(containerEl); + if (focusable && focusable.length > 0) { + focusable[0].focus(); + } else { + containerEl.focus(); + } + }, 5); + }); + } + + onResizeHandleKeydown(event: KeyboardEvent): void { + if (!this.resizable || this.maximized) return; + if ( + !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) + ) + return; + + event.preventDefault(); + + const handleEl = event.target as HTMLElement; + this.renderer.addClass(handleEl, 'cps-dialog-resizable-handle-resizing'); + + const containerEl = this.container as HTMLDivElement; + const step = this._rootFontSizePx; + + let newWidth = DomHandler.getOuterWidth(containerEl); + let newHeight = DomHandler.getOuterHeight(containerEl); + + if (event.key === 'ArrowRight') newWidth += step; + else if (event.key === 'ArrowLeft') newWidth -= step; + else if (event.key === 'ArrowDown') newHeight += step; + else if (event.key === 'ArrowUp') newHeight -= step; + + const minW = this._toPx(this.config.minWidth); + const minH = this._toPx(this.config.minHeight); + const maxW = this.config.maxWidth + ? this._toPx(this.config.maxWidth) + : Infinity; + const maxH = this.config.maxHeight + ? this._toPx(this.config.maxHeight) + : Infinity; + const viewport = DomHandler.getViewport(); + const offset = containerEl.getBoundingClientRect(); + + newWidth = Math.max( + minW, + Math.min(newWidth, maxW, viewport.width - offset.left) ); - if (focusable && focusable.length > 0) { - this.zone.runOutsideAngular(() => { - setTimeout(() => focusable[0].focus(), 5); - }); + newHeight = Math.max( + minH, + Math.min(newHeight, maxH, viewport.height - offset.top) + ); + + this._style.width = this._pxToRem(newWidth); + this._style.height = this._pxToRem(newHeight); + containerEl.style.width = this._pxToRem(newWidth); + containerEl.style.height = this._pxToRem(newHeight); + } + + onResizeHandleKeyup(event: KeyboardEvent): void { + if ( + ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) + ) { + this.renderer.removeClass( + event.target as HTMLElement, + 'cps-dialog-resizable-handle-resizing' + ); + } + } + + onHeaderKeyup(event: KeyboardEvent): void { + if ( + event.target === this.headerViewChild?.nativeElement && + ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) + ) { + const headerEl = this.headerViewChild + ?.nativeElement as HTMLElement | null; + if (headerEl) + this.renderer.removeClass(headerEl, 'cps-dialog-header-moving'); } } + onHeaderKeydown(event: KeyboardEvent): void { + if (!this.draggable || this.maximized) return; + if (event.target !== this.headerViewChild?.nativeElement) return; + if ( + !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) + ) + return; + + event.preventDefault(); + + const headerEl = this.headerViewChild?.nativeElement as HTMLElement | null; + if (headerEl) this.renderer.addClass(headerEl, 'cps-dialog-header-moving'); + + const containerEl = this.container as HTMLDivElement; + const step = this._rootFontSizePx; + const offset = containerEl.getBoundingClientRect(); + + containerEl.style.position = 'fixed'; + containerEl.style.margin = '0'; + + let newLeft = offset.left; + let newTop = offset.top; + + if (event.key === 'ArrowLeft') newLeft -= step; + else if (event.key === 'ArrowRight') newLeft += step; + else if (event.key === 'ArrowUp') newTop -= step; + else if (event.key === 'ArrowDown') newTop += step; + + if (this.keepInViewport) { + const containerWidth = DomHandler.getOuterWidth(containerEl); + const containerHeight = DomHandler.getOuterHeight(containerEl); + const viewport = DomHandler.getViewport(); + newLeft = Math.max( + this.minX, + Math.min(newLeft, viewport.width - containerWidth) + ); + newTop = Math.max( + this.minY, + Math.min(newTop, viewport.height - containerHeight) + ); + } else { + const containerWidth = DomHandler.getOuterWidth(containerEl); + ({ left: newLeft, top: newTop } = this._clampDragPos( + newLeft, + newTop, + containerWidth + )); + } + + this._style.left = this._pxToRem(newLeft); + this._style.top = this._pxToRem(newTop); + containerEl.style.left = this._pxToRem(newLeft); + containerEl.style.top = this._pxToRem(newTop); + } + toggleMaximized(value?: boolean) { if (!this.maximizable) return; if (typeof value === 'boolean') { @@ -379,8 +567,8 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { : 0; let newWidth = containerWidth + deltaX; let newHeight = containerHeight + deltaY; - const minWidth = (this.container as HTMLDivElement).style.minWidth; - const minHeight = (this.container as HTMLDivElement).style.minHeight; + const minWidth = this._toPx(this.config.minWidth); + const minHeight = this._toPx(this.config.minHeight); const offset = (this.container as HTMLDivElement).getBoundingClientRect(); const viewport = DomHandler.getViewport(); const hasBeenDragged = @@ -393,23 +581,25 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { } if ( - (!minWidth || newWidth > parseInt(minWidth)) && + (!minWidth || newWidth > minWidth) && offset.left + newWidth < viewport.width ) { const newContentWidth = contentWidth + newWidth - containerWidth; const newHeaderWidth = headerWidth + newWidth - containerWidth; - this._style.width = - Math.max(newWidth, newContentWidth, newHeaderWidth) + 'px'; + this._style.width = this._pxToRem( + Math.max(newWidth, newContentWidth, newHeaderWidth) + ); (this.container as HTMLDivElement).style.width = this._style.width; } if ( - (!minHeight || newHeight > parseInt(minHeight)) && + (!minHeight || newHeight > minHeight) && offset.top + newHeight < viewport.height ) { const newContentHeight = contentHeight + newHeight - containerHeight; - this._style.height = - Math.max(newHeight, headerHeight + newContentHeight) + 'px'; + this._style.height = this._pxToRem( + Math.max(newHeight, headerHeight + newContentHeight) + ); (this.container as HTMLDivElement).style.height = this._style.height; } @@ -467,21 +657,29 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { if (this.keepInViewport) { if (leftPos >= this.minX && leftPos + containerWidth < viewport.width) { - this._style.left = leftPos + 'px'; + this._style.left = this._pxToRem(leftPos); this.lastPageX = event.pageX; - (this.container as HTMLDivElement).style.left = leftPos + 'px'; + (this.container as HTMLDivElement).style.left = + this._pxToRem(leftPos); } if (topPos >= this.minY && topPos + containerHeight < viewport.height) { - this._style.top = topPos + 'px'; + this._style.top = this._pxToRem(topPos); this.lastPageY = event.pageY; - (this.container as HTMLDivElement).style.top = topPos + 'px'; + (this.container as HTMLDivElement).style.top = this._pxToRem(topPos); } } else { + const clamped = this._clampDragPos(leftPos, topPos, containerWidth); this.lastPageX = event.pageX; - (this.container as HTMLDivElement).style.left = leftPos + 'px'; + (this.container as HTMLDivElement).style.left = this._pxToRem( + clamped.left + ); this.lastPageY = event.pageY; - (this.container as HTMLDivElement).style.top = topPos + 'px'; + (this.container as HTMLDivElement).style.top = this._pxToRem( + clamped.top + ); + this._style.left = this._pxToRem(clamped.left); + this._style.top = this._pxToRem(clamped.top); } } } @@ -574,6 +772,10 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { this.bindDocumentEscapeListener(); } + if (this.config.modal !== false) { + this.bindFocusTrapListener(); + } + if (this.resizable) { this.bindDocumentResizeListeners(); } @@ -586,6 +788,7 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { unbindGlobalListeners() { this.unbindDocumentEscapeListener(); + this.unbindFocusTrapListener(); this.unbindDocumentResizeListeners(); this.unbindDocumentDragListener(); this.unbindDocumentDragEndListener(); @@ -600,7 +803,7 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { documentTarget, 'keydown', (event) => { - if (event.which === 27) { + if (event.key === 'Escape') { if ( parseInt((this.container as HTMLDivElement).style.zIndex) === ZIndexUtils.getCurrent() @@ -619,6 +822,49 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { } } + bindFocusTrapListener() { + if (!isPlatformBrowser(this.platformId) || !this.container) return; + + this._focusTrapListener = this.renderer.listen( + this.container, + 'keydown', + (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + + const focusable = DomHandler.getFocusableElements( + this.container as HTMLDivElement + ); + if (!focusable || focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = this.document.activeElement; + + if (event.shiftKey) { + if (active === first || active === this.container) { + event.preventDefault(); + last.focus(); + } + } else { + if (active === last || active === this.container) { + event.preventDefault(); + first.focus(); + } + } + } + ); + } + + unbindFocusTrapListener() { + if (this._focusTrapListener) { + this._focusTrapListener(); + this._focusTrapListener = null; + } + } + unbindMaskClickListener() { if (this.maskClickListener) { this.maskClickListener(); @@ -626,8 +872,38 @@ export class CpsDialogComponent implements AfterViewInit, OnDestroy { } } - ngOnDestroy() { - this.onContainerDestroy(); - this.componentRef?.destroy(); + private get _rootFontSizePx(): number { + return parseFloat(getComputedStyle(this.document.documentElement).fontSize); + } + + private _pxToRem(px: number): string { + return `${px / this._rootFontSizePx}rem`; + } + + private _toPx(size: number | string | undefined, fallback = 0): number { + if (size == null) return fallback; + const str = convertSize(size); + if (!str) return fallback; + const { value, unit } = parseSize(str); + if (unit === 'px') return value; + if (unit === 'rem' || unit === 'em') return value * this._rootFontSizePx; + return fallback; + } + + cvtSize(size: number | string | undefined): string { + return size != null ? convertSize(size) : ''; + } + + private _clampDragPos( + left: number, + top: number, + containerWidth: number + ): { left: number; top: number } { + const { width, height } = DomHandler.getViewport(); + const m = MIN_DRAG_VISIBLE_REM * this._rootFontSizePx; + return { + left: Math.max(m - containerWidth, Math.min(left, width - m)), + top: Math.max(0, Math.min(top, height - m)) + }; } } diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-config.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-config.ts index b8fe4293..268cbed2 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-config.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-config.ts @@ -1,5 +1,13 @@ import { CpsTooltipPosition } from '../../../directives/cps-tooltip/cps-tooltip.directive'; +/** + * Defines the auto-focus target when the dialog opens. + * - 'dialog' - focuses the dialog container itself + * - 'first-tabbable' - focuses the first tabbable element inside the dialog + * @group Types + */ +export type CpsDialogAutoFocusTarget = 'dialog' | 'first-tabbable'; + /** * Configuration for the dialog service. * @group Interface @@ -42,33 +50,50 @@ export class CpsDialogConfig { */ showHeaderBottomBorder?: boolean; /** - * Identifies the element (or elements) that labels the element it is applied to. + * Identifies the element that labels the element it is applied to. Takes precedence over ariaLabel. */ ariaLabelledBy?: string; /** - * Width of the dialog. + * Defines a string value that labels the dialog for assistive technologies when no visible title or ariaLabelledBy is present. + */ + ariaLabel?: string; + /** + * Identifies the element that describes the dialog content for assistive technologies. + */ + ariaDescribedBy?: string; + /** + * Defines which element receives focus when the dialog opens. + * - 'dialog' - focuses the dialog container + * - 'first-tabbable' / true - focuses the first tabbable element + * - string (a CSS selector) - focuses the first matching element + * - false - disables auto-focus + * @default true + */ + autoFocus?: CpsDialogAutoFocusTarget | string | boolean; + /** + * Width of the dialog, a number denoting pixels or a string. */ - width?: string; + width?: number | string; /** - * Height of the dialog. + * Height of the dialog, a number denoting pixels or a string. */ - height?: string; + height?: number | string; /** - * Min-width of the dialog. + * Min-width of the dialog, a number denoting pixels or a string. */ - minWidth?: string; + minWidth?: number | string; /** - * Min-height of the dialog. + * Min-height of the dialog, a number denoting pixels or a string. */ - minHeight?: string; + minHeight?: number | string; /** - * Max-width of the dialog. + * Max-width of the dialog, a number denoting pixels or a string. */ - maxWidth?: string; + maxWidth?: number | string; /** - * Max-height of the dialog. + * Max-height of the dialog, a number denoting pixels or a string. */ - maxHeight?: string; + maxHeight?: number | string; /** * Specifies if pressing escape key should hide the dialog. */ @@ -134,13 +159,13 @@ export class CpsDialogConfig { */ keepInViewport?: boolean; /** - * Minimum value for the left coordinate of dialog in dragging. + * Minimum value for the left coordinate of dialog in dragging, a number denoting pixels or a string. */ - minX?: number; + minX?: number | string; /** - * Minimum value for the top coordinate of dialog in dragging. + * Minimum value for the top coordinate of dialog in dragging, a number denoting pixels or a string. */ - minY?: number; + minY?: number | string; /** * Determines whether the dialog can be displayed full screen. */ From 669c86fe8e464ce94d11881fe35b4e0386e883f4 Mon Sep 17 00:00:00 2001 From: fateeand Date: Mon, 4 May 2026 18:37:01 +0200 Subject: [PATCH 02/12] listen to root font size --- .../cps-dialog/cps-dialog.component.ts | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index 302cdd7e..d1f60f0b 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -123,6 +123,8 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { documentDragEndListener!: VoidListener | null; + private _windowResizeCacheListener: VoidListener | null = null; + private _focusTrapListener: VoidListener | null = null; private _previouslyFocusedElement: HTMLElement | null = null; @@ -134,6 +136,17 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { _resizeEnded = new EventEmitter(); _maximizedStateChanged = new EventEmitter(); + private _rootFontSizePxCache: number | null = null; + private get _rootFontSizePx(): number { + if (!isPlatformBrowser(this.platformId)) return 16; + if (this._rootFontSizePxCache == null) { + this._rootFontSizePxCache = parseFloat( + getComputedStyle(this.document.documentElement).fontSize || '16' + ); + } + return this._rootFontSizePxCache; + } + get ariaLabel(): string | null { if (this.config.ariaLabelledBy) return null; return this.config.ariaLabel || this.config.headerTitle || null; @@ -198,6 +211,16 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { ) {} ngOnInit(): void { + if (isPlatformBrowser(this.platformId)) { + const win = this.document.defaultView as Window; + this._windowResizeCacheListener = this.renderer.listen( + win, + 'resize', + () => { + this._rootFontSizePxCache = null; + } + ); + } if ( !this.config.ariaLabel?.trim() && !this.config.ariaLabelledBy?.trim() && @@ -220,6 +243,8 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy() { + this._windowResizeCacheListener?.(); + this._windowResizeCacheListener = null; this.onContainerDestroy(); this.componentRef?.destroy(); } @@ -872,10 +897,6 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { } } - private get _rootFontSizePx(): number { - return parseFloat(getComputedStyle(this.document.documentElement).fontSize); - } - private _pxToRem(px: number): string { return `${px / this._rootFontSizePx}rem`; } From b0f258caa1f818992d852814ab856493853b1a2e Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 5 May 2026 11:51:58 +0200 Subject: [PATCH 03/12] update dialog a11y tests --- playwright/cps-accessibility.spec.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index c08e2fce..1b38be33 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -39,14 +39,25 @@ const components: ComponentEntry[] = [ // }, { route: '/dialog', - name: 'Dialog', + name: 'Confirmation dialog', selector: '[role="dialog"]', setup: async (page) => { await page.waitForSelector('.example-content'); await page .locator('.example-content cps-button') - .filter({ hasText: /dialog/i }) - .first() + .filter({ hasText: /Confirmation dialog/i }) + .click(); + } + }, + { + route: '/dialog', + name: 'Regular dialog', + selector: '[role="dialog"]', + setup: async (page) => { + await page.waitForSelector('.example-content'); + await page + .locator('.example-content cps-button') + .filter({ hasText: /Regular dialog/i }) .click(); } }, From b5fbb4035e4f7b0ef02bccdf9cd6ea157a2b3e9b Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 5 May 2026 15:25:08 +0200 Subject: [PATCH 04/12] address copilot feedback Co-authored-by: Copilot --- playwright/cps-accessibility.spec.ts | 36 ++++ .../src/app/api-data/cps-dialog.json | 8 +- .../cps-button/cps-button.component.ts | 12 +- .../cps-tooltip/cps-tooltip.directive.ts | 13 +- .../cps-dialog/cps-dialog.component.html | 40 ++-- .../cps-dialog/cps-dialog.component.scss | 16 +- .../cps-dialog/cps-dialog.component.ts | 185 ++++++++++++------ .../cps-dialog/utils/cps-dialog-ref.ts | 60 +++--- .../src/lib/utils/internal/size-utils.ts | 36 ++-- 9 files changed, 257 insertions(+), 149 deletions(-) diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 1b38be33..ff5efa06 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -61,6 +61,42 @@ const components: ComponentEntry[] = [ .click(); } }, + { + route: '/dialog', + name: 'Draggable dialog', + selector: '[role="dialog"]', + setup: async (page) => { + await page.waitForSelector('.example-content'); + await page + .locator('.example-content cps-button') + .filter({ hasText: /Draggable dialog/i }) + .click(); + } + }, + { + route: '/dialog', + name: 'Resizable dialog', + selector: '[role="dialog"]', + setup: async (page) => { + await page.waitForSelector('.example-content'); + await page + .locator('.example-content cps-button') + .filter({ hasText: /Resizable dialog/i }) + .click(); + } + }, + { + route: '/dialog', + name: 'Dialog with blurred background and container autofocus', + selector: '[role="dialog"]', + setup: async (page) => { + await page.waitForSelector('.example-content'); + await page + .locator('.example-content cps-button') + .filter({ hasText: /blurred background/i }) + .click(); + } + }, // { route: '/divider', name: 'Divider', selector: 'cps-divider' }, // { // route: '/expansion-panel', diff --git a/projects/composition/src/app/api-data/cps-dialog.json b/projects/composition/src/app/api-data/cps-dialog.json index a088560d..3f0c233c 100644 --- a/projects/composition/src/app/api-data/cps-dialog.json +++ b/projects/composition/src/app/api-data/cps-dialog.json @@ -434,22 +434,22 @@ }, { "name": "onDragStart", - "type": "Observable", + "type": "Observable", "description": "Event triggered on drag start." }, { "name": "onDragEnd", - "type": "Observable", + "type": "Observable", "description": "Event triggered on drag end." }, { "name": "onResizeStart", - "type": "Observable", + "type": "Observable", "description": "Event triggered on resize start." }, { "name": "onResizeEnd", - "type": "Observable", + "type": "Observable", "description": "Event triggered on resize end." }, { diff --git a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts index 89e036c3..fa35db68 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts @@ -154,12 +154,14 @@ export class CpsButtonComponent implements OnChanges { this.cvtHeight = convertSize(this.height); if (this.cvtHeight) { const parsedHeight = parseSize(this.cvtHeight); - const unit = parsedHeight.unit; - const size = parsedHeight.value * 0.4; - const isPx = unit === 'px'; + if (parsedHeight) { + const unit = parsedHeight.unit; + const size = parsedHeight.value * 0.4; + const isPx = unit === 'px'; - this.cvtFontSize = `${isPx ? Math.floor(size) : size}${unit}`; - this.cvtIconSize = `${isPx ? Math.ceil(size) : size}${unit}`; + this.cvtFontSize = `${isPx ? Math.floor(size) : size}${unit}`; + this.cvtIconSize = `${isPx ? Math.ceil(size) : size}${unit}`; + } } } else { switch (this.size) { diff --git a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts index 70dca7e1..3f9d8ab5 100644 --- a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts +++ b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts @@ -338,17 +338,18 @@ export class CpsTooltipDirective implements OnInit, OnDestroy { } private _getOffsetPx(): number { - const { value, unit } = parseSize(this.cvtTooltipOffset()); - if (unit === 'px') return value; - if (unit === 'rem') return value * this._rootFontSizePx; - if (unit === 'em') + const parsed = parseSize(this.cvtTooltipOffset()); + if (!parsed) throw new Error(`Unsupported value for tooltipOffset.`); + if (parsed.unit === 'px') return parsed.value; + if (parsed.unit === 'rem') return parsed.value * this._rootFontSizePx; + if (parsed.unit === 'em') return ( - value * + parsed.value * parseFloat( getComputedStyle(this._elementRef.nativeElement).fontSize || '16' ) ); - throw new Error(`Unsupported unit "${unit}" for tooltipOffset.`); + throw new Error(`Unsupported unit "${parsed.unit}" for tooltipOffset.`); } private _getCoords(): { left: number; top: number } | undefined { diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html index 937a7790..5dab7d94 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html @@ -47,12 +47,12 @@ [attr.aria-labelledby]="config.ariaLabelledBy || null" [attr.aria-label]="ariaLabel" [attr.aria-describedby]="config.ariaDescribedBy || null" - [style.width]="cvtSize(config.width)" - [style.height]="cvtSize(config.height)" - [style.minWidth]="cvtSize(config.minWidth)" - [style.minHeight]="cvtSize(config.minHeight)" - [style.maxWidth]="maximized ? '' : cvtSize(config.maxWidth)" - [style.maxHeight]="maximized ? '' : cvtSize(config.maxHeight)"> + [style.width]="cvtWidth" + [style.height]="cvtHeight" + [style.minWidth]="cvtMinWidth" + [style.minHeight]="cvtMinHeight" + [style.maxWidth]="cvtMaxWidth" + [style.maxHeight]="cvtMaxHeight"> @if (config.showHeader !== false) {
+ (mousedown)="initDrag($event)"> + @if (draggable && !maximized) { + + + }
@if (config.headerIcon) { @@ -137,7 +140,8 @@ style="z-index: 90" tabindex="0" role="button" - aria-label="Resize dialog. Use arrow keys to resize." + aria-label="Resize dialog" + aria-description="Use arrow keys to resize" (mousedown)="initResize($event)" (keydown)="onResizeHandleKeydown($event)" (keyup)="onResizeHandleKeyup($event)"> diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss index 6299a371..c95a814b 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.scss @@ -158,7 +158,8 @@ $animation-duration: 150ms; padding: 1rem; } -.cps-dialog .cps-dialog-content:last-of-type { +.cps-dialog .cps-dialog-content:last-of-type, +.cps-dialog-resizable .cps-dialog-content { border-bottom-right-radius: 0.25rem; border-bottom-left-radius: 0.25rem; } @@ -179,12 +180,7 @@ $animation-duration: 150ms; &:hover { background-color: $header-drag-hover-background; } - &:focus-visible { - background-color: $header-drag-hover-background; - @include focus-ring(0.125rem, 0.25rem, inherit); - } - &.cps-dialog-header-moving, - &.cps-dialog-header-moving:focus-visible { + &.cps-dialog-header-moving { background-color: $header-drag-active-background; } .cps-dialog-header-left { @@ -193,6 +189,12 @@ $animation-duration: 150ms; } } } + + .cps-dialog-drag-handle { + &:focus-visible { + @include focus-ring(0.25rem, 0.375rem, 0.25rem); + } + } } .cps-dialog .cps-dialog-header-action-buttons { diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index d1f60f0b..788cce3c 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -102,6 +102,8 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('mask') maskViewChild: Nullable; @ViewChild('content') contentViewChild: Nullable; @ViewChild('header') headerViewChild: Nullable; + @ViewChild('dragHandle', { read: ElementRef }) + dragHandleViewChild: Nullable; childComponentType: Nullable>; @@ -123,35 +125,49 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { documentDragEndListener!: VoidListener | null; - private _windowResizeCacheListener: VoidListener | null = null; - private _focusTrapListener: VoidListener | null = null; - + private _keyboardDragging = false; + private _keyboardResizing = false; private _previouslyFocusedElement: HTMLElement | null = null; _openStateChanged = new EventEmitter(); - _dragStarted = new EventEmitter(); - _dragEnded = new EventEmitter(); - _resizeStarted = new EventEmitter(); - _resizeEnded = new EventEmitter(); + _dragStarted = new EventEmitter(); + _dragEnded = new EventEmitter(); + _resizeStarted = new EventEmitter(); + _resizeEnded = new EventEmitter(); _maximizedStateChanged = new EventEmitter(); - private _rootFontSizePxCache: number | null = null; - private get _rootFontSizePx(): number { - if (!isPlatformBrowser(this.platformId)) return 16; - if (this._rootFontSizePxCache == null) { - this._rootFontSizePxCache = parseFloat( - getComputedStyle(this.document.documentElement).fontSize || '16' - ); - } - return this._rootFontSizePxCache; - } + private _rootFontSizePx = 16; get ariaLabel(): string | null { if (this.config.ariaLabelledBy) return null; return this.config.ariaLabel || this.config.headerTitle || null; } + get cvtWidth(): string { + return convertSize(this.config.width); + } + + get cvtHeight(): string { + return convertSize(this.config.height); + } + + get cvtMinWidth(): string { + return convertSize(this.config.minWidth); + } + + get cvtMinHeight(): string { + return convertSize(this.config.minHeight); + } + + get cvtMaxWidth(): string { + return this.maximized ? '' : convertSize(this.config.maxWidth); + } + + get cvtMaxHeight(): string { + return this.maximized ? '' : convertSize(this.config.maxHeight); + } + get minX(): number { return this._toPx(this.config.minX); } @@ -201,7 +217,7 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { // eslint-disable-next-line no-useless-constructor constructor( @Inject(DOCUMENT) private document: Document, - @Inject(PLATFORM_ID) private platformId: any, + @Inject(PLATFORM_ID) private platformId: object, private _dialogRef: CpsDialogRef, private _cdRef: ChangeDetectorRef, public renderer: Renderer2, @@ -212,13 +228,8 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { ngOnInit(): void { if (isPlatformBrowser(this.platformId)) { - const win = this.document.defaultView as Window; - this._windowResizeCacheListener = this.renderer.listen( - win, - 'resize', - () => { - this._rootFontSizePxCache = null; - } + this._rootFontSizePx = parseFloat( + getComputedStyle(this.document.documentElement).fontSize || '16' ); } if ( @@ -243,8 +254,6 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy() { - this._windowResizeCacheListener?.(); - this._windowResizeCacheListener = null; this.onContainerDestroy(); this.componentRef?.destroy(); } @@ -307,8 +316,7 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { this.onContainerDestroy(); this._dialogRef.destroy(); } else { - this._openStateChanged.emit(); - this.focus(); + this.focus(() => this._openStateChanged.emit()); } } @@ -377,36 +385,49 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { } } - focus() { + focus(afterFocus?: () => void) { const autoFocus = this.config.autoFocus ?? true; - if (autoFocus === false) return; + if (autoFocus === false) { + afterFocus?.(); + return; + } const containerEl = this.container as HTMLDivElement | null; - if (!containerEl) return; + if (!containerEl) { + afterFocus?.(); + return; + } this.zone.runOutsideAngular(() => { setTimeout(() => { + let handled = false; + if (autoFocus === 'dialog') { containerEl.focus(); - return; - } - - if (typeof autoFocus === 'string' && autoFocus !== 'first-tabbable') { + handled = true; + } else if ( + typeof autoFocus === 'string' && + autoFocus !== 'first-tabbable' + ) { const target = containerEl.querySelector(autoFocus); if (target) { target.focus(); - return; + handled = true; } } - // 'first-tabbable' or true (default) - const focusable: HTMLElement[] = - DomHandler.getFocusableElements(containerEl); - if (focusable && focusable.length > 0) { - focusable[0].focus(); - } else { - containerEl.focus(); + if (!handled) { + // 'first-tabbable', true (default), or selector not found + const focusable: HTMLElement[] = + DomHandler.getFocusableElements(containerEl); + if (focusable && focusable.length > 0) { + focusable[0].focus(); + } else { + containerEl.focus(); + } } + + if (afterFocus) this.zone.run(afterFocus); }, 5); }); } @@ -422,6 +443,10 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { const handleEl = event.target as HTMLElement; this.renderer.addClass(handleEl, 'cps-dialog-resizable-handle-resizing'); + if (!this._keyboardResizing) { + this._keyboardResizing = true; + this._resizeStarted.emit(event); + } const containerEl = this.container as HTMLDivElement; const step = this._rootFontSizePx; @@ -434,23 +459,33 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { else if (event.key === 'ArrowDown') newHeight += step; else if (event.key === 'ArrowUp') newHeight -= step; - const minW = this._toPx(this.config.minWidth); - const minH = this._toPx(this.config.minHeight); + const contentEl = (this.contentViewChild as ElementRef)?.nativeElement; + const headerEl = (this.headerViewChild as ElementRef)?.nativeElement; + const contentWidth = contentEl ? DomHandler.getOuterWidth(contentEl) : 0; + const contentHeight = contentEl ? DomHandler.getOuterHeight(contentEl) : 0; + const headerWidth = headerEl ? DomHandler.getOuterWidth(headerEl) : 0; + const headerHeight = headerEl ? DomHandler.getOuterHeight(headerEl) : 0; + + const viewport = DomHandler.getViewport(); + const minW = this._resolveMinPx(this.config.minWidth, 'minWidth'); + const minH = this._resolveMinPx(this.config.minHeight, 'minHeight'); const maxW = this.config.maxWidth - ? this._toPx(this.config.maxWidth) + ? this._toPx(this.config.maxWidth, Infinity) : Infinity; const maxH = this.config.maxHeight - ? this._toPx(this.config.maxHeight) + ? this._toPx(this.config.maxHeight, Infinity) : Infinity; - const viewport = DomHandler.getViewport(); const offset = containerEl.getBoundingClientRect(); newWidth = Math.max( minW, + contentWidth, + headerWidth, Math.min(newWidth, maxW, viewport.width - offset.left) ); newHeight = Math.max( minH, + headerHeight + contentHeight, Math.min(newHeight, maxH, viewport.height - offset.top) ); @@ -468,24 +503,31 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { event.target as HTMLElement, 'cps-dialog-resizable-handle-resizing' ); + this._keyboardResizing = false; + this._resizeEnded.emit(event); } } onHeaderKeyup(event: KeyboardEvent): void { if ( - event.target === this.headerViewChild?.nativeElement && + this.dragHandleViewChild?.nativeElement?.contains(event.target as Node) && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) ) { const headerEl = this.headerViewChild ?.nativeElement as HTMLElement | null; if (headerEl) this.renderer.removeClass(headerEl, 'cps-dialog-header-moving'); + this._keyboardDragging = false; + this._dragEnded.emit(event); } } onHeaderKeydown(event: KeyboardEvent): void { if (!this.draggable || this.maximized) return; - if (event.target !== this.headerViewChild?.nativeElement) return; + if ( + !this.dragHandleViewChild?.nativeElement?.contains(event.target as Node) + ) + return; if ( !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key) ) @@ -495,6 +537,10 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { const headerEl = this.headerViewChild?.nativeElement as HTMLElement | null; if (headerEl) this.renderer.addClass(headerEl, 'cps-dialog-header-moving'); + if (!this._keyboardDragging) { + this._keyboardDragging = true; + this._dragStarted.emit(event); + } const containerEl = this.container as HTMLDivElement; const step = this._rootFontSizePx; @@ -592,8 +638,8 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { : 0; let newWidth = containerWidth + deltaX; let newHeight = containerHeight + deltaY; - const minWidth = this._toPx(this.config.minWidth); - const minHeight = this._toPx(this.config.minHeight); + const minWidth = this._resolveMinPx(this.config.minWidth, 'minWidth'); + const minHeight = this._resolveMinPx(this.config.minHeight, 'minHeight'); const offset = (this.container as HTMLDivElement).getBoundingClientRect(); const viewport = DomHandler.getViewport(); const hasBeenDragged = @@ -897,22 +943,33 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { } } + private _resolveMinPx( + configMin: number | string | undefined, + prop: 'minWidth' | 'minHeight' + ): number { + return Math.max( + this._toPx(configMin), + this._toPx(this.container?.style[prop]) + ); + } + private _pxToRem(px: number): string { return `${px / this._rootFontSizePx}rem`; } - private _toPx(size: number | string | undefined, fallback = 0): number { + private _toPx( + size: number | string | null | undefined, + fallback = 0 + ): number { if (size == null) return fallback; - const str = convertSize(size); - if (!str) return fallback; - const { value, unit } = parseSize(str); - if (unit === 'px') return value; - if (unit === 'rem' || unit === 'em') return value * this._rootFontSizePx; - return fallback; - } - - cvtSize(size: number | string | undefined): string { - return size != null ? convertSize(size) : ''; + if (typeof size === 'number') return size; + const parsed = parseSize(convertSize(size)); + if (!parsed) return fallback; + if (parsed.unit === 'px') return parsed.value; + if (parsed.unit === 'rem') return parsed.value * this._rootFontSizePx; + throw new Error( + `Unsupported unit "${parsed.unit}" in dialog config. Use px or rem.` + ); } private _clampDragPos( diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref.ts index 3ee40b1d..6569a3f6 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref.ts @@ -17,18 +17,26 @@ export class CpsDialogRef { this._containerInstance?._openStateChanged.pipe(take(1)).subscribe(() => { this._onOpen.next(); }); - this._containerInstance?._dragStarted.subscribe((event: MouseEvent) => { - this._onDragStart.next(event); - }); - this._containerInstance?._dragEnded.subscribe((event: MouseEvent) => { - this._onDragEnd.next(event); - }); - this._containerInstance?._resizeStarted.subscribe((event: MouseEvent) => { - this._onResizeStart.next(event); - }); - this._containerInstance?._resizeEnded.subscribe((event: MouseEvent) => { - this._onResizeEnd.next(event); - }); + this._containerInstance?._dragStarted.subscribe( + (event: MouseEvent | KeyboardEvent) => { + this._onDragStart.next(event); + } + ); + this._containerInstance?._dragEnded.subscribe( + (event: MouseEvent | KeyboardEvent) => { + this._onDragEnd.next(event); + } + ); + this._containerInstance?._resizeStarted.subscribe( + (event: MouseEvent | KeyboardEvent) => { + this._onResizeStart.next(event); + } + ); + this._containerInstance?._resizeEnded.subscribe( + (event: MouseEvent | KeyboardEvent) => { + this._onResizeEnd.next(event); + } + ); this._containerInstance?._maximizedStateChanged.subscribe( (value: boolean) => { this._onMaximize.next(value); @@ -113,37 +121,41 @@ export class CpsDialogRef { */ onDestroy: Observable = this._onDestroy.asObservable(); - private readonly _onDragStart = new Subject(); + private readonly _onDragStart = new Subject(); /** * Event triggered on drag start. - * @param {MouseEvent} event - Mouse event. + * @param {MouseEvent | KeyboardEvent} event - Mouse or keyboard event. * @group Events */ - onDragStart: Observable = this._onDragStart.asObservable(); + onDragStart: Observable = + this._onDragStart.asObservable(); - private readonly _onDragEnd = new Subject(); + private readonly _onDragEnd = new Subject(); /** * Event triggered on drag end. - * @param {MouseEvent} event - Mouse event. + * @param {MouseEvent | KeyboardEvent} event - Mouse or keyboard event. * @group Events */ - onDragEnd: Observable = this._onDragEnd.asObservable(); + onDragEnd: Observable = + this._onDragEnd.asObservable(); - private readonly _onResizeStart = new Subject(); + private readonly _onResizeStart = new Subject(); /** * Event triggered on resize start. - * @param {MouseEvent} event - Mouse event. + * @param {MouseEvent | KeyboardEvent} event - Mouse or keyboard event. * @group Events */ - onResizeStart: Observable = this._onResizeStart.asObservable(); + onResizeStart: Observable = + this._onResizeStart.asObservable(); - private readonly _onResizeEnd = new Subject(); + private readonly _onResizeEnd = new Subject(); /** * Event triggered on resize end. - * @param {MouseEvent} event - Mouse event. + * @param {MouseEvent | KeyboardEvent} event - Mouse or keyboard event. * @group Events */ - onResizeEnd: Observable = this._onResizeEnd.asObservable(); + onResizeEnd: Observable = + this._onResizeEnd.asObservable(); private readonly _onMaximize = new Subject(); /** diff --git a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts index dbce3ff8..f1ba2540 100644 --- a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts +++ b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts @@ -1,25 +1,19 @@ -export const convertSize = (size: number | string): string => { +export const convertSize = ( + size: number | string | null | undefined +): string => { + if (size == null) return ''; const res = String(size).trim(); - if (!res) { - return ''; - } - - if (res.startsWith('calc(') && res.endsWith(')')) return res; - - if (/^\d+(\.\d+)?$/i.test(res)) { - return res + 'px'; - } - if (/^\d+(\.\d+)?(px|rem|em|%)$/i.test(res)) { - return res; - } - - throw new Error(`Invalid size value: ${size}`); + if (!res) return ''; + if (/^\d+(\.\d+)?$/i.test(res)) return res + 'px'; + if (/^\d+(\.\d+)?(px|rem|em|%)$/i.test(res)) return res; + // calc(), auto, fit-content, min(), vw, etc. - pass through as-is + return res; }; -export const parseSize = (size: string): { value: number; unit: string } => { - const match = size.match(/^(\d+(?:\.\d+)?)(px|rem|em|%)$/); - if (!match) throw new Error(`Invalid size value: ${size}`); - const value = parseFloat(match[1]); - const unit = match[2]; - return { value, unit }; +export const parseSize = ( + size: string +): { value: number; unit: string } | null => { + const match = size.match(/^(-?\d+(?:\.\d+)?)(px|rem|em|%)$/); + if (!match) return null; + return { value: parseFloat(match[1]), unit: match[2] }; }; From 44e3531580d88239c20675631d4d8da6ec2cd777 Mon Sep 17 00:00:00 2001 From: fateeand Date: Tue, 5 May 2026 15:45:16 +0200 Subject: [PATCH 05/12] restore focus check Co-authored-by: Copilot --- .../components/cps-dialog/cps-dialog.component.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index 788cce3c..00dd7d37 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -129,6 +129,7 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { private _keyboardDragging = false; private _keyboardResizing = false; private _previouslyFocusedElement: HTMLElement | null = null; + private _shouldRestoreFocus = false; _openStateChanged = new EventEmitter(); _dragStarted = new EventEmitter(); @@ -299,6 +300,9 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { break; case 'void': + this._shouldRestoreFocus = + this.config.modal !== false || + !!this.container?.contains(this.document.activeElement); if (this.wrapper && this.config.modal !== false) { if (this.config.blurredBackground) { DomHandler.addClass( @@ -332,7 +336,12 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { } this.container = null; - this._previouslyFocusedElement?.focus(); + if ( + this._shouldRestoreFocus && + this._previouslyFocusedElement?.isConnected + ) { + this._previouslyFocusedElement.focus(); + } this._previouslyFocusedElement = null; } From 904eff6ce80cb6ef497fb047487cc7a3afa341d0 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 16:55:03 +0200 Subject: [PATCH 06/12] add UTs Co-authored-by: Copilot --- .../cps-dialog/cps-dialog.service.spec.ts | 387 +++++++++ .../cps-dialog/cps-dialog.component.spec.ts | 821 ++++++++++++++++++ 2 files changed, 1208 insertions(+) create mode 100644 projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts create mode 100644 projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts new file mode 100644 index 00000000..998e887d --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts @@ -0,0 +1,387 @@ +import { + ApplicationRef, + Component, + EnvironmentInjector, + Injector +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { CpsDialogService } from './cps-dialog.service'; +import { CpsDialogConfig } from './utils/cps-dialog-config'; +import { CpsDialogRef } from './utils/cps-dialog-ref'; +import { CpsConfirmationComponent } from './internal/components/cps-confirmation/cps-confirmation.component'; + +@Component({ template: '' }) +class TestContentComponent {} + +function makeMockDialogComponentInstance() { + return { + childComponentType: undefined as unknown, + close: jest.fn(), + visible: true, + maximized: false as boolean | undefined, + toggleMaximized: jest.fn(), + _openStateChanged: new Subject(), + _dragStarted: new Subject(), + _dragEnded: new Subject(), + _resizeStarted: new Subject(), + _resizeEnded: new Subject(), + _maximizedStateChanged: new Subject() + }; +} + +type MockComponentRef = { + instance: ReturnType; + hostView: { rootNodes: HTMLElement[] }; + destroy: jest.Mock; +}; + +function makeMockComponentRef(): MockComponentRef { + const domElement = document.createElement('div'); + return { + instance: makeMockDialogComponentInstance(), + hostView: { rootNodes: [domElement] }, + destroy: jest.fn() + }; +} + +describe('CpsDialogService', () => { + let service: CpsDialogService; + let appRef: ApplicationRef; + let lastCreatedMockRef: MockComponentRef; + + /** + * Spy on the private appendDialogComponentToBody method, replacing the + * real createComponent / createEnvironmentInjector calls (which are + * non-configurable getters in the jest-transformed Angular module) with a + * hand-rolled mock that honours all subscriptions and cleanup paths. + */ + function setupAppendSpy() { + jest + .spyOn(service as any, 'appendDialogComponentToBody') + .mockImplementation((config: CpsDialogConfig) => { + const dialogRef = new CpsDialogRef(); + const mockRef = makeMockComponentRef(); + lastCreatedMockRef = mockRef; + + const sub = dialogRef.onClose.subscribe(() => { + service.dialogComponentRefMap.get(dialogRef)?.instance.close(); + }); + + const destroySub = dialogRef.onDestroy.subscribe(() => { + (service as any).removeDialogComponentFromBody(dialogRef); + destroySub.unsubscribe(); + sub.unsubscribe(); + }); + + appRef.attachView(mockRef.hostView as any); + document.body.appendChild(mockRef.hostView.rootNodes[0]); + + service.dialogComponentRefMap.set(dialogRef, mockRef as any); + service.openDialogs.push(dialogRef); + dialogRef._setContainerInstance(mockRef.instance as any); + + return dialogRef; + }); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [CpsDialogService] + }); + + service = TestBed.inject(CpsDialogService); + appRef = TestBed.inject(ApplicationRef); + jest.spyOn(appRef, 'attachView').mockImplementation(jest.fn()); + jest.spyOn(appRef, 'detachView').mockImplementation(jest.fn()); + setupAppendSpy(); + }); + + afterEach(() => { + (service as any)._openDialogsAtThisLevel.length = 0; + service.dialogComponentRefMap.clear(); + jest.restoreAllMocks(); + document.body + .querySelectorAll('div') + .forEach((el) => el.parentNode?.removeChild(el)); + }); + + describe('openDialogs getter', () => { + it('should return its own array when there is no parent service', () => { + expect(service.openDialogs).toEqual([]); + }); + + it('should delegate to parent service when a parent is present', () => { + const parentService = TestBed.inject(CpsDialogService); + const childService = new CpsDialogService( + appRef, + TestBed.inject(EnvironmentInjector), + TestBed.inject(Injector), + document, + parentService + ); + expect(childService.openDialogs).toBe(parentService.openDialogs); + }); + }); + + describe('open()', () => { + it('should return a CpsDialogRef', () => { + const ref = service.open(TestContentComponent, new CpsDialogConfig()); + expect(ref).toBeInstanceOf(CpsDialogRef); + }); + + it('should add the ref to openDialogs', () => { + service.open(TestContentComponent, new CpsDialogConfig()); + expect(service.openDialogs).toHaveLength(1); + }); + + it('should set childComponentType on the dialog instance', () => { + service.open(TestContentComponent, new CpsDialogConfig()); + expect(lastCreatedMockRef.instance.childComponentType).toBe( + TestContentComponent + ); + }); + + it('should call ApplicationRef.attachView with the component host view', () => { + service.open(TestContentComponent, new CpsDialogConfig()); + expect(appRef.attachView).toHaveBeenCalledWith( + lastCreatedMockRef.hostView + ); + }); + + it('should append a DOM element to document.body', () => { + const beforeCount = document.body.children.length; + service.open(TestContentComponent, new CpsDialogConfig()); + expect(document.body.children.length).toBe(beforeCount + 1); + }); + + it('should store the component ref in dialogComponentRefMap', () => { + const ref = service.open(TestContentComponent, new CpsDialogConfig()); + expect(service.dialogComponentRefMap.get(ref)).toBe(lastCreatedMockRef); + }); + + it('should open multiple dialogs independently', () => { + service.open(TestContentComponent, new CpsDialogConfig()); + service.open(TestContentComponent, new CpsDialogConfig()); + expect(service.openDialogs).toHaveLength(2); + }); + + it('should call _setContainerInstance on the dialog ref', () => { + const spySet = jest.spyOn( + CpsDialogRef.prototype, + '_setContainerInstance' + ); + service.open(TestContentComponent, new CpsDialogConfig()); + expect(spySet).toHaveBeenCalledWith(lastCreatedMockRef.instance); + }); + }); + + describe('openConfirmationDialog()', () => { + it('should return a CpsDialogRef', () => { + const ref = service.openConfirmationDialog(new CpsDialogConfig()); + expect(ref).toBeInstanceOf(CpsDialogRef); + }); + + it('should set default headerTitle when not provided', () => { + const config = new CpsDialogConfig(); + service.openConfirmationDialog(config); + expect(config.headerTitle).toBe('Confirm the action'); + }); + + it('should not override headerTitle when already set', () => { + const config = new CpsDialogConfig(); + config.headerTitle = 'Custom Title'; + service.openConfirmationDialog(config); + expect(config.headerTitle).toBe('Custom Title'); + }); + + it('should set default headerIcon to "warning"', () => { + const config = new CpsDialogConfig(); + service.openConfirmationDialog(config); + expect(config.headerIcon).toBe('warning'); + }); + + it('should not override headerIcon when already set', () => { + const config = new CpsDialogConfig(); + config.headerIcon = 'info'; + service.openConfirmationDialog(config); + expect(config.headerIcon).toBe('info'); + }); + + it('should set default headerIconColor to "calm"', () => { + const config = new CpsDialogConfig(); + service.openConfirmationDialog(config); + expect(config.headerIconColor).toBe('calm'); + }); + + it('should not override headerIconColor when already set', () => { + const config = new CpsDialogConfig(); + config.headerIconColor = 'warn'; + service.openConfirmationDialog(config); + expect(config.headerIconColor).toBe('warn'); + }); + + it('should set default minWidth to "25rem"', () => { + const config = new CpsDialogConfig(); + service.openConfirmationDialog(config); + expect(config.minWidth).toBe('25rem'); + }); + + it('should not override minWidth when already set', () => { + const config = new CpsDialogConfig(); + config.minWidth = '30rem'; + service.openConfirmationDialog(config); + expect(config.minWidth).toBe('30rem'); + }); + + it('should set default maxWidth to "37.5rem"', () => { + const config = new CpsDialogConfig(); + service.openConfirmationDialog(config); + expect(config.maxWidth).toBe('37.5rem'); + }); + + it('should not override maxWidth when already set', () => { + const config = new CpsDialogConfig(); + config.maxWidth = '50rem'; + service.openConfirmationDialog(config); + expect(config.maxWidth).toBe('50rem'); + }); + + it('should set childComponentType to CpsConfirmationComponent', () => { + service.openConfirmationDialog(new CpsDialogConfig()); + expect(lastCreatedMockRef.instance.childComponentType).toBe( + CpsConfirmationComponent + ); + }); + + it('should add the ref to openDialogs', () => { + service.openConfirmationDialog(new CpsDialogConfig()); + expect(service.openDialogs).toHaveLength(1); + }); + }); + + describe('closeAll()', () => { + it('should call close() on each open dialog', () => { + const ref1 = service.open(TestContentComponent, new CpsDialogConfig()); + const ref2 = service.open(TestContentComponent, new CpsDialogConfig()); + const closeSpy1 = jest.spyOn(ref1, 'close'); + const closeSpy2 = jest.spyOn(ref2, 'close'); + service.closeAll(); + expect(closeSpy1).toHaveBeenCalled(); + expect(closeSpy2).toHaveBeenCalled(); + }); + + it('should call close() in reverse order', () => { + const order: number[] = []; + const ref1 = service.open(TestContentComponent, new CpsDialogConfig()); + const ref2 = service.open(TestContentComponent, new CpsDialogConfig()); + jest.spyOn(ref1, 'close').mockImplementation(() => order.push(1)); + jest.spyOn(ref2, 'close').mockImplementation(() => order.push(2)); + service.closeAll(); + expect(order).toEqual([2, 1]); + }); + + it('should call destroy() instead of close() when force is true', () => { + const ref1 = service.open(TestContentComponent, new CpsDialogConfig()); + const ref2 = service.open(TestContentComponent, new CpsDialogConfig()); + const destroySpy1 = jest + .spyOn(ref1, 'destroy') + .mockImplementation(jest.fn()); + const destroySpy2 = jest + .spyOn(ref2, 'destroy') + .mockImplementation(jest.fn()); + const closeSpy1 = jest.spyOn(ref1, 'close'); + const closeSpy2 = jest.spyOn(ref2, 'close'); + service.closeAll(true); + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + expect(closeSpy1).not.toHaveBeenCalled(); + expect(closeSpy2).not.toHaveBeenCalled(); + }); + + it('should do nothing when there are no open dialogs', () => { + expect(() => service.closeAll()).not.toThrow(); + }); + }); + + describe('dialog cleanup on destroy signal', () => { + it('should remove ref from openDialogs when onDestroy fires', () => { + const ref = service.open(TestContentComponent, new CpsDialogConfig()); + expect(service.openDialogs).toHaveLength(1); + ref.destroy(); + expect(service.openDialogs).toHaveLength(0); + }); + + it('should remove entry from dialogComponentRefMap when onDestroy fires', () => { + const ref = service.open(TestContentComponent, new CpsDialogConfig()); + expect(service.dialogComponentRefMap.has(ref)).toBe(true); + ref.destroy(); + expect(service.dialogComponentRefMap.has(ref)).toBe(false); + }); + + it('should call detachView on ApplicationRef when onDestroy fires', () => { + service.open(TestContentComponent, new CpsDialogConfig()); + const capturedRef = lastCreatedMockRef; + service.openDialogs[0].destroy(); + expect(appRef.detachView).toHaveBeenCalledWith(capturedRef.hostView); + }); + + it('should call destroy on the component ref when onDestroy fires', () => { + service.open(TestContentComponent, new CpsDialogConfig()); + const capturedRef = lastCreatedMockRef; + service.openDialogs[0].destroy(); + expect(capturedRef.destroy).toHaveBeenCalled(); + }); + + it('should handle an unregistered ref gracefully', () => { + const unknownRef = new CpsDialogRef(); + expect(() => unknownRef.destroy()).not.toThrow(); + }); + + it('should only remove the destroyed ref from openDialogs', () => { + const ref1 = service.open(TestContentComponent, new CpsDialogConfig()); + service.open(TestContentComponent, new CpsDialogConfig()); + ref1.destroy(); + expect(service.openDialogs).toHaveLength(1); + }); + }); + + describe('onClose subscription', () => { + it('should call close() on the dialog component instance when dialogRef.close() is called', () => { + const ref = service.open(TestContentComponent, new CpsDialogConfig()); + const capturedRef = lastCreatedMockRef; + ref.close(); + expect(capturedRef.instance.close).toHaveBeenCalled(); + }); + }); + + describe('ngOnDestroy()', () => { + it('should destroy all dialogs at this level', () => { + const ref1 = service.open(TestContentComponent, new CpsDialogConfig()); + const ref2 = service.open(TestContentComponent, new CpsDialogConfig()); + const destroySpy1 = jest + .spyOn(ref1, 'destroy') + .mockImplementation(jest.fn()); + const destroySpy2 = jest + .spyOn(ref2, 'destroy') + .mockImplementation(jest.fn()); + service.ngOnDestroy(); + expect(destroySpy1).toHaveBeenCalled(); + expect(destroySpy2).toHaveBeenCalled(); + }); + + it('should destroy dialogs in reverse order', () => { + const order: number[] = []; + const ref1 = service.open(TestContentComponent, new CpsDialogConfig()); + const ref2 = service.open(TestContentComponent, new CpsDialogConfig()); + jest.spyOn(ref1, 'destroy').mockImplementation(() => order.push(1)); + jest.spyOn(ref2, 'destroy').mockImplementation(() => order.push(2)); + service.ngOnDestroy(); + expect(order).toEqual([2, 1]); + }); + + it('should not throw when no dialogs are open', () => { + expect(() => service.ngOnDestroy()).not.toThrow(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts new file mode 100644 index 00000000..bce19d73 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts @@ -0,0 +1,821 @@ +import { Component } from '@angular/core'; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { PrimeNG } from 'primeng/config'; +import { DomHandler } from 'primeng/dom'; +import { CpsDialogComponent } from './cps-dialog.component'; +import { CpsDialogConfig } from '../../../utils/cps-dialog-config'; +import { CpsDialogRef } from '../../../utils/cps-dialog-ref'; + +@Component({ template: '' }) +class TestChildComponent {} + +describe('CpsDialogComponent', () => { + let component: CpsDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: { + close: jest.Mock; + destroy: jest.Mock; + disableClose: boolean; + componentInstance: unknown; + }; + let config: CpsDialogConfig; + + function setup(configOverrides: Partial = {}) { + mockDialogRef = { + close: jest.fn(), + destroy: jest.fn(), + disableClose: false, + componentInstance: null + }; + + config = Object.assign(new CpsDialogConfig(), configOverrides); + + TestBed.configureTestingModule({ + imports: [CpsDialogComponent, NoopAnimationsModule], + providers: [ + { provide: CpsDialogRef, useValue: mockDialogRef }, + { provide: CpsDialogConfig, useValue: config }, + PrimeNG + ] + }); + + fixture = TestBed.createComponent(CpsDialogComponent); + component = fixture.componentInstance; + component.childComponentType = TestChildComponent; + fixture.detectChanges(); + } + + describe('creation', () => { + it('should create with a headerTitle', () => { + setup({ headerTitle: 'My Dialog' }); + expect(component).toBeTruthy(); + }); + }); + + describe('ngOnInit accessibility warning', () => { + it('should warn when no accessible name is provided', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setup({}); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('dialog has no accessible name') + ); + warnSpy.mockRestore(); + }); + + it('should not warn when headerTitle is set', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setup({ headerTitle: 'Dialog Title' }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('should not warn when ariaLabel is set', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setup({ ariaLabel: 'My dialog label' }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('should not warn when ariaLabelledBy is set', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setup({ ariaLabelledBy: 'some-id' }); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('should warn when accessible name properties are whitespace only', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setup({ headerTitle: ' ', ariaLabel: ' ', ariaLabelledBy: '\t' }); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('ariaLabel getter', () => { + it('should return null when ariaLabelledBy is set', () => { + setup({ ariaLabelledBy: 'header-id', ariaLabel: 'My Label' }); + expect(component.ariaLabel).toBeNull(); + }); + + it('should return ariaLabel when set and no ariaLabelledBy', () => { + setup({ ariaLabel: 'Custom label' }); + expect(component.ariaLabel).toBe('Custom label'); + }); + + it('should return headerTitle as fallback', () => { + setup({ headerTitle: 'Dialog Title' }); + expect(component.ariaLabel).toBe('Dialog Title'); + }); + + it('should return null when no label sources are set', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setup({}); + warnSpy.mockRestore(); + expect(component.ariaLabel).toBeNull(); + }); + + it('should prefer ariaLabel over headerTitle', () => { + setup({ ariaLabel: 'Label', headerTitle: 'Title' }); + expect(component.ariaLabel).toBe('Label'); + }); + }); + + describe('style setter/getter', () => { + beforeEach(() => setup({ headerTitle: 'Test' })); + + it('should set _style and originalStyle when value is provided', () => { + component.style = { width: '500px' }; + expect(component.style).toEqual({ width: '500px' }); + expect(component.originalStyle).toEqual({ width: '500px' }); + }); + + it('should not update style when value is null/undefined', () => { + component.style = { width: '400px' }; + component.style = null; + expect(component.style).toEqual({ width: '400px' }); + }); + + it('should return a copy of _style, not the same reference', () => { + const orig = { color: 'red' }; + component.style = orig; + orig.color = 'blue'; + expect(component.style.color).toBe('red'); + }); + }); + + describe('boolean getters', () => { + it('should return false for keepInViewport by default', () => { + setup({ headerTitle: 'Test' }); + expect(component.keepInViewport).toBe(false); + }); + + it('should return config keepInViewport value', () => { + setup({ headerTitle: 'Test', keepInViewport: true }); + expect(component.keepInViewport).toBe(true); + }); + + it('should return false for maximizable by default', () => { + setup({ headerTitle: 'Test' }); + expect(component.maximizable).toBe(false); + }); + + it('should return config maximizable value', () => { + setup({ headerTitle: 'Test', maximizable: true }); + expect(component.maximizable).toBe(true); + }); + + it('should return false for draggable by default', () => { + setup({ headerTitle: 'Test' }); + expect(component.draggable).toBe(false); + }); + + it('should return config draggable value', () => { + setup({ headerTitle: 'Test', draggable: true }); + expect(component.draggable).toBe(true); + }); + + it('should return false for resizable by default', () => { + setup({ headerTitle: 'Test' }); + expect(component.resizable).toBe(false); + }); + + it('should return config resizable value', () => { + setup({ headerTitle: 'Test', resizable: true }); + expect(component.resizable).toBe(true); + }); + }); + + describe('position getter', () => { + it('should return empty string when position is not set', () => { + setup({ headerTitle: 'Test' }); + expect(component.position).toBe(''); + }); + + it('should return config position', () => { + setup({ headerTitle: 'Test', position: 'top' }); + expect(component.position).toBe('top'); + }); + }); + + describe('close()', () => { + it('should set visible to false when not disabled', () => { + setup({ headerTitle: 'Test' }); + component.close(); + expect(component.visible).toBe(false); + }); + + it('should do nothing when config.disableClose is true', () => { + setup({ headerTitle: 'Test', disableClose: true }); + component.close(); + expect(component.visible).toBe(true); + }); + + it('should do nothing when dialogRef.disableClose is true', () => { + setup({ headerTitle: 'Test' }); + mockDialogRef.disableClose = true; + component.close(); + expect(component.visible).toBe(true); + }); + }); + + describe('hide()', () => { + it('should call dialogRef.close() when not disabled', () => { + setup({ headerTitle: 'Test' }); + component.hide(); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it('should do nothing when config.disableClose is true', () => { + setup({ headerTitle: 'Test', disableClose: true }); + component.hide(); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + + it('should do nothing when dialogRef.disableClose is true', () => { + setup({ headerTitle: 'Test' }); + mockDialogRef.disableClose = true; + component.hide(); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + }); + + describe('toggleMaximized()', () => { + it('should do nothing when maximizable is false', () => { + setup({ headerTitle: 'Test' }); + component.toggleMaximized(); + expect(component.maximized).toBeUndefined(); + }); + + it('should toggle maximized to true', () => { + setup({ headerTitle: 'Test', maximizable: true }); + component.toggleMaximized(); + expect(component.maximized).toBe(true); + }); + + it('should toggle maximized back to false', () => { + setup({ headerTitle: 'Test', maximizable: true }); + component.toggleMaximized(); + component.toggleMaximized(); + expect(component.maximized).toBe(false); + }); + + it('should set maximized to specific boolean value', () => { + setup({ headerTitle: 'Test', maximizable: true }); + component.toggleMaximized(true); + expect(component.maximized).toBe(true); + component.toggleMaximized(false); + expect(component.maximized).toBe(false); + }); + + it('should be a no-op when setting maximized to its current value', () => { + setup({ headerTitle: 'Test', maximizable: true }); + component.maximized = true; + const emitSpy = jest.spyOn(component._maximizedStateChanged, 'emit'); + component.toggleMaximized(true); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should add overflow-hidden class to body when maximized', () => { + setup({ headerTitle: 'Test', maximizable: true }); + component.toggleMaximized(true); + expect(document.body.classList.contains('cps-overflow-hidden')).toBe( + true + ); + }); + + it('should remove overflow-hidden class from body when minimized', () => { + setup({ headerTitle: 'Test', maximizable: true }); + component.toggleMaximized(true); + component.toggleMaximized(false); + expect(document.body.classList.contains('cps-overflow-hidden')).toBe( + false + ); + }); + + it('should emit maximized state changed event', () => { + setup({ headerTitle: 'Test', maximizable: true }); + const emitSpy = jest.spyOn(component._maximizedStateChanged, 'emit'); + component.toggleMaximized(true); + expect(emitSpy).toHaveBeenCalledWith(true); + }); + }); + + describe('initDrag()', () => { + describe('when draggable', () => { + beforeEach(() => setup({ headerTitle: 'Test', draggable: true })); + + it('should set dragging to true', () => { + const event = new MouseEvent('mousedown', { + clientX: 100, + clientY: 200 + }); + component.initDrag(event); + expect(component.dragging).toBe(true); + }); + + it('should record initial page coordinates', () => { + const event = new MouseEvent('mousedown', { + clientX: 150, + clientY: 250 + }); + component.initDrag(event); + expect(component.lastPageX).toBe(150); + expect(component.lastPageY).toBe(250); + }); + + it('should not start drag when maximized', () => { + component.maximized = true; + const event = new MouseEvent('mousedown'); + component.initDrag(event); + expect(component.dragging).toBeUndefined(); + }); + + it('should emit dragStarted event', () => { + const emitSpy = jest.spyOn(component._dragStarted, 'emit'); + const event = new MouseEvent('mousedown'); + component.initDrag(event); + expect(emitSpy).toHaveBeenCalledWith(event); + }); + }); + + describe('when not draggable', () => { + beforeEach(() => setup({ headerTitle: 'Test', draggable: false })); + + it('should not start drag', () => { + const event = new MouseEvent('mousedown'); + component.initDrag(event); + expect(component.dragging).toBeUndefined(); + }); + }); + }); + + describe('endDrag()', () => { + beforeEach(() => setup({ headerTitle: 'Test', draggable: true })); + + it('should set dragging to false', () => { + const startEvent = new MouseEvent('mousedown'); + component.initDrag(startEvent); + const endEvent = new MouseEvent('mouseup'); + component.endDrag(endEvent); + expect(component.dragging).toBe(false); + }); + + it('should emit dragEnded event', () => { + const startEvent = new MouseEvent('mousedown'); + component.initDrag(startEvent); + const emitSpy = jest.spyOn(component._dragEnded, 'emit'); + const endEvent = new MouseEvent('mouseup'); + component.endDrag(endEvent); + expect(emitSpy).toHaveBeenCalledWith(endEvent); + }); + + it('should do nothing when not currently dragging', () => { + const emitSpy = jest.spyOn(component._dragEnded, 'emit'); + component.endDrag(new MouseEvent('mouseup')); + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('initResize()', () => { + describe('when resizable', () => { + beforeEach(() => setup({ headerTitle: 'Test', resizable: true })); + + it('should set resizing to true', () => { + const event = new MouseEvent('mousedown', { + clientX: 100, + clientY: 200 + }); + component.initResize(event); + expect(component.resizing).toBe(true); + }); + + it('should record initial page coordinates', () => { + const event = new MouseEvent('mousedown', { clientX: 50, clientY: 75 }); + component.initResize(event); + expect(component.lastPageX).toBe(50); + expect(component.lastPageY).toBe(75); + }); + + it('should emit resizeStarted event', () => { + const emitSpy = jest.spyOn(component._resizeStarted, 'emit'); + const event = new MouseEvent('mousedown'); + component.initResize(event); + expect(emitSpy).toHaveBeenCalledWith(event); + }); + }); + + describe('when not resizable', () => { + beforeEach(() => setup({ headerTitle: 'Test', resizable: false })); + + it('should not start resize', () => { + const event = new MouseEvent('mousedown'); + component.initResize(event); + expect(component.resizing).toBeUndefined(); + }); + }); + }); + + describe('resizeEnd()', () => { + beforeEach(() => setup({ headerTitle: 'Test', resizable: true })); + + it('should set resizing to false', () => { + const startEvent = new MouseEvent('mousedown'); + component.initResize(startEvent); + const endEvent = new MouseEvent('mouseup'); + component.resizeEnd(endEvent); + expect(component.resizing).toBe(false); + }); + + it('should emit resizeEnded event', () => { + const startEvent = new MouseEvent('mousedown'); + component.initResize(startEvent); + const emitSpy = jest.spyOn(component._resizeEnded, 'emit'); + const endEvent = new MouseEvent('mouseup'); + component.resizeEnd(endEvent); + expect(emitSpy).toHaveBeenCalledWith(endEvent); + }); + + it('should do nothing when not currently resizing', () => { + const emitSpy = jest.spyOn(component._resizeEnded, 'emit'); + component.resizeEnd(new MouseEvent('mouseup')); + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('enableModality()', () => { + it('should add cps-overflow-hidden class to body', () => { + setup({ headerTitle: 'Test' }); + component.wrapper = document.body; + component.enableModality(); + expect(document.body.classList.contains('cps-overflow-hidden')).toBe( + true + ); + }); + + it('should not add body class when modal is false', () => { + setup({ headerTitle: 'Test', modal: false }); + component.wrapper = document.body; + component.enableModality(); + expect(document.body.classList.contains('cps-overflow-hidden')).toBe( + false + ); + }); + }); + + describe('disableModality()', () => { + it('should remove cps-overflow-hidden class from body', () => { + setup({ headerTitle: 'Test' }); + document.body.classList.add('cps-overflow-hidden'); + component.wrapper = document.body; + component.disableModality(); + expect(document.body.classList.contains('cps-overflow-hidden')).toBe( + false + ); + }); + }); + + describe('resetPosition()', () => { + it('should clear position styles on the container', () => { + setup({ headerTitle: 'Test' }); + const mockContainer = document.createElement('div'); + mockContainer.style.position = 'fixed'; + mockContainer.style.left = '100px'; + mockContainer.style.top = '50px'; + mockContainer.style.margin = '0'; + component.container = mockContainer as HTMLDivElement; + + component.resetPosition(); + + expect(mockContainer.style.position).toBe(''); + expect(mockContainer.style.left).toBe(''); + expect(mockContainer.style.top).toBe(''); + expect(mockContainer.style.margin).toBe(''); + }); + }); + + describe('focus()', () => { + beforeEach(() => setup({ headerTitle: 'Test' })); + + it('should call afterFocus without focusing when autoFocus is false', fakeAsync(() => { + config.autoFocus = false; + component.container = document.createElement('div') as HTMLDivElement; + const afterFocus = jest.fn(); + component.focus(afterFocus); + tick(10); + expect(afterFocus).toHaveBeenCalled(); + })); + + it('should focus the dialog container when autoFocus is "dialog"', fakeAsync(() => { + config.autoFocus = 'dialog'; + const containerEl = document.createElement('div') as HTMLDivElement; + containerEl.setAttribute('tabindex', '-1'); + document.body.appendChild(containerEl); + component.container = containerEl; + const focusSpy = jest.spyOn(containerEl, 'focus'); + component.focus(); + tick(10); + expect(focusSpy).toHaveBeenCalled(); + document.body.removeChild(containerEl); + })); + + it('should focus the first tabbable element by default', fakeAsync(() => { + config.autoFocus = true; + const containerEl = document.createElement('div') as HTMLDivElement; + const button = document.createElement('button'); + containerEl.appendChild(button); + document.body.appendChild(containerEl); + component.container = containerEl; + jest + .spyOn(DomHandler, 'getFocusableElements') + .mockReturnValue([button] as HTMLElement[]); + const focusSpy = jest.spyOn(button, 'focus'); + component.focus(); + tick(10); + expect(focusSpy).toHaveBeenCalled(); + jest.restoreAllMocks(); + document.body.removeChild(containerEl); + })); + + it('should fall back to container focus when no focusable elements exist', fakeAsync(() => { + config.autoFocus = true; + const containerEl = document.createElement('div') as HTMLDivElement; + containerEl.setAttribute('tabindex', '-1'); + document.body.appendChild(containerEl); + component.container = containerEl; + const focusSpy = jest.spyOn(containerEl, 'focus'); + component.focus(); + tick(10); + expect(focusSpy).toHaveBeenCalled(); + document.body.removeChild(containerEl); + })); + + it('should focus an element matching a CSS selector', fakeAsync(() => { + config.autoFocus = '#my-input'; + const containerEl = document.createElement('div') as HTMLDivElement; + const input = document.createElement('input'); + input.id = 'my-input'; + containerEl.appendChild(input); + document.body.appendChild(containerEl); + component.container = containerEl; + const focusSpy = jest.spyOn(input, 'focus'); + component.focus(); + tick(10); + expect(focusSpy).toHaveBeenCalled(); + document.body.removeChild(containerEl); + })); + + it('should call afterFocus callback after focusing', fakeAsync(() => { + config.autoFocus = false; + component.container = document.createElement('div') as HTMLDivElement; + const afterFocus = jest.fn(); + component.focus(afterFocus); + tick(10); + expect(afterFocus).toHaveBeenCalled(); + })); + }); + + describe('onResizeHandleKeydown()', () => { + function makeKeyEvent(key: string, target: HTMLElement): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, bubbles: true }); + Object.defineProperty(event, 'target', { + value: target, + configurable: true + }); + return event; + } + + describe('when resizable', () => { + beforeEach(() => setup({ headerTitle: 'Test', resizable: true })); + + it('should do nothing when maximized', () => { + component.maximized = true; + const handleEl = document.createElement('div'); + const event = makeKeyEvent('ArrowRight', handleEl); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + component.onResizeHandleKeydown(event); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing for non-arrow keys', () => { + const containerEl = document.createElement('div') as HTMLDivElement; + component.container = containerEl; + const handleEl = document.createElement('div'); + const event = makeKeyEvent('Enter', handleEl); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + component.onResizeHandleKeydown(event); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('should call preventDefault for arrow keys when resizable', () => { + const containerEl = document.createElement('div') as HTMLDivElement; + document.body.appendChild(containerEl); + component.container = containerEl; + const handleEl = document.createElement('div'); + document.body.appendChild(handleEl); + const event = makeKeyEvent('ArrowRight', handleEl); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + component.onResizeHandleKeydown(event); + expect(preventDefaultSpy).toHaveBeenCalled(); + document.body.removeChild(containerEl); + document.body.removeChild(handleEl); + }); + + it('should emit resizeStarted on first arrow key press', () => { + const containerEl = document.createElement('div') as HTMLDivElement; + document.body.appendChild(containerEl); + component.container = containerEl; + const handleEl = document.createElement('div'); + document.body.appendChild(handleEl); + const emitSpy = jest.spyOn(component._resizeStarted, 'emit'); + const event = makeKeyEvent('ArrowDown', handleEl); + component.onResizeHandleKeydown(event); + expect(emitSpy).toHaveBeenCalledWith(event); + document.body.removeChild(containerEl); + document.body.removeChild(handleEl); + }); + + it('should not emit resizeStarted again when already resizing', () => { + const containerEl = document.createElement('div') as HTMLDivElement; + document.body.appendChild(containerEl); + component.container = containerEl; + const handleEl = document.createElement('div'); + document.body.appendChild(handleEl); + const event = makeKeyEvent('ArrowDown', handleEl); + component.onResizeHandleKeydown(event); + const emitSpy = jest.spyOn(component._resizeStarted, 'emit'); + const event2 = makeKeyEvent('ArrowDown', handleEl); + component.onResizeHandleKeydown(event2); + expect(emitSpy).not.toHaveBeenCalled(); + document.body.removeChild(containerEl); + document.body.removeChild(handleEl); + }); + }); + + describe('when not resizable', () => { + beforeEach(() => setup({ headerTitle: 'Test', resizable: false })); + + it('should do nothing for arrow keys', () => { + const containerEl = document.createElement('div') as HTMLDivElement; + component.container = containerEl; + const handleEl = document.createElement('div'); + const event = makeKeyEvent('ArrowRight', handleEl); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + component.onResizeHandleKeydown(event); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('onResizeHandleKeyup()', () => { + beforeEach(() => setup({ headerTitle: 'Test', resizable: true })); + + it('should emit resizeEnded for arrow keys', () => { + const emitSpy = jest.spyOn(component._resizeEnded, 'emit'); + const handleEl = document.createElement('div'); + document.body.appendChild(handleEl); + const event = new KeyboardEvent('keyup', { + key: 'ArrowUp', + bubbles: true + }); + Object.defineProperty(event, 'target', { + value: handleEl, + configurable: true + }); + component.onResizeHandleKeyup(event); + expect(emitSpy).toHaveBeenCalledWith(event); + document.body.removeChild(handleEl); + }); + + it('should not emit resizeEnded for non-arrow keys', () => { + const emitSpy = jest.spyOn(component._resizeEnded, 'emit'); + const event = new KeyboardEvent('keyup', { key: 'Tab' }); + component.onResizeHandleKeyup(event); + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onHeaderKeydown()', () => { + it('should do nothing when not draggable', () => { + setup({ headerTitle: 'Test', draggable: false }); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + component.onHeaderKeydown(event); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing when maximized', () => { + setup({ headerTitle: 'Test', draggable: true }); + component.maximized = true; + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + component.onHeaderKeydown(event); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing when target is not inside drag handle', () => { + setup({ headerTitle: 'Test', draggable: true }); + const containerEl = document.createElement('div') as HTMLDivElement; + component.container = containerEl; + const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + component.onHeaderKeydown(event); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onHeaderKeyup()', () => { + it('should not emit dragEnded when target is not in drag handle', () => { + setup({ headerTitle: 'Test', draggable: true }); + const emitSpy = jest.spyOn(component._dragEnded, 'emit'); + const event = new KeyboardEvent('keyup', { key: 'ArrowRight' }); + component.onHeaderKeyup(event); + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('parent getter', () => { + it('should return undefined when there is only one dialog in the DOM', () => { + setup({ headerTitle: 'Test' }); + expect(component.parent).toBeUndefined(); + }); + }); + + describe('size getters', () => { + it('should return converted width', () => { + setup({ headerTitle: 'Test', width: '500px' }); + expect(component.cvtWidth).toBe('500px'); + }); + + it('should return converted height', () => { + setup({ headerTitle: 'Test', height: '300px' }); + expect(component.cvtHeight).toBe('300px'); + }); + + it('should return converted minWidth', () => { + setup({ headerTitle: 'Test', minWidth: '200px' }); + expect(component.cvtMinWidth).toBe('200px'); + }); + + it('should return converted minHeight', () => { + setup({ headerTitle: 'Test', minHeight: '100px' }); + expect(component.cvtMinHeight).toBe('100px'); + }); + + it('should return empty string for maxWidth when maximized', () => { + setup({ headerTitle: 'Test', maxWidth: '800px' }); + component.maximized = true; + expect(component.cvtMaxWidth).toBe(''); + }); + + it('should return converted maxWidth when not maximized', () => { + setup({ headerTitle: 'Test', maxWidth: '800px' }); + expect(component.cvtMaxWidth).toBe('800px'); + }); + + it('should return empty string for maxHeight when maximized', () => { + setup({ headerTitle: 'Test', maxHeight: '600px' }); + component.maximized = true; + expect(component.cvtMaxHeight).toBe(''); + }); + + it('should return converted maxHeight when not maximized', () => { + setup({ headerTitle: 'Test', maxHeight: '600px' }); + expect(component.cvtMaxHeight).toBe('600px'); + }); + }); + + describe('minX / minY getters', () => { + it('should return 0 when minX is not set', () => { + setup({ headerTitle: 'Test' }); + expect(component.minX).toBe(0); + }); + + it('should return 0 when minY is not set', () => { + setup({ headerTitle: 'Test' }); + expect(component.minY).toBe(0); + }); + + it('should return pixel value when minX is set as number', () => { + setup({ headerTitle: 'Test', minX: 50 }); + expect(component.minX).toBe(50); + }); + + it('should return pixel value when minY is set as number', () => { + setup({ headerTitle: 'Test', minY: 30 }); + expect(component.minY).toBe(30); + }); + }); + + describe('ngOnDestroy', () => { + it('should not throw on destroy', () => { + setup({ headerTitle: 'Test' }); + expect(() => fixture.destroy()).not.toThrow(); + }); + }); +}); From 6d4c01a5b204b83eb38ea3fe43eff3f18363bb53 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:28:09 +0200 Subject: [PATCH 07/12] disable close button if disableClose is true Co-authored-by: Copilot --- .../cps-dialog/cps-dialog.component.html | 1 + .../components/cps-dialog/cps-dialog.component.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html index 5dab7d94..09d99c0e 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html @@ -121,6 +121,7 @@ width="2rem" color="graphite" type="borderless" + [disabled]="isCloseDisabled()" (clicked)="hide()"> } diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index 00dd7d37..d42707b9 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -345,19 +345,19 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { this._previouslyFocusedElement = null; } - close() { - if (this.config?.disableClose || this._dialogRef?.disableClose) return; + isCloseDisabled() { + return !!this.config?.disableClose || !!this._dialogRef?.disableClose; + } + close() { + if (this.isCloseDisabled()) return; this.visible = false; this._cdRef.markForCheck(); } hide() { - if (this.config?.disableClose) return; - - if (this._dialogRef) { - if (!this._dialogRef.disableClose) this._dialogRef.close(); - } + if (this.isCloseDisabled()) return; + this._dialogRef?.close(); } enableModality() { From 11835f341610e79c19441b49e92d29ff6dc72a0a Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:32:00 +0200 Subject: [PATCH 08/12] fix resizing Co-authored-by: Copilot --- .../components/cps-dialog/cps-dialog.component.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index d42707b9..0b871765 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -468,13 +468,6 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { else if (event.key === 'ArrowDown') newHeight += step; else if (event.key === 'ArrowUp') newHeight -= step; - const contentEl = (this.contentViewChild as ElementRef)?.nativeElement; - const headerEl = (this.headerViewChild as ElementRef)?.nativeElement; - const contentWidth = contentEl ? DomHandler.getOuterWidth(contentEl) : 0; - const contentHeight = contentEl ? DomHandler.getOuterHeight(contentEl) : 0; - const headerWidth = headerEl ? DomHandler.getOuterWidth(headerEl) : 0; - const headerHeight = headerEl ? DomHandler.getOuterHeight(headerEl) : 0; - const viewport = DomHandler.getViewport(); const minW = this._resolveMinPx(this.config.minWidth, 'minWidth'); const minH = this._resolveMinPx(this.config.minHeight, 'minHeight'); @@ -488,13 +481,10 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { newWidth = Math.max( minW, - contentWidth, - headerWidth, Math.min(newWidth, maxW, viewport.width - offset.left) ); newHeight = Math.max( minH, - headerHeight + contentHeight, Math.min(newHeight, maxH, viewport.height - offset.top) ); From a32e6b2f4ead2591563832b736c9fd36c31f40b2 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:44:40 +0200 Subject: [PATCH 09/12] remove redundant line --- projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts index f1ba2540..958ad36e 100644 --- a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts +++ b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts @@ -5,7 +5,7 @@ export const convertSize = ( const res = String(size).trim(); if (!res) return ''; if (/^\d+(\.\d+)?$/i.test(res)) return res + 'px'; - if (/^\d+(\.\d+)?(px|rem|em|%)$/i.test(res)) return res; + // calc(), auto, fit-content, min(), vw, etc. - pass through as-is return res; }; From 1380bb24d9a90e217f52c4f1755cc68cdf77fb0f Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:46:51 +0200 Subject: [PATCH 10/12] add validator Co-authored-by: Copilot --- .../src/lib/utils/internal/size-utils.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts index 958ad36e..50766324 100644 --- a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts +++ b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts @@ -5,9 +5,17 @@ export const convertSize = ( const res = String(size).trim(); if (!res) return ''; if (/^\d+(\.\d+)?$/i.test(res)) return res + 'px'; - - // calc(), auto, fit-content, min(), vw, etc. - pass through as-is - return res; + if ( + /^-?\d+(\.\d+)?(px|rem|em|%|vw|vh|vmin|vmax|dvw|dvh|svw|svh|lvw|lvh|ch|ex|cm|mm|in|pt|pc|fr)$/i.test( + res + ) || + /^(auto|min-content|max-content|fit-content|none|inherit|initial|unset|normal)$/i.test( + res + ) || + /^(calc|min|max|clamp|fit-content|var|env)\(.+\)$/i.test(res) + ) + return res; + return ''; }; export const parseSize = ( From b92e6b92543fba558cc07f72c8e39ca954ae5849 Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 6 May 2026 17:48:44 +0200 Subject: [PATCH 11/12] support negative Co-authored-by: Copilot --- projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts index 50766324..b2b8a3dc 100644 --- a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts +++ b/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts @@ -4,7 +4,7 @@ export const convertSize = ( if (size == null) return ''; const res = String(size).trim(); if (!res) return ''; - if (/^\d+(\.\d+)?$/i.test(res)) return res + 'px'; + if (/^-?\d+(\.\d+)?$/i.test(res)) return res + 'px'; if ( /^-?\d+(\.\d+)?(px|rem|em|%|vw|vh|vmin|vmax|dvw|dvh|svw|svh|lvw|lvh|ch|ex|cm|mm|in|pt|pc|fr)$/i.test( res From 43fd1437547b5653203519da0b15655530de27ce Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 7 May 2026 17:40:16 +0200 Subject: [PATCH 12/12] add isPlatformBrowser check --- .../cps-tooltip/cps-tooltip.directive.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts index 155aeed4..82b1e52b 100644 --- a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts +++ b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts @@ -344,13 +344,15 @@ export class CpsTooltipDirective implements OnInit, OnDestroy { if (!parsed) throw new Error(`Unsupported value for tooltipOffset.`); if (parsed.unit === 'px') return parsed.value; if (parsed.unit === 'rem') return parsed.value * this._rootFontSizePx; - if (parsed.unit === 'em') - return ( - parsed.value * - parseFloat( - getComputedStyle(this._elementRef.nativeElement).fontSize || '16' - ) - ); + if (parsed.unit === 'em') { + const fontSize = isPlatformBrowser(this._platformId) + ? parseFloat( + getComputedStyle(this._elementRef.nativeElement).fontSize || '16' + ) + : 16; + return parsed.value * fontSize; + } + throw new Error(`Unsupported unit "${parsed.unit}" for tooltipOffset.`); }