diff --git a/.github/workflows/angular-test-and-lint.yml b/.github/workflows/angular-test-and-lint.yml
index b0f22c744..4c99858e2 100644
--- a/.github/workflows/angular-test-and-lint.yml
+++ b/.github/workflows/angular-test-and-lint.yml
@@ -22,6 +22,22 @@ jobs:
npm ci --cache .npm --prefer-offline
npm run lint
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "lts/*"
+ - name: Build library
+ run: |
+ npm ci --cache .npm --prefer-offline
+ npm run build
+
test:
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/auto-add-to-project.yml b/.github/workflows/auto-add-to-project.yml
new file mode 100644
index 000000000..8f086be2d
--- /dev/null
+++ b/.github/workflows/auto-add-to-project.yml
@@ -0,0 +1,15 @@
+name: Auto Add to Project
+
+on:
+ issues:
+ types:
+ - opened
+
+jobs:
+ add-to-project:
+ uses: TEDI-Design-System/general/.github/workflows/auto-add-to-project.yml@main
+ with:
+ project-url: https://github.com/orgs/TEDI-Design-System/projects/7
+ secrets:
+ PROJECT_AUTO_ADD_APP_ID: ${{ secrets.PROJECT_AUTO_ADD_APP_ID }}
+ PROJECT_AUTO_ADD_APP_KEY: ${{ secrets.PROJECT_AUTO_ADD_APP_KEY }}
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 7134eea31..95a595e1d 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -105,6 +105,18 @@ const preview: Preview = {
color: "#fff",
description: "TEDI-ready",
},
+ partiallyTediReady: {
+ background: '#9bbb5f',
+ color: '#fff',
+ description:
+ 'This component lacks some TEDI-Ready functionality, e.g it may rely on another component that has not yet been developed',
+ },
+ mobileViewDifference: {
+ background: '#99BDDA',
+ color: '#000',
+ description:
+ 'This component has a different layout on mobile. Use the mobile breakpoint or resize the browser window to review the mobile design.',
+ },
},
},
},
diff --git a/.stylelintrc.json b/.stylelintrc.json
new file mode 100644
index 000000000..1b294f597
--- /dev/null
+++ b/.stylelintrc.json
@@ -0,0 +1,66 @@
+{
+ "extends": [
+ "stylelint-config-standard-scss",
+ "stylelint-config-recess-order",
+ "stylelint-config-prettier-scss"
+ ],
+ "rules": {
+ "selector-class-pattern": [
+ "^(tedi-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*(?:--[a-z][a-z0-9]+(?:-[a-z0-9]+)*)?|ng-[a-z]+(?:-[a-z]+)*|float-ui-[a-z]+(?:-[a-z]+)*)$",
+ {
+ "message": "Class selector must start with 'tedi-' prefix and follow BEM naming (e.g., .tedi-button, .tedi-button__icon, .tedi-button--primary). Selector: \"%s\"",
+ "resolveNestedSelectors": true
+ }
+ ],
+ "selector-pseudo-element-no-unknown": [
+ true,
+ {
+ "ignorePseudoElements": [
+ "ng-deep"
+ ]
+ }
+ ],
+ "selector-pseudo-class-no-unknown": [
+ true,
+ {
+ "ignorePseudoClasses": [
+ "host",
+ "host-context"
+ ]
+ }
+ ],
+ "max-nesting-depth": [
+ 4,
+ {
+ "ignore": [
+ "blockless-at-rules",
+ "pseudo-classes"
+ ],
+ "ignoreAtRules": [
+ "/include/"
+ ]
+ }
+ ],
+ "scss/no-global-function-names": null,
+ "scss/comment-no-empty": null,
+ "scss/at-if-no-null": null,
+ "custom-property-pattern": null,
+ "no-invalid-position-at-import-rule": null,
+ "no-descending-specificity": null,
+ "scss/operator-no-newline-after": null,
+ "lightness-notation": null,
+ "scss/operator-no-unspaced": null,
+ "block-no-redundant-nested-style-rules": null,
+ "rule-empty-line-before": null
+ },
+ "overrides": [
+ {
+ "files": [
+ "src/**/*.scss"
+ ],
+ "rules": {
+ "selector-class-pattern": null
+ }
+ }
+ ]
+}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index bf46f488f..796a2fa2f 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -4,6 +4,7 @@
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"dbaeumer.vscode-eslint",
+ "stylelint.vscode-stylelint",
"lokalise.i18n-ally",
"vivaxy.vscode-conventional-commits"
]
diff --git a/.vscode/settings.json b/.vscode/settings.json
index ed814de1b..4d9f6e293 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -15,8 +15,10 @@
"typescript.validate.enable": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
- "source.fixAll.eslint": "explicit"
+ "source.fixAll.eslint": "explicit",
+ "source.fixAll.stylelint": "explicit"
},
+ "stylelint.validate": ["css", "scss"],
"typescript.tsdk": "node_modules/typescript/lib",
"conventionalCommits.scopes": [
"accordion",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 446255dd2..ef84076ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,146 @@
+# [6.0.0-rc.11](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.10...angular-6.0.0-rc.11) (2026-01-29)
+
+
+### Bug Fixes
+
+* **breadcrumbs:** migrated from ngFor to new [@for](https://github.com/for) loop with proper item tracking [#305](https://github.com/TEDI-Design-System/angular/issues/305) ([#306](https://github.com/TEDI-Design-System/angular/issues/306)) ([c3916b3](https://github.com/TEDI-Design-System/angular/commit/c3916b3496548d2018947a19d19f819f8d26b64a))
+
+# [6.0.0-rc.10](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.9...angular-6.0.0-rc.10) (2026-01-29)
+
+
+### Features
+
+* **vertical-stepper:** add community vertical stepper [#254](https://github.com/TEDI-Design-System/angular/issues/254) ([#296](https://github.com/TEDI-Design-System/angular/issues/296)) ([a5d9e82](https://github.com/TEDI-Design-System/angular/commit/a5d9e829ab4b5640168befc555c8ece38dd02b52))
+
+# [6.0.0-rc.9](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.8...angular-6.0.0-rc.9) (2026-01-29)
+
+
+### Bug Fixes
+
+* **sidenav:** added presentation role to list item components [#303](https://github.com/TEDI-Design-System/angular/issues/303) ([#304](https://github.com/TEDI-Design-System/angular/issues/304)) ([1713ac9](https://github.com/TEDI-Design-System/angular/commit/1713ac9bd11b16b0e2872b5c4d15781cc2abd704))
+
+# [6.0.0-rc.8](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.7...angular-6.0.0-rc.8) (2026-01-29)
+
+
+### Bug Fixes
+
+* **modal:** use correct z-index variable, default footer align to flex-end [#301](https://github.com/TEDI-Design-System/angular/issues/301) ([#302](https://github.com/TEDI-Design-System/angular/issues/302)) ([c590b0a](https://github.com/TEDI-Design-System/angular/commit/c590b0aa99f776e9d849ccd773197e6305842e1e))
+
+# [6.0.0-rc.7](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.6...angular-6.0.0-rc.7) (2026-01-26)
+
+
+### Features
+
+* **toast:** toast tedi-ready component [#270](https://github.com/TEDI-Design-System/angular/issues/270) ([#271](https://github.com/TEDI-Design-System/angular/issues/271)) ([d5e7954](https://github.com/TEDI-Design-System/angular/commit/d5e7954567050b3c7d431b80cda15fd290a0958f))
+
+# [6.0.0-rc.6](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.5...angular-6.0.0-rc.6) (2026-01-23)
+
+
+### Bug Fixes
+
+* **header:** fixed header-profile aria-label being read as js code [#291](https://github.com/TEDI-Design-System/angular/issues/291) ([#292](https://github.com/TEDI-Design-System/angular/issues/292)) ([5b626fe](https://github.com/TEDI-Design-System/angular/commit/5b626fea88c6f199f1fd783e28ad868415d88e61))
+
+# [6.0.0-rc.5](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.4...angular-6.0.0-rc.5) (2026-01-23)
+
+
+### Bug Fixes
+
+* **sidenav:** fixed SR semantics, added focus handling and styles [#207](https://github.com/TEDI-Design-System/angular/issues/207) ([#289](https://github.com/TEDI-Design-System/angular/issues/289)) ([865fb38](https://github.com/TEDI-Design-System/angular/commit/865fb387fb083b0d2486e2b68d9c0ac8fa6764c1))
+
+# [6.0.0-rc.4](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.3...angular-6.0.0-rc.4) (2026-01-23)
+
+
+### Bug Fixes
+
+* **file-dropzone:** remove single console.log [#66](https://github.com/TEDI-Design-System/angular/issues/66) ([#288](https://github.com/TEDI-Design-System/angular/issues/288)) ([3abde8d](https://github.com/TEDI-Design-System/angular/commit/3abde8d9da916cc6d17035f8879d834fd1372800))
+
+# [6.0.0-rc.3](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.2...angular-6.0.0-rc.3) (2026-01-22)
+
+
+### Bug Fixes
+
+* **alert:** fixed close button overlapping text [#282](https://github.com/TEDI-Design-System/angular/issues/282) ([#287](https://github.com/TEDI-Design-System/angular/issues/287)) ([d2c8e1b](https://github.com/TEDI-Design-System/angular/commit/d2c8e1ba86e4b4dff85b093770c8ab699f26b8b8))
+* **number-field:** wcag fixes [#43](https://github.com/TEDI-Design-System/angular/issues/43) ([#286](https://github.com/TEDI-Design-System/angular/issues/286)) ([b24368f](https://github.com/TEDI-Design-System/angular/commit/b24368f2a47520fc0f1158e37d3f6b502e16abad))
+* **text-group, label:** improved SR accessibility, extended tedi-label usage [#49](https://github.com/TEDI-Design-System/angular/issues/49) ([3900e16](https://github.com/TEDI-Design-System/angular/commit/3900e167cac639b5ac3ab6fd4b7698d4bbd4c6f4))
+
+# [6.0.0-rc.2](https://github.com/TEDI-Design-System/angular/compare/angular-6.0.0-rc.1...angular-6.0.0-rc.2) (2026-01-20)
+
+
+### Bug Fixes
+
+* **date-picker:** fixed Date object comparison, fixed WCAG problems [#259](https://github.com/TEDI-Design-System/angular/issues/259) ([#269](https://github.com/TEDI-Design-System/angular/issues/269)) ([0a8277f](https://github.com/TEDI-Design-System/angular/commit/0a8277f2cd6b0304c0ae6a8e9980393ed7fae701))
+
+# [6.0.0-rc.1](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.8...angular-6.0.0-rc.1) (2026-01-20)
+
+
+### Bug Fixes
+
+* **toggle, number-field:** renamed id to inputId [#24](https://github.com/TEDI-Design-System/angular/issues/24) ([#266](https://github.com/TEDI-Design-System/angular/issues/266)) ([d451a56](https://github.com/TEDI-Design-System/angular/commit/d451a56a8836159e8975a78e4eba3437486c92e5))
+
+
+### BREAKING CHANGES
+
+* **toggle, number-field:** toggle and number-field components must switch to inputId input
+
+## [5.0.1-rc.8](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.7...angular-5.0.1-rc.8) (2026-01-20)
+
+
+### Bug Fixes
+
+* **button:** added correct focus-visible style [#47](https://github.com/TEDI-Design-System/angular/issues/47) ([#279](https://github.com/TEDI-Design-System/angular/issues/279)) ([431eda9](https://github.com/TEDI-Design-System/angular/commit/431eda90c112fd55ca5cdf957186fd60ea6a3ec7))
+
+## [5.0.1-rc.7](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.6...angular-5.0.1-rc.7) (2026-01-20)
+
+
+### Bug Fixes
+
+* **label:** wcag-compliant required label handling [#50](https://github.com/TEDI-Design-System/angular/issues/50) ([#277](https://github.com/TEDI-Design-System/angular/issues/277)) ([ebfdd96](https://github.com/TEDI-Design-System/angular/commit/ebfdd96edc2dd915d5724cff0e4e711307e0cf77))
+
+## [5.0.1-rc.6](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.5...angular-5.0.1-rc.6) (2026-01-20)
+
+
+### Bug Fixes
+
+* **alert:** changed DOM order of alert elements [#44](https://github.com/TEDI-Design-System/angular/issues/44) ([#278](https://github.com/TEDI-Design-System/angular/issues/278)) ([d5974e3](https://github.com/TEDI-Design-System/angular/commit/d5974e323489110e25b52e6090953ca2665ae85c))
+
+## [5.0.1-rc.5](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.4...angular-5.0.1-rc.5) (2026-01-20)
+
+
+### Bug Fixes
+
+* **row, col, collapse:** added tedi-prefix to class names [#251](https://github.com/TEDI-Design-System/angular/issues/251) ([#274](https://github.com/TEDI-Design-System/angular/issues/274)) ([3064c28](https://github.com/TEDI-Design-System/angular/commit/3064c28ed9442daf824ea8fd574b4b86fe2bb5b8))
+
+## [5.0.1-rc.4](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.3...angular-5.0.1-rc.4) (2026-01-19)
+
+
+### Bug Fixes
+
+* **carousel:** carousel wcag fixes [#241](https://github.com/TEDI-Design-System/angular/issues/241) ([#263](https://github.com/TEDI-Design-System/angular/issues/263)) ([91b6913](https://github.com/TEDI-Design-System/angular/commit/91b69136e3267524b718e8c7e8f709a71765ed54))
+* **info-button:** added default aria-label [#46](https://github.com/TEDI-Design-System/angular/issues/46) ([#265](https://github.com/TEDI-Design-System/angular/issues/265)) ([a76084f](https://github.com/TEDI-Design-System/angular/commit/a76084f91704075637b6e0e19508c1c0851f94d4))
+* **info-button:** fixed circular service import [#46](https://github.com/TEDI-Design-System/angular/issues/46) ([cf49aad](https://github.com/TEDI-Design-System/angular/commit/cf49aade10399a386db6c2d4b45c1aee9a35a239))
+* **tooltip:** wcag improvements [#45](https://github.com/TEDI-Design-System/angular/issues/45) ([#267](https://github.com/TEDI-Design-System/angular/issues/267)) ([a860c06](https://github.com/TEDI-Design-System/angular/commit/a860c06e60d3d2df6cf2e6441a3e63d1ca7f5044))
+
+## [5.0.1-rc.3](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.2...angular-5.0.1-rc.3) (2026-01-16)
+
+
+### Bug Fixes
+
+* **file-dropzone:** make file dropzone update forms on deletion, and use form validation instead of it's own internal implementation ([#239](https://github.com/TEDI-Design-System/angular/issues/239)) ([86ebc6f](https://github.com/TEDI-Design-System/angular/commit/86ebc6ffe7c674f8859410f9dccca92db270641b)), closes [#66](https://github.com/TEDI-Design-System/angular/issues/66) [#66](https://github.com/TEDI-Design-System/angular/issues/66) [#66](https://github.com/TEDI-Design-System/angular/issues/66) [#66](https://github.com/TEDI-Design-System/angular/issues/66) [#66](https://github.com/TEDI-Design-System/angular/issues/66)
+
+## [5.0.1-rc.2](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.1-rc.1...angular-5.0.1-rc.2) (2026-01-16)
+
+
+### Bug Fixes
+
+* **modal, table-of-contents:** fix import paths [#243](https://github.com/TEDI-Design-System/angular/issues/243) ([#264](https://github.com/TEDI-Design-System/angular/issues/264)) ([2b95a3f](https://github.com/TEDI-Design-System/angular/commit/2b95a3f68d8def6ebb04c4072e6112711361df7b))
+
+## [5.0.1-rc.1](https://github.com/TEDI-Design-System/angular/compare/angular-5.0.0...angular-5.0.1-rc.1) (2026-01-13)
+
+
+### Bug Fixes
+
+* **toggle:** fixed handle position of active state for default size [#256](https://github.com/TEDI-Design-System/angular/issues/256) ([#257](https://github.com/TEDI-Design-System/angular/issues/257)) ([8357069](https://github.com/TEDI-Design-System/angular/commit/83570693315923d8beb3b69f44f15f5899a905b9))
+
# [5.0.0](https://github.com/TEDI-Design-System/angular/compare/angular-4.1.0...angular-5.0.0) (2026-01-08)
diff --git a/community/components/form/file-dropzone/file-dropzone.component.html b/community/components/form/file-dropzone/file-dropzone.component.html
index c44064015..15f84538e 100644
--- a/community/components/form/file-dropzone/file-dropzone.component.html
+++ b/community/components/form/file-dropzone/file-dropzone.component.html
@@ -36,8 +36,8 @@
@for (file of files(); track file.name) {
FileDropzoneComponent),
- multi: true,
- },
- ],
+ providers: [FileService],
})
export class FileDropzoneComponent implements ControlValueAccessor, OnInit {
/**
@@ -149,6 +148,7 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit {
/**
* Validation functions that can be used to validate files.
* Each function should return a string error message if validation fails, or undefined if it passes
+ * Validators are only added once during component initialization, otherwise use addAsyncValidators on the FormControl.
*/
validators = input([
validateFileSize,
@@ -169,24 +169,25 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit {
**/
fileDelete = output();
+ private _onChange = (_: FileDropzone[]) => {};
+ private _onTouched = () => {};
+
+ private _translationService = inject(TediTranslationService);
+ private _fileService = inject(FileService);
+ private _destroyRef = inject(DestroyRef);
+
fileInputElement =
viewChild.required>("fileInput");
formatBytes = (bytes: number): string =>
formatBytes(bytes, this.sizeDisplayStandard());
- private _uploadState = computed(() => this._fileService.uploadState());
-
- private _onChange = (_: FileDropzone[]) => {};
- private _onTouched = () => {};
-
- private _translationService = inject(TediTranslationService);
- private _fileService = inject(FileService);
+ uploadState = signal("none");
isDragActive = signal(false);
disabled = signal(false);
- uploadError = signal(null);
+ uploadError = signal(undefined);
files = this._fileService.files;
classes = computed(() => {
@@ -197,8 +198,8 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit {
}
if (this.hasError()) {
classList.push("tedi-file-dropzone--invalid");
- } else if (this._uploadState() !== "none") {
- classList.push(`tedi-file-dropzone--${this._uploadState()}`);
+ } else if (this.uploadState() !== "none") {
+ classList.push(`tedi-file-dropzone--${this.uploadState()}`);
}
if (this.isDragActive()) {
classList.push("tedi-file-dropzone--drop-over");
@@ -215,19 +216,76 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit {
this.accept(),
this.maxSize(),
this.sizeDisplayStandard(),
- this._translationService.translate.bind(this._translationService)
+ this._translationService.translate.bind(this._translationService),
),
type: "hint",
position: "left",
}));
+ constructor(@Self() public _ngControl: NgControl) {
+ this._ngControl.valueAccessor = this;
+ }
+
ngOnInit(): void {
this.addFiles(this.defaultFiles());
- this._fileService.maxSize = this.maxSize;
- this._fileService.accept = this.accept;
this._fileService.mode = this.mode;
- this._fileService.validators = this.validators;
- this._fileService.sizeDisplayStandard = this.sizeDisplayStandard;
+ const control = this._ngControl.control;
+ control?.addAsyncValidators(this.runValidators);
+
+ this._ngControl.control?.statusChanges
+ ?.pipe(takeUntilDestroyed(this._destroyRef))
+ .subscribe(this.formChanges.bind(this));
+ }
+
+ formChanges() {
+ this.uploadError.set(this._currentErrorState());
+ this.uploadState.set(this._getNewState());
+ }
+
+ runValidators = async (
+ control: AbstractControl,
+ ): Promise => {
+ const controlFiles = control.value;
+
+ if (!controlFiles || !controlFiles.length) {
+ return null;
+ }
+
+ const issues = this.validateFiles(controlFiles);
+
+ return issues.length
+ ? {
+ tediFileDropzone: [...issues],
+ }
+ : null;
+ };
+
+ validateFiles(files: FileDropzone[]): FileDropzoneErrors {
+ const issues: FileDropzoneErrors = [];
+ files.forEach((file) => {
+ this.validators().forEach((validator) => {
+ const error = validator(
+ this.maxSize(),
+ this.accept(),
+ file,
+ this.sizeDisplayStandard(),
+ this._translationService.translate.bind(this._translationService),
+ );
+
+ if (error && error.code) {
+ issues.push(error);
+ file.fileStatus = "invalid";
+ file.helper = {
+ text: error.message,
+ type: "error",
+ };
+ } else if (!issues.find((issue) => issue.fileName === file.name)) {
+ file.fileStatus = "valid";
+ file.helper = undefined;
+ }
+ });
+ });
+ return issues;
}
fileClasses = (file: FileDropzone): string => {
@@ -301,33 +359,40 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit {
return;
}
- const error = await this._fileService.addFiles(files);
- const normalizedFiles = this._fileService.normalizeFiles(files);
-
- this._updateErrorState(error);
+ await this._fileService.addFiles(files);
this._onChange(this._fileService.files());
- this.fileChange.emit(normalizedFiles);
+ this.fileChange.emit(this._fileService.files());
}
- removeFile(file: FileDropzone) {
- const error = this._fileService.removeFiles([file]);
- this._updateErrorState(error);
+ async removeFile(file: FileDropzone) {
+ await this._fileService.removeFiles([file]);
this._onChange(this._fileService.files());
this.fileDelete.emit(file);
+ this._ngControl.control?.markAsTouched();
+ }
+
+ private _currentErrorState(): string | undefined {
+ const errors = this._ngControl.control?.errors?.["tediFileDropzone"];
+
+ if (errors && !this.validateIndividually()) {
+ const dropzoneErrors: FileDropzoneErrors = errors;
+ return dropzoneErrors.map((error) => error.message).join(" ");
+ }
+ return undefined;
}
- private _updateErrorState(error: string[]) {
- if (
- this._uploadState() === "invalid" &&
- !this.validateIndividually() &&
- error.length
- ) {
- this.uploadError.set(error[0]);
- } else {
- this.uploadError.set("");
+ private _getNewState(): ValidationState {
+ const errors: FileDropzoneErrors =
+ this._ngControl.control?.errors?.["tediFileDropzone"];
+ if (this._ngControl.control?.touched) {
+ return "none";
+ }
+ if (errors?.length) {
+ return "invalid";
}
+ return this.files().length > 0 ? "valid" : "none";
}
onContainerClick() {
diff --git a/community/components/form/file-dropzone/file-dropzone.stories.ts b/community/components/form/file-dropzone/file-dropzone.stories.ts
index cea65add4..d88c94ad4 100644
--- a/community/components/form/file-dropzone/file-dropzone.stories.ts
+++ b/community/components/form/file-dropzone/file-dropzone.stories.ts
@@ -60,6 +60,7 @@ const meta: Meta = {
accept: {
description: `Specifies the allowed file types (e.g., "image/png, image/jpeg").
Does not validate the contents of the file, only the file extension.
+ txt will not work, use .txt instead, the . is required.
https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept`,
},
maxSize: {
@@ -145,7 +146,12 @@ export const Default: Story = {
});
const logValue = () => {
- console.log(form.get("files")?.value);
+ console.log(
+ form.get("files")?.value,
+ form.controls.files,
+ form.controls,
+ form.controls.files.errors
+ );
};
return {
template: `${Template(args)} Log Form Value `,
diff --git a/community/components/form/file-dropzone/file.service.ts b/community/components/form/file-dropzone/file.service.ts
index 4106ab7e2..673e96d51 100644
--- a/community/components/form/file-dropzone/file.service.ts
+++ b/community/components/form/file-dropzone/file.service.ts
@@ -1,32 +1,17 @@
-import { inject, Injectable, Signal, signal } from "@angular/core";
-import {
- DropzoneValidatorFunction,
- FileDropzone,
- FileInputMode,
- SizeDisplayStandard,
- ValidationState,
-} from "./types";
-import { TediTranslationService } from "@tedi-design-system/angular/tedi";
+import { Injectable, Signal, signal } from "@angular/core";
+import { FileDropzone, FileInputMode } from "./types";
@Injectable()
export class FileService {
- maxSize = signal(0).asReadonly();
- accept = signal("").asReadonly();
mode = signal("append").asReadonly();
- validators = signal([]).asReadonly();
- sizeDisplayStandard = signal("IEC").asReadonly();
-
- uploadState = signal("none");
protected _files = signal([]);
- private _translateService = inject(TediTranslationService);
-
get files(): Signal {
return this._files.asReadonly();
}
- public async addFiles(files: FileDropzone[] | File[]): Promise {
+ public async addFiles(files: FileDropzone[] | File[]): Promise {
let newFiles = this.normalizeFiles(files);
const currentFiles = this.files();
@@ -55,19 +40,9 @@ export class FileService {
newFiles.push(...currentFiles);
// remove old invalid files, fileStatus will not yet be set for new files
- if (this.uploadState() === "invalid") {
- newFiles = newFiles.filter((file) => file.fileStatus !== "invalid");
- }
- const error = this._checkErrorState(newFiles);
- this._files.set(newFiles);
- this.uploadState.set(this._getNewState(!!error.length));
- return error;
- }
+ newFiles = newFiles.filter((file) => file.fileStatus !== "invalid");
- public reValidateFiles() {
- const files = this.files();
- const error = this._checkErrorState(files);
- this.uploadState.set(this._getNewState(!!error.length));
+ this._files.set(newFiles);
}
public normalizeFiles(files: FileDropzone[] | File[]): FileDropzone[] {
@@ -86,55 +61,12 @@ export class FileService {
return newFiles;
}
- public removeFiles(files: FileDropzone[]): string[] {
+ public async removeFiles(files: FileDropzone[]): Promise {
if (!files || files.length === 0) {
- return [];
+ return;
}
const newFiles = this.files().filter((file) => !files.includes(file));
this._files.set(newFiles);
- const errors = this._checkErrorState(newFiles);
- if (errors.length) {
- this.uploadState.set("invalid");
- } else {
- this.uploadState.set(this._files.length > 0 ? "valid" : "none");
- }
-
- return errors;
- }
-
- private _getNewState(error: boolean): ValidationState {
- if (error) {
- return "invalid";
- }
- return this._files().length > 0 ? "valid" : "none";
- }
-
- private _checkErrorState(files: FileDropzone[]): string[] {
- const errors: string[] = [];
- for (const file of files) {
- file.helper = undefined;
- const error = this.validators()
- .map((validator) =>
- validator(
- this.maxSize(),
- this.accept(),
- file,
- this.sizeDisplayStandard(),
- this._translateService.translate.bind(this._translateService)
- )
- )
- .filter((err) => err !== undefined);
-
- if (error.length) {
- errors.push(...error);
- file.helper = {
- type: "error",
- text: error.join(", "),
- };
- }
- file.fileStatus = error.length ? "invalid" : "valid";
- }
- return errors;
}
private async _renameDuplicates(
diff --git a/community/components/form/file-dropzone/types.ts b/community/components/form/file-dropzone/types.ts
index 092e72391..9df7d878f 100644
--- a/community/components/form/file-dropzone/types.ts
+++ b/community/components/form/file-dropzone/types.ts
@@ -59,6 +59,19 @@ export class FileDropzone {
}
}
+export interface FileDropzoneError {
+ fileName: string;
+ code: string;
+ message: string;
+}
+
+export type FileDropzoneErrors = FileDropzoneError[];
+
+export enum FileDropzoneErrorCode {
+ FILE_TOO_LARGE = "file-too-large",
+ INVALID_FILE_TYPE = "invalid-file-type",
+}
+
export type FileInputMode = "append" | "replace";
export type ValidationState = "none" | "valid" | "invalid";
@@ -69,6 +82,6 @@ export type DropzoneValidatorFunction = (
file: FileDropzone,
standard: SizeDisplayStandard,
translate: (key: string, ...args: unknown[]) => string
-) => string | undefined;
+) => FileDropzoneError | undefined;
export type SizeDisplayStandard = "SI" | "IEC";
diff --git a/community/components/form/file-dropzone/utils.ts b/community/components/form/file-dropzone/utils.ts
index 916d6e4f5..35c1b9896 100644
--- a/community/components/form/file-dropzone/utils.ts
+++ b/community/components/form/file-dropzone/utils.ts
@@ -1,5 +1,10 @@
import { IECFileSize, SIFileSize } from "./constants";
-import { FileDropzone, SizeDisplayStandard } from "./types";
+import {
+ FileDropzone,
+ FileDropzoneError,
+ FileDropzoneErrorCode,
+ SizeDisplayStandard,
+} from "./types";
export function formatBytes(
bytes: number,
@@ -66,14 +71,18 @@ export function validateFileSize(
file: FileDropzone,
standard: SizeDisplayStandard,
translate: (key: string, ...args: unknown[]) => string
-) {
+): FileDropzoneError | undefined {
if (maxSize && file.size > maxSize) {
const maxSizeMB = formatBytes(maxSize, standard);
- return translate(
- `file-upload.size-rejected-extended`,
- file.name,
- maxSizeMB
- );
+ return {
+ code: FileDropzoneErrorCode.FILE_TOO_LARGE,
+ fileName: file.name,
+ message: translate(
+ `file-upload.size-rejected-extended`,
+ file.name,
+ maxSizeMB
+ ),
+ };
}
return undefined;
}
@@ -84,14 +93,14 @@ export function validateFileType(
file: FileDropzone,
standard: SizeDisplayStandard,
translate: (key: string, ...args: unknown[]) => string
-) {
+): FileDropzoneError | undefined {
if (acceptFileTypes) {
const validTypes = acceptFileTypes
.split(",")
.map((type) => type.trim().toLowerCase());
const fileType = file.type.toLowerCase();
- const fileName = file.name.toLowerCase() || "";
+ const fileName = file.name.toLowerCase();
const matches = validTypes.some((type) => {
if (type.startsWith(".")) {
@@ -104,11 +113,15 @@ export function validateFileType(
});
if (!matches) {
- return translate(
- `file-upload.extension-rejected-extended`,
- file.name,
- acceptFileTypes
- );
+ return {
+ code: FileDropzoneErrorCode.INVALID_FILE_TYPE,
+ fileName: file.name,
+ message: translate(
+ "file-upload.extension-rejected-extended",
+ file.name,
+ acceptFileTypes
+ ),
+ };
}
}
return undefined;
diff --git a/community/components/navigation/breadcrumbs/breadcrumbs.component.html b/community/components/navigation/breadcrumbs/breadcrumbs.component.html
index a64aa6f74..dbff796bc 100644
--- a/community/components/navigation/breadcrumbs/breadcrumbs.component.html
+++ b/community/components/navigation/breadcrumbs/breadcrumbs.component.html
@@ -1,47 +1,38 @@
- 0"
- aria-label="Breadcrumb"
- class="tedi__breadcrumbs"
->
-
-
-
- @if (!last) {
-
- {{
- crumb.label
- }}
-
-
- } @else {
-
- {{ crumb.label }}
-
+@if (breakpointInputs().crumbs.length > 0) {
+
+
+ @if (!breakpointInputs().shortCrumbs) {
+ @for (crumb of breakpointInputs().crumbs; track crumb.href; let last = $last) {
+ @if (!last) {
+
+ {{
+ crumb.label
+ }}
+
+
+ } @else {
+
+ {{ crumb.label }}
+
+ }
}
-
-
-
- 1 &&
- this.breakpointInputs().shortCrumbs
- "
- >
-
-
-
+ }
+
+ @if (breakpointInputs().crumbs.length > 1 && breakpointInputs().shortCrumbs) {
+
+ }
+
+}
diff --git a/community/components/navigation/breadcrumbs/breadcrumbs.component.scss b/community/components/navigation/breadcrumbs/breadcrumbs.component.scss
index f53e72c73..254de03d2 100644
--- a/community/components/navigation/breadcrumbs/breadcrumbs.component.scss
+++ b/community/components/navigation/breadcrumbs/breadcrumbs.component.scss
@@ -2,7 +2,7 @@
display: block;
}
-.tedi__breadcrumbs {
+.tedi-breadcrumbs {
&__list {
display: flex;
flex-wrap: wrap;
diff --git a/community/components/navigation/breadcrumbs/breadcrumbs.component.ts b/community/components/navigation/breadcrumbs/breadcrumbs.component.ts
index 6a6b0aa79..22d5912c7 100644
--- a/community/components/navigation/breadcrumbs/breadcrumbs.component.ts
+++ b/community/components/navigation/breadcrumbs/breadcrumbs.component.ts
@@ -1,5 +1,4 @@
import { Component, computed, inject, input } from "@angular/core";
-import { CommonModule } from "@angular/common";
import { RouterLink } from "@angular/router";
import {
IconComponent,
@@ -45,7 +44,6 @@ export type Breadcrumbs = {
selector: "tedi-breadcrumbs",
standalone: true,
imports: [
- CommonModule,
RouterLink,
LinkComponent,
IconComponent,
diff --git a/community/components/navigation/table-of-contents/table-of-contents.component.ts b/community/components/navigation/table-of-contents/table-of-contents.component.ts
index f4eb7931f..636d49c69 100644
--- a/community/components/navigation/table-of-contents/table-of-contents.component.ts
+++ b/community/components/navigation/table-of-contents/table-of-contents.component.ts
@@ -14,7 +14,7 @@ import {
import {
CardComponent,
CardContentComponent,
-} from "community/components/cards";
+} from "../../cards/card";
import {
TextComponent,
IconComponent,
diff --git a/community/components/navigation/vertical-stepper/index.ts b/community/components/navigation/vertical-stepper/index.ts
new file mode 100644
index 000000000..18dbe5e80
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/index.ts
@@ -0,0 +1,2 @@
+export * from "./vertical-stepper.component";
+export * from "./vertical-stepper-item/vertical-stepper-item.component";
diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.html b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.html
new file mode 100644
index 000000000..28ce0f9be
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.html
@@ -0,0 +1,79 @@
+
+ @if (subItem() || !compact()) {
+ @if (completed()) {
+
+ } @else if (error()) {
+
+ }
+ }
+
+
+
+ @if (!selected() && compact() && !subItem()) {
+ @if (completed()) {
+
+ } @else if (error()) {
+
+ }
+ }
+
+
+
+ @if (hasSubItems()) {
+
+ {{ title() }}
+
+ } @else {
+
+ @if (route()) {
+ {{ title() }}
+ } @else {
+
+ {{ title() }}
+
+ }
+
+
+ }
+
+
+
+
+@if (opened()) {
+
+}
diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.scss b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.scss
new file mode 100644
index 000000000..2215c69c8
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.scss
@@ -0,0 +1,271 @@
+.tedi-vertical-stepper-item {
+ $host-class: &;
+ display: grid;
+ position: relative;
+
+ --_step-indicator-size: 24px;
+ --_step-indicator-color: var(--general-text-secondary, #4b4e62);
+ --_step-indicator-border: var(--stepper-step-default-border, #9293a4);
+ --_step-indicator-border-hover: var(
+ --stepper-step-default-border-hover,
+ #005aa3
+ );
+ --_step-indicator-border-width: 1px;
+ --_step-indicator-background: var(--stepper-step-default-bg, #fff);
+ --_step-indicator-background-hover: var(--stepper-step-default-bg, #fff);
+
+ --_step-padding: var(--stepper-item-vertical-lg-padding-top, 8px);
+ --_step-inner-spacing: var(--stepper-item-vertical-lg-inner-spacing, 8px);
+ --_step-line-color: var(--stepper-item-vertical-line, #9293a4);
+
+ --_step-title-color: var(--stepper-item-vertical-text-default, #151926);
+ --_step-title-color-hover: var(--stepper-item-vertical-text-hover, #004277);
+ --_step-title-size: var(--body-regular-size, 16px);
+ --_step-title-weight: var(--body-regular-weight, 400);
+ --_step-title-line-height: var(--body-regular-line-height, 24px);
+ --_step-title-spacing: var(--layout-grid-gutters-08, 8px);
+
+ grid-template-areas:
+ "indicator title"
+ ". description";
+ grid-template-columns: auto 1fr;
+ grid-auto-columns: auto;
+ column-gap: var(--_step-inner-spacing);
+ z-index: 0;
+
+ &__indicator {
+ grid-area: indicator;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: var(--_step-indicator-size);
+ width: var(--_step-indicator-size);
+ border: var(--_step-indicator-border) solid
+ var(--_step-indicator-border-width);
+ background-color: var(--_step-indicator-background);
+ color: var(--_step-indicator-color);
+ border-radius: 9999px;
+ font-size: var(--body-small-bold-size, 14px);
+ font-weight: var(--body-bold-weight, 700);
+ align-self: center;
+ justify-self: center;
+
+ &::before {
+ content: counter(step-number);
+ }
+ }
+
+ &:has(> &__title :is(button, a):hover) > &__indicator {
+ background-color: var(--_step-indicator-background-hover);
+ border-color: var(--_step-indicator-border-hover);
+ }
+
+ &:not(&--sub-item) {
+ counter-increment: step-number;
+ }
+
+ &__title {
+ grid-area: title;
+ padding: var(--_step-padding) 0;
+
+ a,
+ button {
+ padding: 0;
+ background: none;
+ border: none;
+ text-decoration: none;
+ font-family: var(--family-default, Roboto);
+ font-size: var(--_step-title-size);
+ font-weight: var(--_step-title-weight);
+ line-height: var(--_step-title-line-height);
+ cursor: pointer;
+ color: var(--_step-title-color);
+
+ &:hover {
+ color: var(--_step-title-color-hover);
+ &:not(#{$host-class}__toggle) {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+
+ &__toggle {
+ display: flex;
+ width: 100%;
+ align-items: center;
+
+ &:hover > span {
+ text-decoration: underline;
+ }
+ }
+
+ &__toggle-icon {
+ margin-left: auto;
+ transition: transform 0.3s;
+ &--opened {
+ transform: rotateX(180deg);
+ }
+ }
+
+ &__status-icon {
+ margin-inline: var(--_step-title-spacing);
+ }
+
+ &__description {
+ grid-area: description;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ &__line {
+ &::before,
+ &::after {
+ content: "";
+ grid-row: 1 / span 1;
+ grid-column: 1 / span 1;
+ left: 50%;
+ transform: translateX(-50%);
+ position: absolute;
+ width: 1px;
+ background-color: var(--_step-line-color);
+ top: 0;
+ bottom: 0;
+ z-index: -1;
+ }
+
+ &::before {
+ grid-row: 1 / span 1;
+ }
+
+ &::after {
+ grid-row: 2 / span 1;
+ }
+ }
+
+ &:not(&--sub-item):first-child > &__line {
+ &::before {
+ top: 50%;
+ }
+ }
+
+ &:not(&--sub-item):last-child {
+ &:not(:has(#{$host-class}--sub-item)) > #{$host-class}__line::before {
+ bottom: 50%;
+ }
+ & > #{$host-class}__line::after {
+ content: none;
+ }
+
+ #{$host-class}--sub-item:last-child #{$host-class}__line {
+ &::before {
+ bottom: 50%;
+ }
+ &::after {
+ content: none;
+ }
+ }
+ }
+
+ &--compact {
+ --_step-indicator-size: var(--stepper-item-vertical-step-size-md, 16px);
+ --_step-padding: var(--stepper-item-vertical-compact-padding-top, 3px);
+ --_step-inner-spacing: var(
+ --stepper-item-vertical-compact-inner-spacing,
+ 6px
+ );
+ --_step-title-spacing: var(--layout-grid-gutters-04, 4px);
+ #{$host-class}__indicator::before {
+ content: none;
+ }
+
+ {$host-class}--enumerated:not(#{$host-class}--sub-item)
+ > #{$host-class}__title
+ :is(a, button)::before {
+ content: counter(step-number) ".";
+ display: inline-block;
+ }
+ }
+
+ &--sub-item {
+ grid-column: 1 / span 2;
+ grid-template-columns: subgrid;
+ --_step-indicator-size: var(--stepper-item-vertical-step-size-sm, 9px);
+ --_step-padding: var(--stepper-item-vertical-padding-y-sm, 2px);
+
+ #{$host-class}__indicator::before {
+ content: none;
+ }
+ }
+
+ &--completed {
+ --_step-indicator-background: var(--stepper-step-completed-bg, #266b42);
+ --_step-indicator-border: var(--stepper-step-completed-bg, #266b42);
+ --_step-indicator-color: var(--general-text-white, #fff);
+ --_step-indicator-border-hover: var(
+ --stepper-step-completed-bg-hover,
+ #1d5032
+ );
+ --_step-indicator-background-hover: var(
+ --stepper-step-completed-bg-hover,
+ #1d5032
+ );
+ }
+
+ &--error {
+ --_step-indicator-background: var(--stepper-step-danger-bg, #ac3232);
+ --_step-indicator-border: var(--stepper-step-danger-bg, #ac3232);
+ --_step-indicator-color: var(--general-text-white, #fff);
+ --_step-indicator-border-hover: var(
+ --stepper-step-danger-bg-hover,
+ #812525
+ );
+ --_step-indicator-background-hover: var(
+ --stepper-step-danger-bg-hover,
+ #812525
+ );
+ }
+
+ &--selected {
+ --_step-indicator-background: var(--stepper-step-selected-bg, #fff);
+ --_step-indicator-color: var(
+ --stepper-item-vertical-text-selected,
+ #004277
+ );
+ --_step-indicator-border: var(--stepper-step-selected-border, #004277);
+ --_step-indicator-border-width: 2px;
+ --_step-indicator-border-hover: var(
+ --stepper-step-selected-border-hover,
+ #003662
+ );
+ --_step-title-color: var(--stepper-item-vertical-text-selected, #004277);
+ --_step-title-weight: var(--body-bold-weight, 700);
+ {$host-class}--compact:not(#{$host-class}--sub-item) {
+ --_step-indicator-border-width: 4px;
+ }
+ }
+
+ &--disabled {
+ --_step-indicator-background: var(--stepper-step-disabled-bg, #d2d3d8);
+ --_step-indicator-color: var(
+ --stepper-item-vertical-text-disabled,
+ #9293a4
+ );
+ --_step-indicator-border: var(--stepper-step-disabled-border, #9293a4);
+ --_step-title-color: var(--stepper-item-vertical-text-disabled, #9293a4);
+ #{$host-class}__title {
+ pointer-events: none;
+ }
+ }
+
+ &--informative {
+ --_step-indicator-background: var(--stepper-step-disabled-bg, #d2d3d8);
+ --_step-indicator-border: var(--stepper-step-disabled-border, #9293a4);
+ --_step-title-color: var(--general-text-tertiary, #5d6071);
+ #{$host-class}__title {
+ pointer-events: none;
+ }
+ }
+}
diff --git a/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.ts b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.ts
new file mode 100644
index 000000000..b8d1af51b
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/vertical-stepper-item/vertical-stepper-item.component.ts
@@ -0,0 +1,88 @@
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ contentChildren,
+ effect,
+ inject,
+ input,
+ model,
+ output,
+ signal,
+ ViewEncapsulation,
+} from "@angular/core";
+import { VerticalStepperComponent } from "../vertical-stepper.component";
+import { RouterLink, RouterLinkActive } from "@angular/router";
+import { NgTemplateOutlet } from "@angular/common";
+import {
+ IconComponent,
+ TediTranslationPipe,
+} from "@tedi-design-system/angular/tedi";
+
+@Component({
+ selector: "tedi-vertical-stepper-item",
+ imports: [
+ IconComponent,
+ RouterLink,
+ RouterLinkActive,
+ NgTemplateOutlet,
+ TediTranslationPipe,
+ ],
+ templateUrl: "./vertical-stepper-item.component.html",
+ styleUrl: "./vertical-stepper-item.component.scss",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ host: {
+ "[class.tedi-vertical-stepper-item]": "true",
+ "[class.tedi-vertical-stepper-item--completed]": "completed()",
+ "[class.tedi-vertical-stepper-item--error]": "error()",
+ "[class.tedi-vertical-stepper-item--selected]": "selected()",
+ "[class.tedi-vertical-stepper-item--disabled]": "disabled()",
+ "[class.tedi-vertical-stepper-item--informative]": "informative()",
+ "[class.tedi-vertical-stepper-item--sub-item]": "subItem()",
+ "[class.tedi-vertical-stepper-item--compact]": "compact()",
+ "[class.tedi-vertical-stepper-item--enumerated]": "enumerated()",
+ "[attr.role]": "'listitem'",
+ },
+})
+export class VerticalStepperItemComponent {
+ completed = input(false, { transform: booleanAttribute });
+ error = input(false, { transform: booleanAttribute });
+ selected = input(false, { transform: booleanAttribute });
+ disabled = input(false, { transform: booleanAttribute });
+ informative = input(false, { transform: booleanAttribute });
+ title = input.required();
+ route = input(undefined);
+ opened = model(false); // for items with children
+
+ itemSelect = output();
+
+ private stepperContext = inject(VerticalStepperComponent, { optional: true });
+ subItem = signal(false);
+ subItems = contentChildren(VerticalStepperItemComponent);
+ compact = computed(() => this.stepperContext?.compact());
+ enumerated = computed(() => this.stepperContext?.enumerated());
+ hasSubItems = computed(() => !!this.subItems().length);
+
+ onSubItemSelect = effect(() => {
+ const subItemSelected = this.subItems().some((item) => item.selected());
+ if (subItemSelected) {
+ this.opened.set(true);
+ }
+ });
+
+ onSubItemChanges = effect(() => {
+ this.subItems().forEach((item) => item.subItem.set(true));
+ });
+
+ toggleOpen() {
+ this.opened.update((previouslyOpened) => !previouslyOpened);
+ }
+
+ routerLinkActiveChange(isActive: boolean) {
+ if (isActive) {
+ this.itemSelect.emit();
+ }
+ }
+}
diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.component.html b/community/components/navigation/vertical-stepper/vertical-stepper.component.html
new file mode 100644
index 000000000..8c590a16f
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/vertical-stepper.component.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.component.scss b/community/components/navigation/vertical-stepper/vertical-stepper.component.scss
new file mode 100644
index 000000000..d757cebf3
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/vertical-stepper.component.scss
@@ -0,0 +1,3 @@
+.tedi-vertical-stepper {
+ counter-reset: step-number;
+}
diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.component.ts b/community/components/navigation/vertical-stepper/vertical-stepper.component.ts
new file mode 100644
index 000000000..a3dd76b33
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/vertical-stepper.component.ts
@@ -0,0 +1,25 @@
+import {
+ booleanAttribute,
+ ChangeDetectionStrategy,
+ Component,
+ input,
+ ViewEncapsulation,
+} from "@angular/core";
+
+@Component({
+ selector: "tedi-vertical-stepper",
+ imports: [],
+ templateUrl: "./vertical-stepper.component.html",
+ styleUrl: "./vertical-stepper.component.scss",
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ host: {
+ "[class.tedi-vertical-stepper]": "true",
+ "[class.tedi-vertical-stepper--compact]": "compact()",
+ },
+})
+export class VerticalStepperComponent {
+ ariaLabel = input();
+ compact = input(false, { transform: booleanAttribute });
+ enumerated = input(false, { transform: booleanAttribute });
+}
diff --git a/community/components/navigation/vertical-stepper/vertical-stepper.stories.ts b/community/components/navigation/vertical-stepper/vertical-stepper.stories.ts
new file mode 100644
index 000000000..e2da25b10
--- /dev/null
+++ b/community/components/navigation/vertical-stepper/vertical-stepper.stories.ts
@@ -0,0 +1,346 @@
+import {
+ argsToTemplate,
+ Meta,
+ moduleMetadata,
+ StoryObj,
+} from "@storybook/angular";
+import { VerticalStepperComponent } from "./vertical-stepper.component";
+import { VerticalStepperItemComponent } from "./vertical-stepper-item/vertical-stepper-item.component";
+import { StatusBadgeComponent } from "../../tags/status-badge/status-badge.component";
+
+/**
+ * The `vertical-stepper` component is stepper where steps are displayed in vertical sequence.
+ *
+ * Vertical-stepper component consists of individual `vertical-stepper-item` components. Steps have title and can be used as routes or buttons for non-routed navigation.
+ *
+ * Step title must be provided as input. Title template can be also provided as element with `item-title` attribute for cases with custom routing logic etc.
+ *
+ * Steps can also have description/action. They can be provided as element with `item-description` attribute.
+ *
+ * Steps can also have sub steps. They can be provided as nested `vertical-stepper-item` components.
+ */
+
+export default {
+ title: "Community/Navigation/VerticalStepper",
+ component: VerticalStepperComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [
+ VerticalStepperComponent,
+ VerticalStepperItemComponent,
+ StatusBadgeComponent,
+ ],
+ }),
+ ],
+ argTypes: {
+ compact: {
+ description: "Whether it's the compact variant",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ enumerated: {
+ description:
+ "Used for compact variant, displays step number infront of the step title",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ ariaLabel: {
+ description: "Aria label for stepper",
+ control: "text",
+ table: {
+ category: "vertical-stepper",
+ type: { summary: "string" },
+ },
+ },
+ itemTitle: {
+ name: "title",
+ description:
+ "Item title. Title can also be provided by element with `item-title` attribute. Input is required for mobile view",
+ control: "text",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "string" },
+ },
+ },
+ itemCompleted: {
+ name: "completed",
+ description: "Is vertical stepper item completed",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ itemError: {
+ name: "error",
+ description: "Does vertical stepper item have error",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ itemSelected: {
+ name: "selected",
+ description: "Is vertical stepper item selected",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ itemDisabled: {
+ name: "disabled",
+ description: "Is vertical stepper item disabled",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ itemInformative: {
+ name: "informative",
+ description:
+ "Is vertical stepper item informative item. For sub items only",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ itemOpened: {
+ name: "opened",
+ description:
+ "Is vertical stepper item opened. For parent items with sub items only",
+ control: "boolean",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "false" },
+ },
+ },
+ itemRoute: {
+ name: "route",
+ description:
+ "Router link for item. If provided, leaf items title will be an anchor element, else button",
+ control: "text",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "RouterLink.routerLink: string | any[] | UrlTree" },
+ },
+ },
+ itemSelect: {
+ description: "Event for when item is selected",
+ table: {
+ category: "vertical-stepper-item",
+ type: { summary: "output" },
+ },
+ },
+ },
+} as Meta;
+
+export const Default: StoryObj = {
+ args: {},
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const Compact: StoryObj = {
+ args: {
+ compact: true,
+ },
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const EnumeratedCompact: StoryObj = {
+ args: {
+ compact: true,
+ enumerated: true,
+ },
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const WithRouterlinks: StoryObj = {
+ args: {},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "For cases when steps are on multiple routes. Item emits `itemSelect` event when its routerLink becomes active",
+ },
+ },
+ },
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const ProjectedTitleTemplates: StoryObj = {
+ args: {},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "For cases when step titles need custom templates or advanced routing logic. For example fragmented navigation. `button` and `a` elements inherit styles",
+ },
+ },
+ },
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+ Link 1
+
+
+ Link 2
+
+
+ Link 3
+
+
+ Link 4
+
+
+ Link 5
+
+
+ `,
+ }),
+};
diff --git a/package-lock.json b/package-lock.json
index 79ec643c8..fcb4226a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"name": "@tedi-design-system/angular",
"version": "0.0.0-semantic-version",
"dependencies": {
- "@tedi-design-system/core": "^3.0.0"
+ "@tedi-design-system/core": "^3.0.1"
},
"devDependencies": {
"@angular-devkit/core": "19.2.15",
@@ -69,6 +69,10 @@
"storybook": "^8.4.7",
"storybook-addon-angular-router": "^1.10.1",
"storybook-addon-pseudo-states": "^4.0.3",
+ "stylelint": "^17.0.0",
+ "stylelint-config-prettier-scss": "^1.0.0",
+ "stylelint-config-recess-order": "^7.4.0",
+ "stylelint-config-standard-scss": "^17.0.0",
"ts-node": "^10.9.2",
"tslib": "^2.5.0",
"typescript": "5.8"
@@ -171,6 +175,7 @@
"integrity": "sha512-uIxi6Vzss6+ycljVhkyPUPWa20w8qxJL9lEn0h6+sX/fhM8Djt0FHIuTQjoX58EoMaQ/1jrXaRaGimkbaFcG9A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "2.3.0",
"@angular-devkit/architect": "0.1902.19",
@@ -297,6 +302,7 @@
"integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ajv": "8.17.1",
"ajv-formats": "3.0.1",
@@ -325,6 +331,7 @@
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -335,6 +342,7 @@
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -356,6 +364,7 @@
"integrity": "sha512-x2tlGg5CsUveFzuRuqeHknSbGirSAoRynEh+KqPRGK0G3WpMViW/M8SuVurecasegfIrDWtYZ4FnVxKqNbKwXQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@angular-devkit/architect": "0.1902.19",
"rxjs": "7.8.1"
@@ -376,6 +385,7 @@
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -540,7 +550,6 @@
"integrity": "sha512-XLPt6gk8VMOrUO9NWRpXN8zgwJuCDV+9y3KbVnd4WyakO0sOz9SVzktuI4AeY9jWS9/tqU6P8Uj0WZsMVz7F8w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "19.7.0",
"eslint-scope": "^8.0.2"
@@ -571,7 +580,6 @@
"integrity": "sha512-eq9vokLU8bjs7g/Znz8zJUQEOhT0MAJ/heBCHbB35S+CtZXJmItrsEqkI1tsRiR58NKXB6cbhBhULVo6qJbhXQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -589,6 +597,7 @@
"integrity": "sha512-SFzQ1bRkNFiOVu+aaz+9INmts7tDUrsHLEr9HmARXr9qk5UmR8prlw39p2u+Bvi6/lCiJ18TZMQQl9mGyr63lg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "2.3.0",
"@angular-devkit/architect": "0.1902.19",
@@ -675,6 +684,7 @@
"integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -772,7 +782,6 @@
"integrity": "sha512-aVa/ctBYH/4qgA7r4sS7TV+/DzRYmcS+3d6l89pNKUXkI8gpmsd+r3FjccaemX4Wqru1QOrMvC+i+e7IBIVv0g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -804,7 +813,6 @@
"integrity": "sha512-4r5tvGA2Ok3o8wROZBkF9qNKS7L0AEpdBIkAVJbLw2rBY2SlyycFIRYyV2+D1lJ1jq/f9U7uN6oon0MjTvNYkA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/core": "7.26.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@@ -882,7 +890,6 @@
"integrity": "sha512-PxhzCwwm23N4Mq6oV7UPoYiJF4r6FzGhRSxOBBlEp322k7zEQbIxd/XO6F3eoG73qC1UsOXMYYv6GnQpx42y3A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -900,7 +907,6 @@
"integrity": "sha512-pZDElcYPmNzPxvWJpZQCIizsNApDIfk9xLJE4I8hzLISfWGbQvfjuuarDAuQZEXudeLXoDOstDXkDja40muLGg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -930,7 +936,6 @@
"integrity": "sha512-OelQ6weCjon8kZD8kcqNzwugvZJurjS3uMJCwsA2vXmP/3zJ31SWtNqE2zLT1R2csVuwnp0h+nRMgq+pINU7Rg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -954,7 +959,6 @@
"integrity": "sha512-dKy0SS395FCh8cW9AQ8nf4Wn3XlONaH7z50T1bGxm3eOoRqjxJYyIeIlEbDdJakMz4QPR3dGr81HleZd8TJumQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -974,7 +978,6 @@
"integrity": "sha512-0TM1D8S7RQ00drKy7hA/ZLBY14dUBqFBgm06djcNcOjNzVAtgkeV0i+0Smq9tCC7UsGKdpZu4RgfYjHATBNlTQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -1029,7 +1032,6 @@
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -1095,6 +1097,7 @@
"integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/types": "^7.25.9"
},
@@ -1380,6 +1383,7 @@
"integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/types": "^7.24.7"
},
@@ -1853,6 +1857,7 @@
"integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-remap-async-to-generator": "^7.25.9",
@@ -1871,6 +1876,7 @@
"integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.25.9",
"@babel/helper-plugin-utils": "^7.25.9",
@@ -2571,6 +2577,7 @@
"integrity": "sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.25.9",
"@babel/helper-plugin-utils": "^7.26.5",
@@ -2592,6 +2599,7 @@
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
+ "peer": true,
"bin": {
"semver": "bin/semver.js"
}
@@ -2750,6 +2758,7 @@
"integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/compat-data": "^7.26.8",
"@babel/helper-compilation-targets": "^7.26.5",
@@ -2834,6 +2843,7 @@
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
+ "peer": true,
"bin": {
"semver": "bin/semver.js"
}
@@ -2938,6 +2948,67 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@cacheable/memory": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz",
+ "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.3.3",
+ "@keyv/bigmap": "^1.3.0",
+ "hookified": "^1.14.0",
+ "keyv": "^5.5.5"
+ }
+ },
+ "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.0.tgz",
+ "integrity": "sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.2.0",
+ "hookified": "^1.13.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.5.4"
+ }
+ },
+ "node_modules/@cacheable/memory/node_modules/keyv": {
+ "version": "5.5.5",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
+ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.3.tgz",
+ "integrity": "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.3.0",
+ "keyv": "^5.5.5"
+ }
+ },
+ "node_modules/@cacheable/utils/node_modules/keyv": {
+ "version": "5.5.5",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
+ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -3332,7 +3403,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -3899,12 +3969,146 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.0.25",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
+ "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/media-query-list-parser": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz",
+ "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/selector-resolve-nested": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz",
+ "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "postcss-selector-parser": "^7.1.1"
+ }
+ },
+ "node_modules/@csstools/selector-specificity": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
+ "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "postcss-selector-parser": "^7.1.1"
+ }
+ },
"node_modules/@discoveryjs/json-ext": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
"integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=14.17.0"
}
@@ -4383,6 +4587,7 @@
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
@@ -4398,6 +4603,7 @@
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -4409,6 +4615,7 @@
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -4422,6 +4629,7 @@
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@eslint/core": "^0.17.0"
},
@@ -4435,6 +4643,7 @@
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
},
@@ -4448,6 +4657,7 @@
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
@@ -4472,6 +4682,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4489,6 +4700,7 @@
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -4500,6 +4712,7 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 4"
}
@@ -4509,7 +4722,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
@@ -4517,6 +4731,7 @@
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -4530,6 +4745,7 @@
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -4543,6 +4759,7 @@
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
@@ -4553,6 +4770,7 @@
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
@@ -4605,6 +4823,7 @@
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=18.18.0"
}
@@ -4615,6 +4834,7 @@
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
@@ -4629,6 +4849,7 @@
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=12.22"
},
@@ -4643,6 +4864,7 @@
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=18.18"
},
@@ -4887,7 +5109,6 @@
"integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@inquirer/checkbox": "^4.1.2",
"@inquirer/confirm": "^5.1.6",
@@ -5690,7 +5911,6 @@
"integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/core": "^7.11.6",
"@jest/types": "^29.6.3",
@@ -5768,7 +5988,6 @@
"integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/schemas": "^29.6.3",
"@types/istanbul-lib-coverage": "^2.0.0",
@@ -5881,6 +6100,7 @@
"integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=10.0"
},
@@ -5898,6 +6118,7 @@
"integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=10.0"
},
@@ -5915,6 +6136,7 @@
"integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=10.0"
},
@@ -5932,6 +6154,7 @@
"integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@jsonjoy.com/base64": "^1.1.2",
"@jsonjoy.com/buffers": "^1.2.0",
@@ -5959,6 +6182,7 @@
"integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@jsonjoy.com/codegen": "^1.0.0",
"@jsonjoy.com/util": "^1.9.0"
@@ -5980,6 +6204,7 @@
"integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@jsonjoy.com/buffers": "^1.0.0",
"@jsonjoy.com/codegen": "^1.0.0"
@@ -5995,12 +6220,20 @@
"tslib": "2"
}
},
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@listr2/prompt-adapter-inquirer": {
"version": "2.0.18",
@@ -6053,7 +6286,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@lmdb/lmdb-darwin-x64": {
"version": "3.2.6",
@@ -6067,7 +6301,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@lmdb/lmdb-linux-arm": {
"version": "3.2.6",
@@ -6081,7 +6316,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@lmdb/lmdb-linux-arm64": {
"version": "3.2.6",
@@ -6095,7 +6331,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@lmdb/lmdb-linux-x64": {
"version": "3.2.6",
@@ -6109,7 +6346,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@lmdb/lmdb-win32-x64": {
"version": "3.2.6",
@@ -6123,7 +6361,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@mdx-js/react": {
"version": "3.1.1",
@@ -6155,7 +6394,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
@@ -6169,7 +6409,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
@@ -6183,7 +6424,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
@@ -6197,7 +6439,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
@@ -6211,7 +6454,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
@@ -6225,7 +6469,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@napi-rs/nice": {
"version": "1.1.1",
@@ -6556,6 +6801,7 @@
"integrity": "sha512-R9aeTrOBiRVl8I698JWPniUAAEpSvzc8SUGWSM5UXWMcHnWqd92cOnJJ1aXDGJZKXrbhMhCBx9Dglmcks5IDpg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
@@ -6925,7 +7171,6 @@
"integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.3",
@@ -7554,7 +7799,8 @@
"optional": true,
"os": [
"android"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.34.8",
@@ -7568,7 +7814,8 @@
"optional": true,
"os": [
"android"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.34.8",
@@ -7582,7 +7829,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.34.8",
@@ -7596,7 +7844,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.34.8",
@@ -7610,7 +7859,8 @@
"optional": true,
"os": [
"freebsd"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.34.8",
@@ -7624,7 +7874,8 @@
"optional": true,
"os": [
"freebsd"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.34.8",
@@ -7638,7 +7889,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.34.8",
@@ -7652,7 +7904,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.34.8",
@@ -7666,7 +7919,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.34.8",
@@ -7680,7 +7934,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.2",
@@ -7694,7 +7949,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.34.8",
@@ -7708,7 +7964,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.34.8",
@@ -7722,7 +7979,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.2",
@@ -7736,7 +7994,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.34.8",
@@ -7750,7 +8009,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.2",
@@ -7764,7 +8024,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.34.8",
@@ -7778,7 +8039,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.34.8",
@@ -7792,7 +8054,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.34.8",
@@ -7806,7 +8069,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.2",
@@ -7820,7 +8084,8 @@
"optional": true,
"os": [
"openharmony"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.34.8",
@@ -7834,7 +8099,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.34.8",
@@ -7848,7 +8114,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.2",
@@ -7862,7 +8129,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.34.8",
@@ -7876,7 +8144,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/@rollup/wasm-node": {
"version": "4.53.2",
@@ -8562,6 +8831,7 @@
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -9236,7 +9506,6 @@
"integrity": "sha512-2GhcCd4dNMrnD7eooEfvbfL4I83qAqEyO0CO7JQAmIO6Rxb9BsOLLI/GD5HkvQB73ArTJ+PT50rfaO820IExOQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/storybook"
@@ -9362,9 +9631,9 @@
}
},
"node_modules/@tedi-design-system/core": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-3.0.0.tgz",
- "integrity": "sha512-YRu9Aa1+WKg+a2iCJd+miDjwa/8bLwajKSa00666W6c9KFZkgRF2B7j/UkyX5BvGhp+iHzTyA5yUfcK3MqTFMA==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-3.0.1.tgz",
+ "integrity": "sha512-ioet8RlFmWjg8fic4WUuYeavLiqUsKx3vFGZzzXkL91xNNjHexNVKhhtMLLkpCywzOc2tKXMx3AYdDhu2dsbwg==",
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
@@ -9415,6 +9684,7 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -9675,6 +9945,7 @@
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
@@ -9686,6 +9957,7 @@
"integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -9696,6 +9968,7 @@
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -9706,6 +9979,7 @@
"integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/express-serve-static-core": "*",
"@types/node": "*"
@@ -9727,7 +10001,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@@ -9757,6 +10030,7 @@
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
@@ -9770,6 +10044,7 @@
"integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
@@ -9792,7 +10067,8 @@
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
@@ -9806,7 +10082,8 @@
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/http-proxy": {
"version": "1.17.17",
@@ -9814,6 +10091,7 @@
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -9909,7 +10187,8 @@
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/node": {
"version": "22.19.1",
@@ -9917,7 +10196,6 @@
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -9928,6 +10206,7 @@
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -9958,14 +10237,16 @@
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/react": {
"version": "18.3.27",
@@ -9973,7 +10254,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -9994,7 +10274,8 @@
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/semver": {
"version": "7.7.1",
@@ -10009,6 +10290,7 @@
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -10019,6 +10301,7 @@
"integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/express": "*"
}
@@ -10029,6 +10312,7 @@
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
@@ -10041,6 +10325,7 @@
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
@@ -10052,6 +10337,7 @@
"integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -10090,6 +10376,7 @@
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -10255,7 +10542,6 @@
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -10299,7 +10585,6 @@
"integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.47.0",
@@ -10355,6 +10640,7 @@
"integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=14.21.3"
},
@@ -10688,7 +10974,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10713,6 +10998,7 @@
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
@@ -10736,6 +11022,7 @@
"integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"loader-utils": "^2.0.0",
"regex-parser": "^2.2.11"
@@ -10750,6 +11037,7 @@
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
@@ -10789,7 +11077,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -10999,7 +11286,6 @@
"integrity": "sha512-1KocmjmBP0qlKQGRhRGN0MGvLxf1q2KDWbvzn7ZGdQrIDLC/hFJ8YmnOWsPrM9RxiZi0o5BxCCu9D7KlbthxIg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@angular-eslint/bundled-angular-compiler": "21.0.1",
"eslint-scope": "^9.0.0"
@@ -11339,7 +11625,8 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/array-ify": {
"version": "1.0.0",
@@ -11371,6 +11658,16 @@
"node": ">=4"
}
},
+ "node_modules/astral-regex": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -11398,6 +11695,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"browserslist": "^4.23.3",
"caniuse-lite": "^1.0.30001646",
@@ -11458,7 +11756,6 @@
"integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/transform": "^29.7.0",
"@types/babel__core": "^7.1.14",
@@ -11524,6 +11821,7 @@
"integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"find-cache-dir": "^4.0.0",
"schema-utils": "^4.0.0"
@@ -11627,6 +11925,7 @@
"integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/helper-define-polyfill-provider": "^0.6.3",
"core-js-compat": "^3.40.0"
@@ -11763,6 +12062,7 @@
"integrity": "sha512-p4AF8uYzm9Fwu8m/hSVTCPXrRBPmB34hQpHsec2KOaR9CZmgoU8IOv4Cvwq4hgz2p4hLMNbsdNl5XeA6XbAQwA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"css-select": "^5.1.0",
"css-what": "^6.1.0",
@@ -11860,6 +12160,7 @@
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": "*"
}
@@ -11929,6 +12230,7 @@
"integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"multicast-dns": "^7.2.5"
@@ -12013,7 +12315,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -12089,6 +12390,7 @@
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"run-applescript": "^7.0.0"
},
@@ -12231,6 +12533,30 @@
"node": ">=18"
}
},
+ "node_modules/cacheable": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz",
+ "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.0.7",
+ "@cacheable/utils": "^2.3.3",
+ "hookified": "^1.15.0",
+ "keyv": "^5.5.5",
+ "qified": "^0.6.0"
+ }
+ },
+ "node_modules/cacheable/node_modules/keyv": {
+ "version": "5.5.5",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
+ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -12459,7 +12785,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -13005,6 +13330,7 @@
"integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"is-plain-object": "^2.0.4",
"kind-of": "^6.0.2",
@@ -13020,6 +13346,7 @@
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"isobject": "^3.0.1"
},
@@ -13082,6 +13409,13 @@
"color-support": "bin.js"
}
},
+ "node_modules/colord": {
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -13127,7 +13461,8 @@
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
"integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==",
"dev": true,
- "license": "ISC"
+ "license": "ISC",
+ "peer": true
},
"node_modules/commondir": {
"version": "1.0.1",
@@ -13167,6 +13502,7 @@
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
@@ -13180,6 +13516,7 @@
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
@@ -13199,6 +13536,7 @@
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -13208,7 +13546,8 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
@@ -13216,6 +13555,7 @@
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -13239,7 +13579,8 @@
"url": "https://feross.org/support"
}
],
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/concat-map": {
"version": "0.0.1",
@@ -13288,6 +13629,7 @@
"integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.8"
}
@@ -13322,6 +13664,7 @@
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -13348,7 +13691,8 @@
"url": "https://feross.org/support"
}
],
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/content-type": {
"version": "1.0.5",
@@ -13473,6 +13817,7 @@
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -13482,7 +13827,8 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/copy-anything": {
"version": "2.0.6",
@@ -13503,6 +13849,7 @@
"integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.1",
@@ -13563,7 +13910,6 @@
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
@@ -13709,12 +14055,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/css-functions-list": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz",
+ "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12 || >=16"
+ }
+ },
"node_modules/css-loader": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz",
"integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"icss-utils": "^5.1.0",
"postcss": "^8.4.33",
@@ -13762,6 +14119,20 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@@ -13942,7 +14313,8 @@
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/deepmerge": {
"version": "4.3.1",
@@ -13960,6 +14332,7 @@
"integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bundle-name": "^4.1.0",
"default-browser-id": "^5.0.0"
@@ -13977,6 +14350,7 @@
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -14021,6 +14395,7 @@
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -14074,6 +14449,7 @@
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
@@ -14086,6 +14462,7 @@
"dev": true,
"license": "Apache-2.0",
"optional": true,
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -14105,7 +14482,8 @@
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/diff": {
"version": "4.0.2",
@@ -14146,6 +14524,7 @@
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@leichtgewicht/ip-codec": "^2.0.1"
},
@@ -14386,6 +14765,7 @@
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 4"
}
@@ -14758,7 +15138,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -14842,6 +15221,7 @@
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -15010,6 +15390,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -15027,6 +15408,7 @@
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -15043,6 +15425,7 @@
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -15054,6 +15437,7 @@
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -15071,6 +15455,7 @@
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -15084,6 +15469,7 @@
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -15101,6 +15487,7 @@
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 4"
}
@@ -15110,7 +15497,8 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/eslint/node_modules/locate-path": {
"version": "6.0.0",
@@ -15118,6 +15506,7 @@
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -15134,6 +15523,7 @@
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -15147,6 +15537,7 @@
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -15163,6 +15554,7 @@
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -15173,6 +15565,7 @@
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
+ "peer": true,
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
@@ -15191,6 +15584,7 @@
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -15218,6 +15612,7 @@
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -15296,7 +15691,8 @@
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/events": {
"version": "3.3.0",
@@ -15378,6 +15774,7 @@
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -15425,6 +15822,7 @@
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
@@ -15450,6 +15848,7 @@
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -15459,7 +15858,8 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/express/node_modules/encodeurl": {
"version": "2.0.0",
@@ -15467,6 +15867,7 @@
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -15477,6 +15878,7 @@
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -15496,6 +15898,7 @@
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -15506,6 +15909,7 @@
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
@@ -15519,6 +15923,7 @@
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -15529,6 +15934,7 @@
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"dependencies": {
"side-channel": "^1.0.6"
},
@@ -15545,6 +15951,7 @@
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
@@ -15574,7 +15981,8 @@
"url": "https://feross.org/support"
}
],
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/express/node_modules/send": {
"version": "0.19.0",
@@ -15582,6 +15990,7 @@
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
@@ -15607,6 +16016,7 @@
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -15617,6 +16027,7 @@
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -15627,6 +16038,7 @@
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
@@ -15721,7 +16133,8 @@
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/fast-uri": {
"version": "3.1.0",
@@ -15740,6 +16153,16 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fastest-levenshtein": {
+ "version": "1.0.16",
+ "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+ "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.9.1"
+ }
+ },
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -15836,6 +16259,7 @@
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"flat-cache": "^4.0.0"
},
@@ -15911,6 +16335,7 @@
"integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"common-path-prefix": "^3.0.0",
"pkg-dir": "^7.0.0"
@@ -15976,6 +16401,7 @@
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"bin": {
"flat": "cli.js"
}
@@ -15986,6 +16412,7 @@
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
@@ -16013,6 +16440,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=4.0"
},
@@ -16090,7 +16518,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -16324,6 +16751,7 @@
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -16334,6 +16762,7 @@
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": "*"
},
@@ -16667,6 +17096,7 @@
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"is-glob": "^4.0.3"
},
@@ -16680,6 +17110,7 @@
"integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=10.0"
},
@@ -16740,12 +17171,61 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/global-modules": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
+ "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "global-prefix": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/global-prefix": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz",
+ "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^1.3.5",
+ "kind-of": "^6.0.2",
+ "which": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/global-prefix/node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/global-prefix/node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -16759,6 +17239,7 @@
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.3",
@@ -16780,6 +17261,7 @@
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -16793,6 +17275,7 @@
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -16800,6 +17283,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/globjoin": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
+ "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -16832,7 +17322,8 @@
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
"integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/handlebars": {
"version": "4.7.8",
@@ -16925,6 +17416,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/hashery": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz",
+ "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.14.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -16971,6 +17475,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/hookified": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.0.tgz",
+ "integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/hosted-git-info": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz",
@@ -16997,6 +17508,7 @@
"integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"inherits": "^2.0.1",
"obuf": "^1.0.0",
@@ -17010,6 +17522,7 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -17026,6 +17539,7 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -17125,13 +17639,25 @@
"node": ">= 12"
}
},
+ "node_modules/html-tags": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz",
+ "integrity": "sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/html-webpack-plugin": {
"version": "5.6.5",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.5.tgz",
"integrity": "sha512-4xynFbKNNk+WlzXeQQ+6YYsH2g7mpfPszQZUi3ovKlj+pDmngQ7vRXjrrmGROabmKwyQkcgcX5hqfOwHbFmK5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/html-minifier-terser": "^6.0.0",
"html-minifier-terser": "^6.0.2",
@@ -17240,7 +17766,8 @@
"resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
"integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/http-errors": {
"version": "2.0.0",
@@ -17282,6 +17809,7 @@
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
@@ -17325,6 +17853,7 @@
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/http-proxy": "^1.17.15",
"debug": "^4.3.6",
@@ -17383,6 +17912,7 @@
"integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10.18"
}
@@ -17793,6 +18323,7 @@
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -17869,6 +18400,7 @@
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"is-docker": "cli.js"
},
@@ -17951,6 +18483,7 @@
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"is-docker": "^3.0.0"
},
@@ -17980,6 +18513,7 @@
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16"
},
@@ -18007,6 +18541,19 @@
"node": ">=8"
}
},
+ "node_modules/is-path-inside": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz",
+ "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -18124,6 +18671,7 @@
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"is-inside-container": "^1.0.0"
},
@@ -18154,6 +18702,7 @@
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -18288,7 +18837,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -19557,7 +20105,6 @@
"integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/types": "^29.6.3",
"@types/node": "*",
@@ -19833,7 +20380,6 @@
"integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"abab": "^2.0.6",
"acorn": "^8.8.1",
@@ -19955,7 +20501,8 @@
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/json-parse-better-errors": {
"version": "1.0.2",
@@ -19986,7 +20533,8 @@
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/json5": {
"version": "2.2.3",
@@ -20054,6 +20602,7 @@
"integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"source-map-support": "^0.5.5"
}
@@ -20072,6 +20621,7 @@
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"json-buffer": "3.0.1"
}
@@ -20096,12 +20646,20 @@
"node": ">=6"
}
},
+ "node_modules/known-css-properties": {
+ "version": "0.37.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz",
+ "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/launch-editor": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz",
"integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"picocolors": "^1.1.1",
"shell-quote": "^1.8.3"
@@ -20113,7 +20671,6 @@
"integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@@ -20141,6 +20698,7 @@
"integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 18.12.0"
},
@@ -20226,6 +20784,7 @@
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
@@ -20240,6 +20799,7 @@
"integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"webpack-sources": "^3.0.0"
},
@@ -20521,6 +21081,7 @@
"hasInstallScript": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"msgpackr": "^1.11.2",
"node-addon-api": "^6.1.0",
@@ -20600,6 +21161,7 @@
"integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 12.13.0"
}
@@ -20718,6 +21280,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.truncate": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+ "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -21109,7 +21678,6 @@
"integrity": "sha512-ev2uM40p0zQ/GbvqotfKcSWEa59fJwluGZj5dcaUOwDRrB1F3dncdXy8NWUApk4fi8atU3kTBOwjyjZ0ud0dxw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -21191,6 +21759,24 @@
"node": ">= 0.4"
}
},
+ "node_modules/mathml-tag-names": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz",
+ "integrity": "sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -21243,6 +21829,7 @@
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
@@ -21270,6 +21857,7 @@
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -21376,6 +21964,7 @@
"integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"schema-utils": "^4.0.0",
"tapable": "^2.2.1"
@@ -21396,7 +21985,8 @@
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"dev": true,
- "license": "ISC"
+ "license": "ISC",
+ "peer": true
},
"node_modules/minimatch": {
"version": "9.0.5",
@@ -21661,6 +22251,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
@@ -21673,6 +22264,7 @@
"hasInstallScript": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
@@ -21694,6 +22286,7 @@
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"dns-packet": "^5.2.2",
"thunky": "^1.0.2"
@@ -22059,7 +22652,8 @@
"integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
"dev": true,
"license": "MIT",
- "optional": true
+ "optional": true,
+ "peer": true
},
"node_modules/node-emoji": {
"version": "2.2.0",
@@ -22083,6 +22677,7 @@
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
+ "peer": true,
"engines": {
"node": ">= 6.13.0"
}
@@ -22119,6 +22714,7 @@
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
@@ -22272,6 +22868,7 @@
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -24835,7 +25432,6 @@
"dev": true,
"inBundle": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -25135,7 +25731,8 @@
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/on-finished": {
"version": "2.4.1",
@@ -25192,6 +25789,7 @@
"integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"default-browser": "^5.2.1",
"define-lazy-prop": "^3.0.0",
@@ -25221,6 +25819,7 @@
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
@@ -25343,7 +25942,8 @@
"integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==",
"dev": true,
"license": "MIT",
- "optional": true
+ "optional": true,
+ "peer": true
},
"node_modules/os-name": {
"version": "4.0.1",
@@ -25507,6 +26107,7 @@
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/retry": "0.12.2",
"is-network-error": "^1.0.0",
@@ -25525,6 +26126,7 @@
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 4"
}
@@ -25683,6 +26285,7 @@
"integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"entities": "^4.3.0",
"parse5": "^7.0.0",
@@ -25725,6 +26328,7 @@
"integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"parse5": "^7.0.0"
},
@@ -25842,7 +26446,8 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/path-type": {
"version": "4.0.0",
@@ -26033,6 +26638,7 @@
"integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"find-up": "^6.3.0"
},
@@ -26049,6 +26655,7 @@
"integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"locate-path": "^7.1.0",
"path-exists": "^5.0.0"
@@ -26114,7 +26721,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -26130,6 +26736,7 @@
"integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cosmiconfig": "^9.0.0",
"jiti": "^1.20.0",
@@ -26162,6 +26769,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -26236,10 +26844,71 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/postcss-resolve-nested-selector": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
+ "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/postcss-safe-parser": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz",
+ "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.31"
+ }
+ },
+ "node_modules/postcss-scss": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz",
+ "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss-scss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.29"
+ }
+ },
"node_modules/postcss-selector-parser": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
- "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
+ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -26250,6 +26919,17 @@
"node": ">=4"
}
},
+ "node_modules/postcss-sorting": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-9.1.0.tgz",
+ "integrity": "sha512-Mn8KJ45HNNG6JBpBizXcyf6LqY/qyqetGcou/nprDnFwBFBLGj0j/sNKV2lj2KMOVOwdXu14aEzqJv8CIV6e8g==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "postcss": "^8.4.20"
+ }
+ },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@@ -26263,6 +26943,7 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8.0"
}
@@ -26273,7 +26954,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -26417,6 +27097,7 @@
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -26431,6 +27112,7 @@
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.10"
}
@@ -26504,6 +27186,19 @@
],
"license": "MIT"
},
+ "node_modules/qified": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
+ "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.14.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -26623,7 +27318,6 @@
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -26634,7 +27328,6 @@
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -26840,7 +27533,8 @@
"resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz",
"integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/regexpu-core": {
"version": "6.4.0",
@@ -27179,6 +27873,7 @@
"integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"adjust-sourcemap-loader": "^4.0.0",
"convert-source-map": "^1.7.0",
@@ -27196,6 +27891,7 @@
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
@@ -27211,6 +27907,7 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -27292,7 +27989,6 @@
"integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.6"
},
@@ -27339,6 +28035,7 @@
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -27376,7 +28073,6 @@
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -27440,6 +28136,7 @@
"integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"neo-async": "^2.6.2"
},
@@ -27546,7 +28243,8 @@
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
"integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/selfsigned": {
"version": "2.4.1",
@@ -27554,6 +28252,7 @@
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/node-forge": "^1.3.0",
"node-forge": "^1"
@@ -27568,7 +28267,6 @@
"integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@semantic-release/commit-analyzer": "^13.0.0-beta.1",
"@semantic-release/error": "^4.0.0",
@@ -28039,6 +28737,7 @@
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
@@ -28055,6 +28754,7 @@
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -28064,7 +28764,8 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/serve-static/node_modules/encodeurl": {
"version": "2.0.0",
@@ -28072,6 +28773,7 @@
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -28082,6 +28784,7 @@
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -28092,6 +28795,7 @@
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
@@ -28117,6 +28821,7 @@
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -28127,6 +28832,7 @@
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -28162,6 +28868,7 @@
"integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"kind-of": "^6.0.2"
},
@@ -28198,6 +28905,7 @@
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -28520,6 +29228,7 @@
"integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"faye-websocket": "^0.11.3",
"uuid": "^8.3.2",
@@ -28532,6 +29241,7 @@
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -28592,6 +29302,7 @@
"integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"iconv-lite": "^0.6.3",
"source-map-js": "^1.0.2"
@@ -28613,6 +29324,7 @@
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -28690,6 +29402,7 @@
"integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.1.0",
"handle-thing": "^2.0.0",
@@ -28707,6 +29420,7 @@
"integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "^4.1.0",
"detect-node": "^2.0.4",
@@ -28811,7 +29525,6 @@
"integrity": "sha512-sVKbCj/OTx67jhmauhxc2dcr1P+yOgz/x3h0krwjyMgdc5Oubvxyg4NYDZmzAw+ym36g/lzH8N0Ccp4dwtdfxw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@storybook/core": "8.6.14"
},
@@ -29145,35 +29858,495 @@
"webpack": "^5.0.0"
}
},
- "node_modules/super-regex": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz",
- "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==",
+ "node_modules/stylelint": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.0.0.tgz",
+ "integrity": "sha512-saMZ2mqdQre4AfouxcbTdpVglDRcROb4MIucKHvgsDb/0IX7ODhcaz+EOIyfxAsm8Zjl/7j4hJj6MgIYYM8Xwg==",
"dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/stylelint"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/stylelint"
+ }
+ ],
"license": "MIT",
"dependencies": {
- "function-timeout": "^1.0.1",
- "make-asynchronous": "^1.0.1",
- "time-span": "^5.1.0"
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.25",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "@csstools/media-query-list-parser": "^5.0.0",
+ "@csstools/selector-resolve-nested": "^4.0.0",
+ "@csstools/selector-specificity": "^6.0.0",
+ "balanced-match": "^3.0.1",
+ "colord": "^2.9.3",
+ "cosmiconfig": "^9.0.0",
+ "css-functions-list": "^3.2.3",
+ "css-tree": "^3.1.0",
+ "debug": "^4.4.3",
+ "fast-glob": "^3.3.3",
+ "fastest-levenshtein": "^1.0.16",
+ "file-entry-cache": "^11.1.1",
+ "global-modules": "^2.0.0",
+ "globby": "^16.1.0",
+ "globjoin": "^0.1.4",
+ "html-tags": "^5.1.0",
+ "ignore": "^7.0.5",
+ "import-meta-resolve": "^4.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-plain-object": "^5.0.0",
+ "known-css-properties": "^0.37.0",
+ "mathml-tag-names": "^4.0.0",
+ "meow": "^14.0.0",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.5.6",
+ "postcss-safe-parser": "^7.0.1",
+ "postcss-selector-parser": "^7.1.1",
+ "postcss-value-parser": "^4.2.0",
+ "string-width": "^8.1.0",
+ "supports-hyperlinks": "^4.4.0",
+ "svg-tags": "^1.0.0",
+ "table": "^6.9.0",
+ "write-file-atomic": "^7.0.0"
},
- "engines": {
- "node": ">=18"
+ "bin": {
+ "stylelint": "bin/stylelint.mjs"
},
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "engines": {
+ "node": ">=20.19.0"
}
},
- "node_modules/supports-color": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
- "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "node_modules/stylelint-config-prettier-scss": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-prettier-scss/-/stylelint-config-prettier-scss-1.0.0.tgz",
+ "integrity": "sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "has-flag": "^4.0.0"
+ "bin": {
+ "stylelint-config-prettier-scss": "bin/check.js",
+ "stylelint-config-prettier-scss-check": "bin/check.js"
},
"engines": {
- "node": ">=8"
+ "node": "14.* || 16.* || >= 18"
+ },
+ "peerDependencies": {
+ "stylelint": ">=15.0.0"
+ }
+ },
+ "node_modules/stylelint-config-recess-order": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-recess-order/-/stylelint-config-recess-order-7.4.0.tgz",
+ "integrity": "sha512-W3G517cBaMDYRX5Fzhro4fhRkkLafLgVSPfQnhxbiLyMnbLq47RMF/NRaOJ4OQsKWYhsIHOIc2Q3VL0X3Q7oPg==",
+ "dev": true,
+ "license": "ISC",
+ "peerDependencies": {
+ "stylelint": ">=16.18",
+ "stylelint-order": ">=7"
+ }
+ },
+ "node_modules/stylelint-config-recommended": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-18.0.0.tgz",
+ "integrity": "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/stylelint"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/stylelint"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "stylelint": "^17.0.0"
+ }
+ },
+ "node_modules/stylelint-config-recommended-scss": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-17.0.0.tgz",
+ "integrity": "sha512-VkVD9r7jfUT/dq3mA3/I1WXXk2U71rO5wvU2yIil9PW5o1g3UM7Xc82vHmuVJHV7Y8ok5K137fmW5u3HbhtTOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-scss": "^4.0.9",
+ "stylelint-config-recommended": "^18.0.0",
+ "stylelint-scss": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "postcss": "^8.3.3",
+ "stylelint": "^17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/stylelint-config-standard": {
+ "version": "40.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-40.0.0.tgz",
+ "integrity": "sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/stylelint"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/stylelint"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "stylelint-config-recommended": "^18.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "stylelint": "^17.0.0"
+ }
+ },
+ "node_modules/stylelint-config-standard-scss": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-17.0.0.tgz",
+ "integrity": "sha512-uLJS6xgOCBw5EMsDW7Ukji8l28qRoMnkRch15s0qwZpskXvWt9oPzMmcYM307m9GN4MxuWLsQh4I6hU9yI53cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "stylelint-config-recommended-scss": "^17.0.0",
+ "stylelint-config-standard": "^40.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "postcss": "^8.3.3",
+ "stylelint": "^17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/stylelint-order": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-7.0.1.tgz",
+ "integrity": "sha512-GWPei1zBVDDjxM+/BmcSCiOcHNd8rSqW6FUZtqQGlTRpD0Z5nSzspzWD8rtKif5KPdzUG68DApKEV/y/I9VbTw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "postcss": "^8.5.6",
+ "postcss-sorting": "^9.1.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "stylelint": "^16.18.0 || ^17.0.0"
+ }
+ },
+ "node_modules/stylelint-order/node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/stylelint-scss": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.0.0.tgz",
+ "integrity": "sha512-H88kCC+6Vtzj76NsC8rv6x/LW8slBzIbyeSjsKVlS+4qaEJoDrcJR4L+8JdrR2ORdTscrBzYWiiT2jq6leYR1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.1",
+ "is-plain-object": "^5.0.0",
+ "known-css-properties": "^0.37.0",
+ "mdn-data": "^2.25.0",
+ "postcss-media-query-parser": "^0.2.3",
+ "postcss-resolve-nested-selector": "^0.1.6",
+ "postcss-selector-parser": "^7.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "stylelint": "^16.8.2 || ^17.0.0"
+ }
+ },
+ "node_modules/stylelint-scss/node_modules/mdn-data": {
+ "version": "2.26.0",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.26.0.tgz",
+ "integrity": "sha512-ZqI0qjKWHMPcGUfLmlr80NPNVHIOjPMHtIOe1qXYFGS0YBZ1YKAzo9yk8W+gGrLCN0Xdv/RKxqdIsqPakEfmow==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/stylelint/node_modules/@sindresorhus/merge-streams": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+ "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stylelint/node_modules/balanced-match": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-3.0.1.tgz",
+ "integrity": "sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/stylelint/node_modules/file-entry-cache": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz",
+ "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^6.1.20"
+ }
+ },
+ "node_modules/stylelint/node_modules/flat-cache": {
+ "version": "6.1.20",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz",
+ "integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.3.2",
+ "flatted": "^3.3.3",
+ "hookified": "^1.15.0"
+ }
+ },
+ "node_modules/stylelint/node_modules/globby": {
+ "version": "16.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-16.1.0.tgz",
+ "integrity": "sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/merge-streams": "^4.0.0",
+ "fast-glob": "^3.3.3",
+ "ignore": "^7.0.5",
+ "is-path-inside": "^4.0.0",
+ "slash": "^5.1.0",
+ "unicorn-magic": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stylelint/node_modules/has-flag": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz",
+ "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stylelint/node_modules/meow": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-14.0.0.tgz",
+ "integrity": "sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stylelint/node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/stylelint/node_modules/string-width": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
+ "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stylelint/node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/stylelint/node_modules/supports-hyperlinks": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz",
+ "integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^5.0.1",
+ "supports-color": "^10.2.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
+ }
+ },
+ "node_modules/stylelint/node_modules/unicorn-magic": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz",
+ "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/stylelint/node_modules/write-file-atomic": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz",
+ "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/super-regex": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz",
+ "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-timeout": "^1.0.1",
+ "make-asynchronous": "^1.0.1",
+ "time-span": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/supports-hyperlinks": {
@@ -29213,6 +30386,12 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/svg-tags": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
+ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==",
+ "dev": true
+ },
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@@ -29246,6 +30425,102 @@
"url": "https://opencollective.com/synckit"
}
},
+ "node_modules/table": {
+ "version": "6.9.0",
+ "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz",
+ "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "ajv": "^8.0.1",
+ "lodash.truncate": "^4.4.2",
+ "slice-ansi": "^4.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/table/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/table/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/table/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/table/node_modules/slice-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+ "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/table/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/table/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/tablesort": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/tablesort/-/tablesort-5.6.0.tgz",
@@ -29430,7 +30705,6 @@
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -29620,6 +30894,7 @@
"integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10.18"
},
@@ -29680,7 +30955,8 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/time-span": {
"version": "5.1.0",
@@ -29863,6 +31139,7 @@
"integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"engines": {
"node": ">=10.0"
},
@@ -29880,6 +31157,7 @@
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"tree-kill": "cli.js"
}
@@ -30016,7 +31294,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -30134,8 +31411,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
- "license": "0BSD",
- "peer": true
+ "license": "0BSD"
},
"node_modules/tuf-js": {
"version": "3.1.0",
@@ -30158,6 +31434,7 @@
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"prelude-ls": "^1.2.1"
},
@@ -30231,7 +31508,8 @@
"resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz",
"integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/typescript": {
"version": "5.8.3",
@@ -30239,7 +31517,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -30592,7 +31869,6 @@
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
- "peer": true,
"bin": {
"uuid": "dist/esm/bin/uuid"
}
@@ -30717,6 +31993,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -30798,7 +32075,8 @@
"optional": true,
"os": [
"android"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.2",
@@ -30812,7 +32090,8 @@
"optional": true,
"os": [
"android"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.2",
@@ -30826,7 +32105,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.2",
@@ -30840,7 +32120,8 @@
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.2",
@@ -30854,7 +32135,8 @@
"optional": true,
"os": [
"freebsd"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.2",
@@ -30868,7 +32150,8 @@
"optional": true,
"os": [
"freebsd"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.2",
@@ -30882,7 +32165,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.2",
@@ -30896,7 +32180,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.2",
@@ -30910,7 +32195,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.2",
@@ -30924,7 +32210,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.2",
@@ -30938,7 +32225,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.2",
@@ -30952,7 +32240,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.2",
@@ -30966,7 +32255,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.2",
@@ -30980,7 +32270,8 @@
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.2",
@@ -30994,7 +32285,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.2",
@@ -31008,7 +32300,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.2",
@@ -31022,7 +32315,8 @@
"optional": true,
"os": [
"win32"
- ]
+ ],
+ "peer": true
},
"node_modules/vite/node_modules/postcss": {
"version": "8.5.6",
@@ -31044,6 +32338,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -31059,6 +32354,7 @@
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -31145,6 +32441,7 @@
"integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"minimalistic-assert": "^1.0.0"
}
@@ -31165,7 +32462,8 @@
"integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==",
"dev": true,
"license": "MIT",
- "optional": true
+ "optional": true,
+ "peer": true
},
"node_modules/web-worker": {
"version": "1.2.0",
@@ -31190,7 +32488,6 @@
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -31238,6 +32535,7 @@
"integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"colorette": "^2.0.10",
"memfs": "^4.6.0",
@@ -31268,6 +32566,7 @@
"integrity": "sha512-4zngfkVM/GpIhC8YazOsM6E8hoB33NP0BCESPOA6z7qaL6umPJNqkO8CNYaLV2FB2MV6H1O3x2luHHOSqppv+A==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@jsonjoy.com/json-pack": "^1.11.0",
"@jsonjoy.com/util": "^1.9.0",
@@ -31346,6 +32645,7 @@
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -31371,6 +32671,7 @@
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -31384,6 +32685,7 @@
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
@@ -31409,6 +32711,7 @@
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -31422,6 +32725,7 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8.6"
},
@@ -31435,6 +32739,7 @@
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@@ -31473,6 +32778,7 @@
"integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"clone-deep": "^4.0.1",
"flat": "^5.0.2",
@@ -31498,6 +32804,7 @@
"integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"typed-assert": "^1.0.8"
},
@@ -31670,7 +32977,8 @@
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/windows-release": {
"version": "4.0.0",
@@ -31751,6 +33059,7 @@
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
diff --git a/package.json b/package.json
index 230267a75..bdd0e1607 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"start": "ng run angular-components:storybook",
"build": "ng build angular-components --configuration production && sass --load-path=node_modules src/styles/index.scss dist/index.css --style=compressed --no-source-map && replace-in-file //fonts//g \"./fonts/\" dist/index.css --isRegex && mkdir -p dist/fonts && cp -a node_modules/@tedi-design-system/core/fonts/* dist/fonts/",
"build:sb": "ng run angular-components:build-storybook && replace-in-file //fonts//g \"./fonts/\" dist/storybook-static/main.css --isRegex",
- "lint": "ng lint angular-components --fix",
+ "lint": "stylelint \"tedi/**/*.scss\" --fix && ng lint angular-components --fix",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch",
"test:coverage": "jest --config ./jest.config.ts --coverage",
@@ -32,7 +32,7 @@
"ngx-float-ui": "^19.0.1 || ^20.0.0"
},
"dependencies": {
- "@tedi-design-system/core": "^3.0.0"
+ "@tedi-design-system/core": "^3.0.1"
},
"devDependencies": {
"@angular-devkit/core": "19.2.15",
@@ -45,8 +45,8 @@
"@angular/animations": "19.2.15",
"@angular/cdk": "19.2.15",
"@angular/cli": "19.2.15",
- "@angular/compiler-cli": "19.2.15",
"@angular/common": "19.2.15",
+ "@angular/compiler-cli": "19.2.15",
"@angular/core": "19.2.15",
"@angular/forms": "19.2.15",
"@angular/language-service": "19.2.15",
@@ -93,6 +93,10 @@
"storybook": "^8.4.7",
"storybook-addon-angular-router": "^1.10.1",
"storybook-addon-pseudo-states": "^4.0.3",
+ "stylelint": "^17.0.0",
+ "stylelint-config-prettier-scss": "^1.0.0",
+ "stylelint-config-recess-order": "^7.4.0",
+ "stylelint-config-standard-scss": "^17.0.0",
"ts-node": "^10.9.2",
"tslib": "^2.5.0",
"typescript": "5.8"
@@ -117,8 +121,8 @@
}
},
"lint-staged": {
- "src/**/*.{css,scss}": [
- "prettier --write"
+ "{src,tedi}/**/*.{css,scss}": [
+ "stylelint --fix"
],
"src/**/*.{ts,html}": [
"prettier --write"
diff --git a/src/docs/badges.mdx b/src/docs/badges.mdx
index 4a8bbdb25..dfcf32caa 100644
--- a/src/docs/badges.mdx
+++ b/src/docs/badges.mdx
@@ -1,4 +1,4 @@
-import { Meta } from '@storybook/blocks';
+import { Meta } from "@storybook/blocks";
@@ -6,60 +6,141 @@ import { Meta } from '@storybook/blocks';
In our Storybook, we use status badges to indicate key attributes of components at a glance. These badges provide information about a component's development status, feature support, and other relevant characteristics.
-
-
- Dev Component
-
-
This badge indicates that the component is intended for developers only and is not available in Figma.
-
- Breakpoint support
-
-
This badge signifies that the component supports breakpoints, allowing props to be overridden at different.
-
- Internal Component
-
-
This badge indicates that the component is used internally within TEDI and is not exported for public use.
-
- Deprecated
-
-
This badge indicates that the component is being phased out and will be removed in a future release.
-
- Exists in TEDI Ready
-
-
This badge means that an equivalent version of the component is available in the TEDI-Ready component set.
-
- Partially TEDI-Ready
-
-
This badge indicates that the component lacks some TEDI-Ready functionality. For example, it may rely on another component that has not yet been developed.
+
+
+ {"Dev Component"}
+
+
+ This badge indicates that the component is intended for developers only and
+ is not available in Figma.
+
+
+ {"Breakpoint support"}
+
+
+ This badge signifies that the component supports breakpoints, allowing props
+ to be overridden at different.
+
+
+ {"Internal Component"}
+
+
+ This badge indicates that the component is used internally within TEDI and
+ is not exported for public use.
+
+
+ {"Deprecated"}
+
+
+ This badge indicates that the component is being phased out and will be
+ removed in a future release.
+
+
+ {"Exists in TEDI Ready"}
+
+
+ This badge means that an equivalent version of the component is available in
+ the TEDI-Ready component set.
+
+
+ {"Partially TEDI-Ready"}
+
+
+ This badge indicates that the component lacks some TEDI-Ready functionality.
+ For example, it may rely on another component that has not yet been
+ developed.
+
+
+ {"Mobile view difference"}
+
+
+ This component has a different layout on mobile. Use the mobile breakpoint
+ or resize the browser window to review the mobile design.
+
diff --git a/src/styles/cdk.scss b/src/styles/cdk.scss
new file mode 100644
index 000000000..bf02054ca
--- /dev/null
+++ b/src/styles/cdk.scss
@@ -0,0 +1,3 @@
+@use "@angular/cdk" as cdk;
+
+@include cdk.a11y-visually-hidden;
diff --git a/src/styles/index.scss b/src/styles/index.scss
index 0424a3d60..c6104eee7 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -1,2 +1,3 @@
-@use "@tedi-design-system/core/index.scss";
-@use "vertical-spacing.scss";
+@use "@tedi-design-system/core/index";
+@use "vertical-spacing";
+@use "cdk";
diff --git a/tedi/components/base/text/text.stories.ts b/tedi/components/base/text/text.stories.ts
index a724148ee..3112f2f94 100644
--- a/tedi/components/base/text/text.stories.ts
+++ b/tedi/components/base/text/text.stories.ts
@@ -27,6 +27,11 @@ export default {
],
}),
],
+ parameters: {
+ status: {
+ type: ["mobileViewDifference"],
+ },
+ },
argTypes: {
ngContent: {
name: "ng-content",
@@ -124,58 +129,25 @@ export const Default: StoryObj
= {
export const Headings: StoryObj = {
render: (args) => ({
props: args,
- styles: [
- `
- h1.mobile {
- font-size: var(--heading-h1-size-mobile);
- }
- h2.mobile {
- font-size: var(--heading-h2-size-mobile);
- }
- h3.mobile {
- font-size: var(--heading-h3-size-mobile);
- }
- h4.mobile {
- font-size: var(--heading-h4-size-mobile);
- }
- h5.mobile {
- font-size: var(--heading-h5-size-mobile);
- }
- h6.mobile {
- font-size: var(--heading-h6-size-mobile);
- }
- }
- `,
- ],
template: `
-
- Desktop
- Mobile
-
Heading H1
- Heading H1
Heading H2
- Heading H2
Heading H3
- Heading H3
Heading H4
- Heading H4
Heading H5
- Heading H5
-
+
Heading H6
- Heading H6
`,
@@ -187,25 +159,17 @@ export const Subtitles: StoryObj = {
props: args,
template: `
-
- Desktop
- Mobile
-
Subtitle
- Subtitle
Subtitle small
- Subtitle small
Label
- Label
-
+
Label bold
- Label bold
`,
diff --git a/tedi/components/buttons/button/button.component.scss b/tedi/components/buttons/button/button.component.scss
index afcc3eecc..b68f5f977 100644
--- a/tedi/components/buttons/button/button.component.scss
+++ b/tedi/components/buttons/button/button.component.scss
@@ -80,6 +80,7 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral";
--_btn-padding-y: var(--button-sm-neutral-padding-y);
--_btn-padding-x: var(--button-sm-neutral-padding-x);
}
+
&:where(.tedi-button--default) {
--_btn-padding-y: var(--button-md-neutral-padding-y);
--_btn-padding-x: var(--button-md-neutral-padding-x);
@@ -89,21 +90,21 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral";
@mixin button-main-styles {
& {
display: inline-flex;
+ gap: var(--_btn-inner-spacing);
align-items: center;
justify-content: center;
- gap: var(--_btn-inner-spacing);
- text-decoration: none;
- background: var(--_btn-bg);
- color: var(--_btn-text);
- border: var(--borders-01) solid var(--_btn-border);
padding: var(--_btn-padding);
- transition: 150ms ease;
- transition-property: background-color, border-color;
- cursor: pointer;
font-family: var(--family-default);
font-weight: var(--body-regular-weight);
line-height: var(--body-bold-line-height);
+ color: var(--_btn-text);
+ text-decoration: none;
+ cursor: pointer;
+ background: var(--_btn-bg);
+ border: var(--borders-01) solid var(--_btn-border);
border-radius: var(--button-radius-default);
+ transition: 150ms ease;
+ transition-property: background-color, border-color;
&--icon-only {
width: var(--button-md-icon-size);
@@ -112,9 +113,11 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral";
}
&--pl {
- &:not(.tedi-button--neutral):not(.tedi-button--danger-neutral):not(
- .tedi-button--neutral-inverted
- ) {
+ &:not(
+ .tedi-button--neutral,
+ .tedi-button--danger-neutral,
+ .tedi-button--neutral-inverted
+ ) {
padding-left: calc(
var(--_btn-padding-x) + var(--_btn-inner-spacing) - 1px
);
@@ -122,9 +125,11 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral";
}
&--pr {
- &:not(.tedi-button--neutral):not(.tedi-button--danger-neutral):not(
- .tedi-button--neutral-inverted
- ) {
+ &:not(
+ .tedi-button--neutral,
+ .tedi-button--danger-neutral,
+ .tedi-button--neutral-inverted
+ ) {
padding-right: calc(
var(--_btn-padding-x) + var(--_btn-inner-spacing) - 1px
);
@@ -132,35 +137,35 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral";
}
&:hover {
+ color: var(--_btn-hover-text);
background: var(--_btn-hover-bg);
border-color: var(--_btn-hover-border);
- color: var(--_btn-hover-text);
}
&:active {
+ color: var(--_btn-active-text);
background: var(--_btn-active-bg);
border-color: var(--_btn-active-border);
- color: var(--_btn-active-text);
}
&:focus-visible {
- background: var(--_btn-focus-bg);
- border-color: var(--_btn-focus-border);
color: var(--_btn-focus-text);
outline: 2px solid var(--_btn-outline);
outline-offset: 1px;
+ background: var(--_btn-focus-bg);
+ border-color: var(--_btn-focus-border);
}
&:disabled {
- background: var(--_btn-disabled-bg);
- border-color: var(--_btn-disabled-border);
color: var(--_btn-disabled-text);
cursor: not-allowed;
+ background: var(--_btn-disabled-bg);
+ border-color: var(--_btn-disabled-border);
}
tedi-icon {
- color: inherit;
font-size: inherit;
+ color: inherit;
}
}
}
@@ -168,12 +173,13 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral";
@mixin button-size($size) {
& {
font-size: var(--button-text-size-#{$size});
+
@include button-size-vars(#{$size});
}
}
.tedi-button {
- @include button-main-styles();
+ @include button-main-styles;
&--small {
@include button-size("sm");
@@ -222,6 +228,13 @@ $neutral-variants: "neutral", "neutral-inverted", "danger-neutral";
@include neutral-button-padding-overrides;
}
+ &[class*="-inverted"]:focus-visible {
+ outline: none;
+ box-shadow:
+ 0 0 0 1px var(--tedi-neutral-100),
+ 0 0 0 3px var(--_btn-outline);
+ }
+
&--icon-only {
&.tedi-button--neutral {
@include button-variant-color-vars("neutral", true);
diff --git a/tedi/components/buttons/closing-button/closing-button.component.scss b/tedi/components/buttons/closing-button/closing-button.component.scss
index 593b4987f..1e92fd5da 100644
--- a/tedi/components/buttons/closing-button/closing-button.component.scss
+++ b/tedi/components/buttons/closing-button/closing-button.component.scss
@@ -1,18 +1,18 @@
.tedi-closing-button {
--general-icon-primary: var(--button-close-text-default);
- flex-shrink: 0;
display: flex;
+ flex-shrink: 0;
align-items: center;
justify-content: center;
+ width: var(--button-sm-icon-size);
+ height: var(--button-sm-icon-size);
padding: 0;
cursor: pointer;
background-color: var(--button-close-background-default);
border: 1px solid var(--button-close-background-default);
- transition: background 0.5s ease;
border-radius: var(--button-radius-default);
- width: var(--button-sm-icon-size);
- height: var(--button-sm-icon-size);
+ transition: background 0.5s ease;
@each $state in hover, active {
&:#{ $state } {
diff --git a/tedi/components/buttons/collapse/collapse.component.html b/tedi/components/buttons/collapse/collapse.component.html
index 238b6128e..fe310cba4 100644
--- a/tedi/components/buttons/collapse/collapse.component.html
+++ b/tedi/components/buttons/collapse/collapse.component.html
@@ -1,12 +1,12 @@
-
+
@if (hideCollapseText() === false) {
-
+
{{
isOpen()
? (closeText() ?? ("close" | tediTranslate))
@@ -15,15 +15,15 @@
}
@if (arrowType() === "default") {
-
+
} @else if (arrowType() === "secondary") {
-
-
+
+
}
-
-
+
diff --git a/tedi/components/buttons/collapse/collapse.component.scss b/tedi/components/buttons/collapse/collapse.component.scss
index e485868b4..42e71cfc3 100644
--- a/tedi/components/buttons/collapse/collapse.component.scss
+++ b/tedi/components/buttons/collapse/collapse.component.scss
@@ -6,63 +6,64 @@ $transition-duration: 300ms;
display: block;
}
-.collapse__button {
- display: flex;
- align-items: center;
- cursor: pointer;
- gap: var(--link-inner-spacing-x);
+.tedi-collapse {
+ &__button {
+ display: flex;
+ gap: var(--link-inner-spacing-x);
+ align-items: center;
+ cursor: pointer;
- @include mixins.button-reset;
+ @include mixins.button-reset;
- &--text {
- text-decoration: underline;
- }
+ &--text {
+ text-decoration: underline;
+ }
- &:focus-visible {
- outline: 2px solid var(--button-main-neutral-text-default);
- outline-offset: var(--global-outline-offset);
+ &:focus-visible {
+ outline: 2px solid var(--button-main-neutral-text-default);
+ outline-offset: var(--global-outline-offset);
+ }
}
-}
-.collapse__icon--wrapper {
- display: flex;
- align-items: center;
- justify-content: center;
- border: var(--borders-01) solid var(--button-main-secondary-border-default);
- border-radius: 100%;
- width: var(--button-sm-icon-size);
- height: var(--button-sm-icon-size);
-}
+ &__icon {
+ color: var(--button-main-neutral-text-default);
+ transition: transform $transition-duration ease;
-.collapse__icon {
- transition: transform $transition-duration ease;
- color: var(--button-main-neutral-text-default);
+ &--wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--button-sm-icon-size);
+ height: var(--button-sm-icon-size);
+ border: var(--borders-01) solid
+ var(--button-main-secondary-border-default);
+ border-radius: 100%;
+ }
- .collapse--open > .collapse__button & {
- transform: rotate(180deg);
+ .tedi-collapse--open > .tedi-collapse__button & {
+ transform: rotate(180deg);
+ }
}
-}
-.collapse__content {
- margin-top: var(--dimensions-05);
- display: grid;
- grid-template-rows: 0fr;
- overflow: hidden;
- transition: grid-template-rows $transition-duration;
-}
+ &__extender {
+ visibility: hidden;
+ min-height: 0;
+ transition: visibility $transition-duration;
+ }
-.collapse__extender {
- min-height: 0;
- transition: visibility $transition-duration;
- visibility: hidden;
-}
+ &__content {
+ display: grid;
+ grid-template-rows: 0fr;
+ margin-top: var(--dimensions-05);
+ overflow: hidden;
+ transition: grid-template-rows $transition-duration;
-.collapse__content {
- .collapse--open > & {
- grid-template-rows: 1fr;
+ .tedi-collapse--open > & {
+ grid-template-rows: 1fr;
- .collapse__extender {
- visibility: visible;
+ .tedi-collapse__extender {
+ visibility: visible;
+ }
}
}
}
diff --git a/tedi/components/buttons/collapse/collapse.component.spec.ts b/tedi/components/buttons/collapse/collapse.component.spec.ts
index 3a9c7c08f..f74882227 100644
--- a/tedi/components/buttons/collapse/collapse.component.spec.ts
+++ b/tedi/components/buttons/collapse/collapse.component.spec.ts
@@ -60,7 +60,7 @@ describe("CollapseComponent", () => {
component.isOpen.set(false);
const buttonText = fixture.nativeElement
- .querySelector(".collapse__button--text")
+ .querySelector(".tedi-collapse__button--text")
.textContent.trim();
fixture.whenStable().then(() => {
@@ -74,7 +74,7 @@ describe("CollapseComponent", () => {
component.isOpen.set(true);
const buttonText = fixture.nativeElement
- .querySelector(".collapse__button--text")
+ .querySelector(".tedi-collapse__button--text")
.textContent.trim();
fixture.whenStable().then(() => {
@@ -86,7 +86,7 @@ describe("CollapseComponent", () => {
fixture.componentRef.setInput("hideCollapseText", true);
fixture.detectChanges();
const buttonText = fixture.nativeElement.querySelector(
- ".collapse__button--text",
+ ".tedi-collapse__button--text",
);
expect(buttonText).toBeNull();
});
@@ -95,7 +95,7 @@ describe("CollapseComponent", () => {
fixture.componentRef.setInput("arrowType", "secondary");
fixture.detectChanges();
const iconWrapper = fixture.nativeElement.querySelector(
- ".collapse__icon--wrapper",
+ ".tedi-collapse__icon--wrapper",
);
expect(iconWrapper).toBeTruthy();
});
@@ -104,7 +104,7 @@ describe("CollapseComponent", () => {
fixture.componentRef.setInput("arrowType", "default");
fixture.detectChanges();
const iconWrapper = fixture.nativeElement.querySelector(
- ".collapse__icon--wrapper",
+ ".tedi-collapse__icon--wrapper",
);
expect(iconWrapper).toBeNull();
});
diff --git a/tedi/components/buttons/info-button/info-button.component.scss b/tedi/components/buttons/info-button/info-button.component.scss
index 39604cce5..6ca83801c 100644
--- a/tedi/components/buttons/info-button/info-button.component.scss
+++ b/tedi/components/buttons/info-button/info-button.component.scss
@@ -1,14 +1,14 @@
.tedi-info-button {
display: inline-flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
+ width: var(--button-xs-icon-size);
+ height: var(--button-xs-icon-size);
vertical-align: bottom;
color: var(--button-main-neutral-text-default);
+ cursor: pointer;
background: transparent;
border: none;
- cursor: pointer;
- width: var(--button-xs-icon-size);
- height: var(--button-xs-icon-size);
border-radius: var(--button-radius-default);
&:hover {
@@ -23,10 +23,10 @@
&:focus-visible {
color: var(--button-main-neutral-text-focus);
- background-color: var(--button-main-neutral-background-focus);
+ outline: var(--borders-02) solid var(--button-main-primary-border-focus);
outline-offset: var(--borders-01);
+ background-color: var(--button-main-neutral-background-focus);
border-color: var(--button-main-neutral-border-default);
- outline: var(--borders-02) solid var(--button-main-primary-border-focus);
}
tedi-icon {
diff --git a/tedi/components/buttons/info-button/info-button.component.spec.ts b/tedi/components/buttons/info-button/info-button.component.spec.ts
index 3936b5e77..f7474a44d 100644
--- a/tedi/components/buttons/info-button/info-button.component.spec.ts
+++ b/tedi/components/buttons/info-button/info-button.component.spec.ts
@@ -1,12 +1,24 @@
+import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { InfoButtonComponent } from "./info-button.component";
+import { TediTranslationService } from "@tedi-design-system/angular/tedi";
describe("InfoButtonComponent", () => {
+
+ const DEFAULT_LABEL = "More information";
let fixture: ComponentFixture
;
+ const translationTrackSpy = jest.fn().mockReturnValue(signal(DEFAULT_LABEL));
+ const translationService = {
+ track: translationTrackSpy,
+ };
+
beforeEach(() => {
TestBed.configureTestingModule({
imports: [InfoButtonComponent],
+ providers: [
+ { provide: TediTranslationService, useValue: translationService }
+ ]
});
fixture = TestBed.createComponent(InfoButtonComponent);
@@ -16,4 +28,16 @@ describe("InfoButtonComponent", () => {
it("should create component", () => {
expect(fixture.componentInstance).toBeTruthy();
});
+
+ it("should render base class and set default aria-label", () => {
+ expect(fixture.nativeElement.classList).toContain("tedi-info-button");
+ expect(translationTrackSpy).toHaveBeenCalledWith("info-button.label");
+ expect(fixture.nativeElement.getAttribute("aria-label")).toBe(DEFAULT_LABEL);
+ });
+
+ it("should set custom aria-label when provided", () => {
+ fixture.componentRef.setInput("aria-label", "Override");
+ fixture.detectChanges();
+ expect(fixture.nativeElement.getAttribute("aria-label")).toBe("Override");
+ });
});
diff --git a/tedi/components/buttons/info-button/info-button.component.ts b/tedi/components/buttons/info-button/info-button.component.ts
index f822a512e..40f397eec 100644
--- a/tedi/components/buttons/info-button/info-button.component.ts
+++ b/tedi/components/buttons/info-button/info-button.component.ts
@@ -1,5 +1,6 @@
-import { ChangeDetectionStrategy, Component, ViewEncapsulation } from "@angular/core";
+import { ChangeDetectionStrategy, Component, inject, input, ViewEncapsulation } from "@angular/core";
import { IconComponent } from "../../base/icon/icon.component";
+import { TediTranslationService } from "../../../services";
@Component({
standalone: true,
@@ -11,6 +12,16 @@ import { IconComponent } from "../../base/icon/icon.component";
encapsulation: ViewEncapsulation.None,
host: {
"class": "tedi-info-button",
+ "[attr.aria-label]": "ariaLabel() || _defaultLabel()"
}
})
-export class InfoButtonComponent {}
+export class InfoButtonComponent {
+ readonly translationService = inject(TediTranslationService);
+
+ /**
+ * InfoButton ARIA label
+ */
+ readonly ariaLabel = input(undefined, { alias: 'aria-label' });
+
+ private readonly _defaultLabel = this.translationService.track('info-button.label');
+}
diff --git a/tedi/components/buttons/info-button/info-button.stories.ts b/tedi/components/buttons/info-button/info-button.stories.ts
index 6797e6ace..e2ebb3e1c 100644
--- a/tedi/components/buttons/info-button/info-button.stories.ts
+++ b/tedi/components/buttons/info-button/info-button.stories.ts
@@ -20,6 +20,15 @@ const PSEUDO_STATE = ["Default", "Hover", "Active", "Focus"];
export default {
title: "TEDI-Ready/Components/Buttons/InfoButton",
+ argTypes: {
+ "aria-label": {
+ control: "text",
+ description: "ARIA label",
+ },
+ },
+ args: {
+ "aria-label": undefined
+ },
component: InfoButtonComponent,
decorators: [
moduleMetadata({
diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.html b/tedi/components/content/carousel/carousel-content/carousel-content.component.html
index a784470cf..76ed5c4bf 100644
--- a/tedi/components/content/carousel/carousel-content/carousel-content.component.html
+++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.html
@@ -7,12 +7,13 @@
@for (idx of renderedIndices(); track $index) {
diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.scss b/tedi/components/content/carousel/carousel-content/carousel-content.component.scss
index 5b0248d96..eb19154dc 100644
--- a/tedi/components/content/carousel/carousel-content/carousel-content.component.scss
+++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.scss
@@ -1,6 +1,6 @@
.tedi-carousel__content {
- width: 100%;
position: relative;
+ width: 100%;
overflow: hidden;
touch-action: pan-y;
cursor: grab;
diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts
index 36c11c0e4..71799b59b 100644
--- a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts
+++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts
@@ -142,6 +142,11 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
return ((i % slidesCount) + slidesCount) % slidesCount;
});
+
+ readonly renderedActiveIndex = computed(() => {
+ return this.trackIndex() - this.windowBase() + this.buffer();
+ });
+
readonly renderedIndices = computed(() => {
const slidesCount = this.slides().length;
@@ -236,7 +241,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
const cellWidth =
(this.viewportWidth() -
this.currentGap() * (this.currentSlidesPerView() - 1)) /
- this.currentSlidesPerView() +
+ this.currentSlidesPerView() +
this.currentGap();
if (!cellWidth) {
@@ -337,7 +342,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
const cellWidth =
(this.viewportWidth() -
this.currentGap() * (this.currentSlidesPerView() - 1)) /
- this.currentSlidesPerView() +
+ this.currentSlidesPerView() +
this.currentGap();
if (!cellWidth) {
diff --git a/tedi/components/content/carousel/carousel-header/carousel-header.component.scss b/tedi/components/content/carousel/carousel-header/carousel-header.component.scss
index 8e3ec40a9..b43f57c78 100644
--- a/tedi/components/content/carousel/carousel-header/carousel-header.component.scss
+++ b/tedi/components/content/carousel/carousel-header/carousel-header.component.scss
@@ -1,7 +1,7 @@
tedi-carousel-header {
display: flex;
+ gap: var(--layout-grid-gutters-16);
align-items: flex-end;
justify-content: space-between;
- gap: var(--layout-grid-gutters-16);
padding-bottom: var(--layout-grid-gutters-08);
}
diff --git a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss
index 8b613217a..205095553 100644
--- a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss
+++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss
@@ -1,33 +1,46 @@
tedi-carousel-indicators {
display: flex;
+ gap: var(--layout-grid-gutters-04);
align-items: center;
min-height: 24px;
- gap: var(--layout-grid-gutters-04);
}
.tedi-carousel__indicator {
- width: 22px;
- height: 8px;
- border-radius: 100px;
- border: 1px solid var(--tedi-primary-600);
- background-color: transparent;
+ position: relative;
+ width: 24px;
+ height: 24px;
cursor: pointer;
+ background: none;
+ border: none;
+
+ &::after {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 22px;
+ height: 8px;
+ content: "";
+ background-color: transparent;
+ border: 1px solid var(--tedi-primary-600);
+ border-radius: 100px;
+ transform: translate(-50%, -50%);
+ }
- &:hover {
+ &:hover::after {
border-color: var(--tedi-primary-700);
}
- &:active {
- border-color: var(--tedi-primary-800);
+ &:active::after {
background-color: var(--tedi-primary-800);
+ border-color: var(--tedi-primary-800);
}
- &:focus-visible {
+ &:focus-visible::after {
outline: var(--borders-02) solid var(--tedi-primary-500);
outline-offset: var(--borders-01);
}
- &--active {
+ &--active::after {
background-color: var(--tedi-primary-600);
}
}
diff --git a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss
index 71ce5fabe..16cb10b69 100644
--- a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss
+++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss
@@ -1,5 +1,5 @@
tedi-carousel-navigation {
display: flex;
- align-items: center;
gap: var(--layout-grid-gutters-08);
+ align-items: center;
}
diff --git a/tedi/components/content/carousel/carousel.component.spec.ts b/tedi/components/content/carousel/carousel.component.spec.ts
index 594a0cf9e..ce0784896 100644
--- a/tedi/components/content/carousel/carousel.component.spec.ts
+++ b/tedi/components/content/carousel/carousel.component.spec.ts
@@ -286,8 +286,18 @@ describe("CarouselContentComponent", () => {
component.trackIndex.set(5);
const index = component.slideIndex();
- expect(index).toBeGreaterThanOrEqual(0);
- expect(index).toBeLessThan(3);
+ expect(index).toEqual(2);
+ });
+
+ it("should compute renderedActiveIndex properly when slides exist", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.trackIndex.set(5);
+ const index = component.renderedActiveIndex();
+ expect(index).toEqual(8);
});
it("should apply fade-right class when fade true and slidesPerView > 1", () => {
@@ -474,7 +484,7 @@ describe("CarouselIndicatorsComponent", () => {
imports: [CarouselNavigationComponent],
template: `
`,
})
-class TestNavigationHostComponent {}
+class TestNavigationHostComponent { }
describe("CarouselNavigationComponent", () => {
let fixture: ComponentFixture
;
diff --git a/tedi/components/content/list/list.component.scss b/tedi/components/content/list/list.component.scss
index 6640e2cde..1eb17d1eb 100644
--- a/tedi/components/content/list/list.component.scss
+++ b/tedi/components/content/list/list.component.scss
@@ -19,8 +19,8 @@ $bullet-colors: (
}
ul.tedi-list {
- list-style: none;
padding-left: var(--list-padding-left-level-1);
+ list-style: none;
li {
position: relative;
@@ -30,11 +30,11 @@ ul.tedi-list {
position: absolute;
top: 0.5em;
left: 0;
+ width: var(--list-icon-size);
+ height: var(--list-icon-size);
content: "";
border: 2px solid var(--general-icon-brand);
border-radius: 50%;
- width: var(--list-icon-size);
- height: var(--list-icon-size);
}
ul.tedi-list li::before {
@@ -74,8 +74,8 @@ ol.tedi-list {
counter-reset: item;
li {
- counter-increment: item;
padding-left: var(--list-padding-left-level-1);
+ counter-increment: item;
&::before {
content: counters(item, ".") ". ";
diff --git a/tedi/components/content/text-group/text-group.component.html b/tedi/components/content/text-group/text-group.component.html
index 585a0aa0f..9356ee187 100644
--- a/tedi/components/content/text-group/text-group.component.html
+++ b/tedi/components/content/text-group/text-group.component.html
@@ -1,8 +1,8 @@
-
-
-
+
+
+
-
+
diff --git a/tedi/components/content/text-group/text-group.component.scss b/tedi/components/content/text-group/text-group.component.scss
index 92e5423c4..80ddf275f 100644
--- a/tedi/components/content/text-group/text-group.component.scss
+++ b/tedi/components/content/text-group/text-group.component.scss
@@ -12,12 +12,12 @@ tedi-text-group {
tedi-text-group-label {
display: flex;
- width: var(--_label-width);
flex-shrink: 0;
+ width: var(--_label-width);
}
tedi-text-group-value {
display: flex;
- align-items: center;
gap: var(--text-group-value-inner-spacing);
+ align-items: center;
}
diff --git a/tedi/components/content/text-group/text-group.component.spec.ts b/tedi/components/content/text-group/text-group.component.spec.ts
index 80137b2ed..a9b056d51 100644
--- a/tedi/components/content/text-group/text-group.component.spec.ts
+++ b/tedi/components/content/text-group/text-group.component.spec.ts
@@ -119,9 +119,23 @@ describe("TextGroupComponent", () => {
).toBe("50%");
});
- it("should have appropriate role attribute", () => {
- const dl = fixture.debugElement.query(By.css("dl")).nativeElement;
- expect(dl.getAttribute("role")).toBe("group");
+ it("should set aria-label on dt from label content", () => {
+ const dt = fixture.debugElement.query(By.css("dt")).nativeElement;
+ expect(dt.getAttribute("aria-label")).toBe("Test Label");
+ });
+
+ it("should hide label content from screen readers", () => {
+ const labelSpan = fixture.debugElement.query(
+ By.css("dt > span[aria-hidden]"),
+ ).nativeElement;
+ expect(labelSpan.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("should update aria-label when label content changes", () => {
+ fixture.componentInstance.label = "Updated Label";
+ fixture.detectChanges();
+ const dt = fixture.debugElement.query(By.css("dt")).nativeElement;
+ expect(dt.getAttribute("aria-label")).toBe("Updated Label");
});
});
diff --git a/tedi/components/content/text-group/text-group.component.ts b/tedi/components/content/text-group/text-group.component.ts
index aa14c8512..a866ef9b8 100644
--- a/tedi/components/content/text-group/text-group.component.ts
+++ b/tedi/components/content/text-group/text-group.component.ts
@@ -1,15 +1,21 @@
import {
+ AfterContentChecked,
ChangeDetectionStrategy,
Component,
computed,
+ contentChild,
+ ElementRef,
inject,
input,
+ signal,
ViewEncapsulation,
} from "@angular/core";
import {
BreakpointInputs,
BreakpointService,
} from "../../../services/breakpoint/breakpoint.service";
+import { LabelComponent } from "../../../components/form";
+import { TextGroupLabelComponent } from "./text-group-label.component";
export type TextGroupType = "vertical" | "horizontal";
@@ -29,14 +35,20 @@ export type TextGroupInputs = {
selector: "tedi-text-group",
templateUrl: "./text-group.component.html",
styleUrl: "./text-group.component.scss",
+ imports: [
+ LabelComponent
+ ],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
-export class TextGroupComponent implements BreakpointInputs {
+export class TextGroupComponent implements BreakpointInputs, AfterContentChecked {
type = input("horizontal");
labelWidth = input();
breakpointService = inject(BreakpointService);
+ readonly textGroupLabel = contentChild(TextGroupLabelComponent, { read: ElementRef });
+ readonly labelText = signal(null);
+
xs = input();
sm = input();
md = input();
@@ -62,4 +74,14 @@ export class TextGroupComponent implements BreakpointInputs {
const classList = [`tedi-text-group--${this.breakpointInputs().type}`];
return classList.join(" ");
});
+
+ ngAfterContentChecked(): void {
+ const labelEl = this.textGroupLabel()?.nativeElement as HTMLElement;
+ if (labelEl) {
+ const text = labelEl.textContent?.trim() || null;
+ if (text !== this.labelText()) {
+ this.labelText.set(text);
+ }
+ }
+ }
}
diff --git a/tedi/components/content/text-group/text-group.stories.ts b/tedi/components/content/text-group/text-group.stories.ts
index c826ecb44..68ffeb404 100644
--- a/tedi/components/content/text-group/text-group.stories.ts
+++ b/tedi/components/content/text-group/text-group.stories.ts
@@ -14,6 +14,7 @@ import {
VerticalSpacingDirective,
} from "@tedi-design-system/angular/tedi";
import { createBreakpointArgTypes } from "../../../../src/dev-tools/createBreakpointArgTypes";
+import { StatusBadgeComponent } from "@tedi-design-system/angular/community";
/**
* Figma ↗
@@ -40,6 +41,7 @@ export default {
TextGroupValueComponent,
IconComponent,
RowComponent,
+ StatusBadgeComponent
],
}),
],
@@ -88,6 +90,12 @@ export const Types: Story = {
label: "Accessibility",
value: "Visible to doctor and representative",
},
+ {
+ type: "vertical",
+ label: "Accessibility",
+ value: "Visible to doctor and representative",
+ statusBadge: 'Submitted'
+ },
{
type: "vertical",
label: "Accessibility",
@@ -139,11 +147,16 @@ export const Types: Story = {
[name]="group.icon.name"
[color]="group.icon.color"
/>
- @if (group.valueModifiers === "bold") {
- {{ group.value }}
- } @else {
- {{ group.value }}
- }
+
+ @if (group.valueModifiers === "bold") {
+ {{ group.value }}
+ } @else {
+ {{ group.value }}
+ }
+ @if (group.statusBadge) {
+ {{ group.statusBadge }}
+ }
+
diff --git a/tedi/components/form/checkbox/checkbox.component.scss b/tedi/components/form/checkbox/checkbox.component.scss
index 2ef441a72..1b5f661e8 100644
--- a/tedi/components/form/checkbox/checkbox.component.scss
+++ b/tedi/components/form/checkbox/checkbox.component.scss
@@ -3,16 +3,16 @@
input[tedi-checkbox][type="checkbox"] {
--_checkbox-icon-size: 1.125rem;
- appearance: none;
position: relative;
- cursor: pointer;
- border: 1px solid var(--form-checkbox-radio-default-border-default);
- background-color: var(--form-checkbox-radio-default-background-default);
- vertical-align: middle;
- padding: 0;
- margin: 0;
width: var(--form-checkbox-radio-size-responsive);
height: var(--form-checkbox-radio-size-responsive);
+ padding: 0;
+ margin: 0;
+ vertical-align: middle;
+ appearance: none;
+ cursor: pointer;
+ background-color: var(--form-checkbox-radio-default-background-default);
+ border: 1px solid var(--form-checkbox-radio-default-border-default);
border-radius: var(--form-checkbox-radio-indicator-radius-checkbox);
@include breakpoints.media-breakpoint-up(sm) {
@@ -26,34 +26,34 @@ input[tedi-checkbox][type="checkbox"] {
&:not(:checked):disabled {
cursor: not-allowed;
- border-color: var(--form-general-border-disabled);
background-color: var(--form-general-background-disabled);
+ border-color: var(--form-general-border-disabled);
}
&:checked,
&:indeterminate {
- border-color: var(--form-checkbox-radio-default-border-selected);
background-color: var(--form-checkbox-radio-default-background-selected);
+ border-color: var(--form-checkbox-radio-default-border-selected);
&:disabled {
cursor: not-allowed;
- border-color: var(--form-checkbox-radio-default-border-selected-disabled);
background-color: var(
--form-checkbox-radio-default-background-selected-disabled
);
+ border-color: var(--form-checkbox-radio-default-border-selected-disabled);
}
&::before {
position: absolute;
top: 50%;
left: 50%;
- content: "check";
- font-size: var(--_checkbox-icon-size);
font-family: "Material Symbols Outlined", sans-serif;
- -webkit-font-smoothing: antialiased;
- color: var(--form-checkbox-radio-default-check-indicator-default);
+ font-size: var(--_checkbox-icon-size);
line-height: 1;
+ color: var(--form-checkbox-radio-default-check-indicator-default);
+ content: "check";
transform: translate(-50%, -50%);
+ -webkit-font-smoothing: antialiased;
}
}
@@ -71,11 +71,11 @@ input[tedi-checkbox][type="checkbox"] {
&:focus-visible {
outline-width: 2px;
- outline-offset: 2px;
outline-style: solid;
+ outline-offset: 2px;
}
- &:not(:checked):not(:disabled).tedi-checkbox--invalid,
+ &:not(:checked, :disabled).tedi-checkbox--invalid,
&:user-invalid,
&.ng-invalid.ng-touched {
border-color: var(--form-general-feedback-error-border);
diff --git a/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.html b/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.html
new file mode 100644
index 000000000..a4353313e
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.html
@@ -0,0 +1,65 @@
+
+ @if (showWeekNumbers()) {
+
+ }
+ @for (wd of weekDays; track $index) {
+
+ {{ wd() }}
+
+ }
+
+
+
+ @for (week of weekRows(); track row; let row = $index) {
+
+ @if (showWeekNumbers()) {
+
+ {{ weekNumbers()[row] }}
+
+ }
+ @for (day of week; track day.date) {
+
+ @if (isToday(day.date)) {
+
+ {{ day.date.getDate() }}
+
+ } @else {
+ {{ day.date.getDate() }}
+ }
+
+ }
+
+ }
+
diff --git a/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.spec.ts b/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.spec.ts
new file mode 100644
index 000000000..f2e45dce4
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.spec.ts
@@ -0,0 +1,205 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { DatePickerCalendarGridComponent } from "./date-picker-calendar-grid.component";
+import { TediTranslationService } from "../../../../services/translation/translation.service";
+import { DatePickerDay } from "../date-picker.component";
+
+class TranslationMock {
+ track(key: string) {
+ return () => key;
+ }
+}
+
+describe("DatePickerCalendarGridComponent", () => {
+ let fixture: ComponentFixture;
+ let component: DatePickerCalendarGridComponent;
+
+ const mockWeekRows: DatePickerDay[][] = [
+ [
+ { date: new Date(2024, 4, 13), disabled: false, inCurrentMonth: true },
+ { date: new Date(2024, 4, 14), disabled: false, inCurrentMonth: true },
+ { date: new Date(2024, 4, 15), disabled: false, inCurrentMonth: true },
+ { date: new Date(2024, 4, 16), disabled: true, inCurrentMonth: true },
+ { date: new Date(2024, 4, 17), disabled: false, inCurrentMonth: true },
+ { date: new Date(2024, 4, 18), disabled: false, inCurrentMonth: true },
+ { date: new Date(2024, 4, 19), disabled: false, inCurrentMonth: true },
+ ],
+ ];
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [DatePickerCalendarGridComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ ],
+ });
+
+ fixture = TestBed.createComponent(DatePickerCalendarGridComponent);
+ component = fixture.componentInstance;
+
+ fixture.componentRef.setInput("gridId", "test-grid");
+ fixture.componentRef.setInput("weekRows", mockWeekRows);
+ fixture.componentRef.setInput("weekNumbers", [20]);
+ fixture.componentRef.setInput("activeDate", new Date(2024, 4, 15));
+ fixture.componentRef.setInput("today", new Date(2024, 4, 15));
+
+ fixture.detectChanges();
+ });
+
+ it("should create component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe("isSelected()", () => {
+ it("should return true when date matches selected date", () => {
+ fixture.componentRef.setInput("selected", new Date(2024, 4, 15));
+ fixture.detectChanges();
+
+ expect(component.isSelected(new Date(2024, 4, 15))).toBe(true);
+ });
+
+ it("should return false when date does not match selected date", () => {
+ fixture.componentRef.setInput("selected", new Date(2024, 4, 15));
+ fixture.detectChanges();
+
+ expect(component.isSelected(new Date(2024, 4, 16))).toBe(false);
+ });
+
+ it("should return false when no date is selected", () => {
+ fixture.componentRef.setInput("selected", null);
+ fixture.detectChanges();
+
+ expect(component.isSelected(new Date(2024, 4, 15))).toBe(false);
+ });
+ });
+
+ describe("isToday()", () => {
+ it("should return true when date is today", () => {
+ expect(component.isToday(new Date(2024, 4, 15))).toBe(true);
+ });
+
+ it("should return false when date is not today", () => {
+ expect(component.isToday(new Date(2024, 4, 16))).toBe(false);
+ });
+ });
+
+ describe("getTabIndex()", () => {
+ it("should return 0 when date matches active date", () => {
+ expect(component.getTabIndex(new Date(2024, 4, 15))).toBe(0);
+ });
+
+ it("should return -1 when date does not match active date", () => {
+ expect(component.getTabIndex(new Date(2024, 4, 16))).toBe(-1);
+ });
+
+ it("should return -1 when activeDate is null", () => {
+ fixture.componentRef.setInput("activeDate", null);
+ fixture.detectChanges();
+
+ expect(component.getTabIndex(new Date(2024, 4, 15))).toBe(-1);
+ });
+ });
+
+ describe("onDayClick()", () => {
+ it("should emit daySelect event with the clicked day", () => {
+ const emitSpy = jest.spyOn(component.daySelect, "emit");
+ const day: DatePickerDay = {
+ date: new Date(2024, 4, 15),
+ disabled: false,
+ inCurrentMonth: true,
+ };
+
+ component.onDayClick(day);
+
+ expect(emitSpy).toHaveBeenCalledWith(day);
+ });
+ });
+
+ describe("onDayKeydown()", () => {
+ it("should emit dayKeydown event with keyboard event and date", () => {
+ const emitSpy = jest.spyOn(component.dayKeydown, "emit");
+ const event = new KeyboardEvent("keydown", { key: "Enter" });
+ const date = new Date(2024, 4, 15);
+
+ component.onDayKeydown(event, date);
+
+ expect(emitSpy).toHaveBeenCalledWith({ event, date });
+ });
+ });
+
+ describe("focusDate()", () => {
+ type GridElementType = {
+ gridElement: () => { nativeElement: HTMLElement } | undefined;
+ };
+
+ it("should focus the button with matching data-date-key", () => {
+ const date = new Date(2024, 4, 15);
+ const key = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ ).getTime();
+
+ const mockButton = document.createElement("button");
+ mockButton.setAttribute("data-date-key", key.toString());
+ const focusSpy = jest.spyOn(mockButton, "focus");
+
+ const mockContainer = document.createElement("div");
+ mockContainer.appendChild(mockButton);
+
+ jest
+ .spyOn(component as unknown as GridElementType, "gridElement")
+ .mockReturnValue({ nativeElement: mockContainer });
+
+ component.focusDate(date);
+
+ expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
+ });
+
+ it("should not focus when button is already focused", () => {
+ const date = new Date(2024, 4, 15);
+ const key = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ ).getTime();
+
+ const mockButton = document.createElement("button");
+ mockButton.setAttribute("data-date-key", key.toString());
+ const focusSpy = jest.spyOn(mockButton, "focus");
+
+ const mockContainer = document.createElement("div");
+ mockContainer.appendChild(mockButton);
+
+ jest
+ .spyOn(component as unknown as GridElementType, "gridElement")
+ .mockReturnValue({ nativeElement: mockContainer });
+
+ Object.defineProperty(document, "activeElement", {
+ value: mockButton,
+ configurable: true,
+ });
+
+ component.focusDate(date);
+
+ expect(focusSpy).not.toHaveBeenCalled();
+ });
+
+ it("should return early when gridElement is null", () => {
+ jest
+ .spyOn(component as unknown as GridElementType, "gridElement")
+ .mockReturnValue(undefined);
+
+ expect(() => component.focusDate(new Date(2024, 4, 15))).not.toThrow();
+ });
+
+ it("should not focus when button is not found", () => {
+ const mockContainer = document.createElement("div");
+
+ jest
+ .spyOn(component as unknown as GridElementType, "gridElement")
+ .mockReturnValue({ nativeElement: mockContainer });
+
+ expect(() => component.focusDate(new Date(2024, 4, 15))).not.toThrow();
+ });
+ });
+});
diff --git a/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.ts b/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.ts
new file mode 100644
index 000000000..67b0c3d75
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-calendar-grid/date-picker-calendar-grid.component.ts
@@ -0,0 +1,104 @@
+import {
+ Component,
+ ViewEncapsulation,
+ ChangeDetectionStrategy,
+ input,
+ output,
+ viewChild,
+ ElementRef,
+ inject,
+} from "@angular/core";
+import { DatePickerDay } from "../date-picker.component";
+import { TediTranslationService } from "../../../../services/translation/translation.service";
+
+@Component({
+ standalone: true,
+ selector: "tedi-date-picker-calendar-grid",
+ templateUrl: "./date-picker-calendar-grid.component.html",
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DatePickerCalendarGridComponent {
+ private readonly gridElement =
+ viewChild>("gridElement");
+ /** Grid element unique ID for ARIA */
+ readonly gridId = input.required();
+
+ /** Week rows containing day data */
+ readonly weekRows = input.required();
+
+ /** Week numbers for each row */
+ readonly weekNumbers = input.required();
+
+ /** Whether to show week numbers */
+ readonly showWeekNumbers = input(false);
+
+ /** Currently active date for keyboard navigation */
+ readonly activeDate = input.required();
+
+ /** Currently selected date */
+ readonly selected = input(null);
+
+ /** Today's date */
+ readonly today = input.required();
+
+ /** Emits when a day is selected */
+ readonly daySelect = output();
+
+ /** Emits on day keydown for keyboard navigation */
+ readonly dayKeydown = output<{ event: KeyboardEvent; date: Date }>();
+
+ private readonly translationService = inject(TediTranslationService);
+
+ readonly weekDays = [
+ this.translationService.track("date-picker.monday-short"),
+ this.translationService.track("date-picker.tuesday-short"),
+ this.translationService.track("date-picker.wednesday-short"),
+ this.translationService.track("date-picker.thursday-short"),
+ this.translationService.track("date-picker.friday-short"),
+ this.translationService.track("date-picker.saturday-short"),
+ this.translationService.track("date-picker.sunday-short"),
+ ];
+
+ isSelected(date: Date): boolean {
+ const sel = this.selected();
+ return !!sel && date.toDateString() === sel.toDateString();
+ }
+
+ isToday(date: Date): boolean {
+ return date.toDateString() === this.today().toDateString();
+ }
+
+ getTabIndex(date: Date): number {
+ const active = this.activeDate();
+ return active && date.toDateString() === active.toDateString() ? 0 : -1;
+ }
+
+ onDayClick(day: DatePickerDay) {
+ this.daySelect.emit(day);
+ }
+
+ onDayKeydown(event: KeyboardEvent, date: Date) {
+ this.dayKeydown.emit({ event, date });
+ }
+
+ /** Focus a specific date in the grid */
+ focusDate(date: Date) {
+ const container = this.gridElement()?.nativeElement;
+ if (!container) return;
+
+ const key = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ date.getDate(),
+ ).getTime();
+
+ const btn = container.querySelector(
+ `[data-date-key="${key}"]`,
+ );
+
+ if (btn && document.activeElement !== btn) {
+ btn.focus({ preventScroll: true });
+ }
+ }
+}
diff --git a/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.html b/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.html
new file mode 100644
index 000000000..d8a0b7907
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.html
@@ -0,0 +1,168 @@
+
diff --git a/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.spec.ts b/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.spec.ts
new file mode 100644
index 000000000..0ecb4a1a9
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.spec.ts
@@ -0,0 +1,151 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { DatePickerHeaderComponent } from "./date-picker-header.component";
+import { TediTranslationService } from "../../../../services/translation/translation.service";
+
+class TranslationMock {
+ track(key: string) {
+ return () => key;
+ }
+}
+
+describe("DatePickerHeaderComponent", () => {
+ let fixture: ComponentFixture;
+ let component: DatePickerHeaderComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [DatePickerHeaderComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ ],
+ });
+
+ fixture = TestBed.createComponent(DatePickerHeaderComponent);
+ component = fixture.componentInstance;
+
+ fixture.componentRef.setInput("uniqueId", "test-datepicker");
+ fixture.componentRef.setInput("currentView", "calendar-grid");
+ fixture.componentRef.setInput("month", new Date(2024, 4, 1));
+ fixture.componentRef.setInput("selectedYear", 2024);
+ fixture.componentRef.setInput("years", [2020, 2021, 2022, 2023, 2024, 2025]);
+ fixture.componentRef.setInput("pagedYears", [2020, 2021, 2022, 2023, 2024, 2025]);
+
+ fixture.detectChanges();
+ });
+
+ it("should create component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe("onPrevMonth()", () => {
+ it("should emit prevMonth event", () => {
+ const emitSpy = jest.spyOn(component.prevMonth, "emit");
+
+ component.onPrevMonth();
+
+ expect(emitSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("onNextMonth()", () => {
+ it("should emit nextMonth event", () => {
+ const emitSpy = jest.spyOn(component.nextMonth, "emit");
+
+ component.onNextMonth();
+
+ expect(emitSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("onMonthSelect()", () => {
+ it("should emit monthSelect event when value is provided", () => {
+ const emitSpy = jest.spyOn(component.monthSelect, "emit");
+
+ component.onMonthSelect("5");
+
+ expect(emitSpy).toHaveBeenCalledWith("5");
+ });
+
+ it("should not emit monthSelect event when value is undefined", () => {
+ const emitSpy = jest.spyOn(component.monthSelect, "emit");
+
+ component.onMonthSelect(undefined);
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it("should not emit monthSelect event when value is empty string", () => {
+ const emitSpy = jest.spyOn(component.monthSelect, "emit");
+
+ component.onMonthSelect("");
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("onYearSelect()", () => {
+ it("should emit yearSelect event when value is provided", () => {
+ const emitSpy = jest.spyOn(component.yearSelect, "emit");
+
+ component.onYearSelect("2025");
+
+ expect(emitSpy).toHaveBeenCalledWith("2025");
+ });
+
+ it("should not emit yearSelect event when value is undefined", () => {
+ const emitSpy = jest.spyOn(component.yearSelect, "emit");
+
+ component.onYearSelect(undefined);
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it("should not emit yearSelect event when value is empty string", () => {
+ const emitSpy = jest.spyOn(component.yearSelect, "emit");
+
+ component.onYearSelect("");
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("onMonthClick()", () => {
+ it("should emit monthClick event", () => {
+ const emitSpy = jest.spyOn(component.monthClick, "emit");
+
+ component.onMonthClick();
+
+ expect(emitSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("onYearClick()", () => {
+ it("should emit yearClick event", () => {
+ const emitSpy = jest.spyOn(component.yearClick, "emit");
+
+ component.onYearClick();
+
+ expect(emitSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("onPrevYearPage()", () => {
+ it("should emit prevYearPage event", () => {
+ const emitSpy = jest.spyOn(component.prevYearPage, "emit");
+
+ component.onPrevYearPage();
+
+ expect(emitSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("onNextYearPage()", () => {
+ it("should emit nextYearPage event", () => {
+ const emitSpy = jest.spyOn(component.nextYearPage, "emit");
+
+ component.onNextYearPage();
+
+ expect(emitSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.ts b/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.ts
new file mode 100644
index 000000000..034d3ce0f
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-header/date-picker-header.component.ts
@@ -0,0 +1,136 @@
+import {
+ Component,
+ ViewEncapsulation,
+ ChangeDetectionStrategy,
+ input,
+ output,
+ inject,
+} from "@angular/core";
+import { ButtonComponent } from "../../../buttons/button/button.component";
+import { IconComponent } from "../../../base/icon/icon.component";
+import { DropdownComponent } from "../../../overlay/dropdown/dropdown.component";
+import { DropdownTriggerDirective } from "../../../overlay/dropdown/dropdown-trigger/dropdown-trigger.directive";
+import { DropdownContentComponent } from "../../../overlay/dropdown/dropdown-content/dropdown-content.component";
+import { DropdownItemComponent } from "../../../overlay/dropdown/dropdown-item/dropdown-item.component";
+import { TediTranslationService } from "../../../../services/translation/translation.service";
+import { DatePickerSelectorMode, DatePickerView } from "../date-picker.component";
+
+@Component({
+ standalone: true,
+ selector: "tedi-date-picker-header",
+ templateUrl: "./date-picker-header.component.html",
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ ButtonComponent,
+ IconComponent,
+ DropdownComponent,
+ DropdownTriggerDirective,
+ DropdownContentComponent,
+ DropdownItemComponent,
+ ],
+})
+export class DatePickerHeaderComponent {
+ /** Unique ID for ARIA controls */
+ readonly uniqueId = input.required();
+
+ /** Current view mode */
+ readonly currentView = input.required();
+
+ /** Currently displayed month */
+ readonly month = input.required();
+
+ /** Month selector mode */
+ readonly monthMode = input("dropdown");
+
+ /** Year selector mode */
+ readonly yearMode = input("dropdown");
+
+ /** Whether to show navigation buttons */
+ readonly showNavigation = input(true);
+
+ /** Whether previous month navigation is enabled */
+ readonly canGoPrev = input(true);
+
+ /** Whether next month navigation is enabled */
+ readonly canGoNext = input(true);
+
+ /** Selected year for display */
+ readonly selectedYear = input.required();
+
+ /** List of available years for dropdown */
+ readonly years = input.required();
+
+ /** Paged years for year grid navigation */
+ readonly pagedYears = input.required();
+
+ /** Whether there's a previous year page */
+ readonly hasPrevYearPage = input(true);
+
+ /** Whether there's a next year page */
+ readonly hasNextYearPage = input(true);
+
+ /** Set of disabled month indices (0-11) */
+ readonly disabledMonths = input>(new Set());
+
+ /** Set of disabled years */
+ readonly disabledYears = input>(new Set());
+
+ readonly prevMonth = output();
+ readonly nextMonth = output();
+ readonly monthSelect = output();
+ readonly yearSelect = output();
+ readonly monthClick = output();
+ readonly yearClick = output();
+ readonly prevYearPage = output();
+ readonly nextYearPage = output();
+
+ readonly translationService = inject(TediTranslationService);
+
+ readonly monthNames = [
+ this.translationService.track("date-picker.january"),
+ this.translationService.track("date-picker.february"),
+ this.translationService.track("date-picker.march"),
+ this.translationService.track("date-picker.april"),
+ this.translationService.track("date-picker.may"),
+ this.translationService.track("date-picker.june"),
+ this.translationService.track("date-picker.july"),
+ this.translationService.track("date-picker.august"),
+ this.translationService.track("date-picker.september"),
+ this.translationService.track("date-picker.october"),
+ this.translationService.track("date-picker.november"),
+ this.translationService.track("date-picker.december"),
+ ];
+
+ onPrevMonth() {
+ this.prevMonth.emit();
+ }
+
+ onNextMonth() {
+ this.nextMonth.emit();
+ }
+
+ onMonthSelect(value?: string) {
+ if (value) this.monthSelect.emit(value);
+ }
+
+ onYearSelect(value?: string) {
+ if (value) this.yearSelect.emit(value);
+ }
+
+ onMonthClick() {
+ this.monthClick.emit();
+ }
+
+ onYearClick() {
+ this.yearClick.emit();
+ }
+
+ onPrevYearPage() {
+ this.prevYearPage.emit();
+ }
+
+ onNextYearPage() {
+ this.nextYearPage.emit();
+ }
+}
diff --git a/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.html b/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.html
new file mode 100644
index 000000000..bcf205821
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.html
@@ -0,0 +1,14 @@
+
+ @for (monthName of monthShortNames; track i; let i = $index) {
+
+ {{ monthName() }}
+
+ }
+
diff --git a/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.spec.ts b/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.spec.ts
new file mode 100644
index 000000000..f0cf29baa
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.spec.ts
@@ -0,0 +1,64 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { DatePickerMonthGridComponent } from "./date-picker-month-grid.component";
+import { TediTranslationService } from "../../../../services/translation/translation.service";
+
+class TranslationMock {
+ track(key: string) {
+ return () => key;
+ }
+}
+
+describe("DatePickerMonthGridComponent", () => {
+ let fixture: ComponentFixture;
+ let component: DatePickerMonthGridComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [DatePickerMonthGridComponent],
+ providers: [
+ { provide: TediTranslationService, useClass: TranslationMock },
+ ],
+ });
+
+ fixture = TestBed.createComponent(DatePickerMonthGridComponent);
+ component = fixture.componentInstance;
+
+ fixture.componentRef.setInput("currentMonth", new Date(2024, 4, 1));
+
+ fixture.detectChanges();
+ });
+
+ it("should create component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should have 12 month short names", () => {
+ expect(component.monthShortNames.length).toBe(12);
+ });
+
+ describe("onMonthClick()", () => {
+ it("should emit monthSelect event with clicked month index", () => {
+ const emitSpy = jest.spyOn(component.monthSelect, "emit");
+
+ component.onMonthClick(5);
+
+ expect(emitSpy).toHaveBeenCalledWith(5);
+ });
+
+ it("should emit January when first month is clicked", () => {
+ const emitSpy = jest.spyOn(component.monthSelect, "emit");
+
+ component.onMonthClick(0);
+
+ expect(emitSpy).toHaveBeenCalledWith(0);
+ });
+
+ it("should emit December when last month is clicked", () => {
+ const emitSpy = jest.spyOn(component.monthSelect, "emit");
+
+ component.onMonthClick(11);
+
+ expect(emitSpy).toHaveBeenCalledWith(11);
+ });
+ });
+});
diff --git a/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.ts b/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.ts
new file mode 100644
index 000000000..2fc61985f
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-month-grid/date-picker-month-grid.component.ts
@@ -0,0 +1,45 @@
+import {
+ Component,
+ ViewEncapsulation,
+ ChangeDetectionStrategy,
+ input,
+ output,
+ inject,
+} from "@angular/core";
+import { TediTranslationService } from "../../../../services/translation/translation.service";
+
+@Component({
+ standalone: true,
+ selector: "tedi-date-picker-month-grid",
+ templateUrl: "./date-picker-month-grid.component.html",
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DatePickerMonthGridComponent {
+ /** Currently displayed month (0-11) */
+ readonly currentMonth = input.required();
+
+ /** Emits selected month index (0-11) */
+ readonly monthSelect = output();
+
+ private readonly translationService = inject(TediTranslationService);
+
+ readonly monthShortNames = [
+ this.translationService.track("date-picker.january-short"),
+ this.translationService.track("date-picker.february-short"),
+ this.translationService.track("date-picker.march-short"),
+ this.translationService.track("date-picker.april-short"),
+ this.translationService.track("date-picker.may-short"),
+ this.translationService.track("date-picker.june-short"),
+ this.translationService.track("date-picker.july-short"),
+ this.translationService.track("date-picker.august-short"),
+ this.translationService.track("date-picker.september-short"),
+ this.translationService.track("date-picker.october-short"),
+ this.translationService.track("date-picker.november-short"),
+ this.translationService.track("date-picker.december-short"),
+ ];
+
+ onMonthClick(index: number) {
+ this.monthSelect.emit(index);
+ }
+}
diff --git a/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.html b/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.html
new file mode 100644
index 000000000..954d35413
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.html
@@ -0,0 +1,12 @@
+
+ @for (year of pagedYears(); track year) {
+
+ {{ year }}
+
+ }
+
diff --git a/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.spec.ts b/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.spec.ts
new file mode 100644
index 000000000..056d37922
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.spec.ts
@@ -0,0 +1,51 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { DatePickerYearGridComponent } from "./date-picker-year-grid.component";
+
+describe("DatePickerYearGridComponent", () => {
+ let fixture: ComponentFixture;
+ let component: DatePickerYearGridComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [DatePickerYearGridComponent],
+ });
+
+ fixture = TestBed.createComponent(DatePickerYearGridComponent);
+ component = fixture.componentInstance;
+
+ fixture.componentRef.setInput("pagedYears", [2020, 2021, 2022, 2023, 2024, 2025]);
+ fixture.componentRef.setInput("selectedYear", 2024);
+
+ fixture.detectChanges();
+ });
+
+ it("should create component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe("onYearClick()", () => {
+ it("should emit yearSelect event with clicked year", () => {
+ const emitSpy = jest.spyOn(component.yearSelect, "emit");
+
+ component.onYearClick(2025);
+
+ expect(emitSpy).toHaveBeenCalledWith(2025);
+ });
+
+ it("should emit the first year in the page when clicked", () => {
+ const emitSpy = jest.spyOn(component.yearSelect, "emit");
+
+ component.onYearClick(2020);
+
+ expect(emitSpy).toHaveBeenCalledWith(2020);
+ });
+
+ it("should emit the last year in the page when clicked", () => {
+ const emitSpy = jest.spyOn(component.yearSelect, "emit");
+
+ component.onYearClick(2025);
+
+ expect(emitSpy).toHaveBeenCalledWith(2025);
+ });
+ });
+});
diff --git a/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.ts b/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.ts
new file mode 100644
index 000000000..72648481b
--- /dev/null
+++ b/tedi/components/form/date-picker/date-picker-year-grid/date-picker-year-grid.component.ts
@@ -0,0 +1,29 @@
+import {
+ Component,
+ ViewEncapsulation,
+ ChangeDetectionStrategy,
+ input,
+ output,
+} from "@angular/core";
+
+@Component({
+ standalone: true,
+ selector: "tedi-date-picker-year-grid",
+ templateUrl: "./date-picker-year-grid.component.html",
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DatePickerYearGridComponent {
+ /** Years to display in current page */
+ readonly pagedYears = input.required();
+
+ /** Currently selected year */
+ readonly selectedYear = input.required();
+
+ /** Emits selected year */
+ readonly yearSelect = output();
+
+ onYearClick(year: number) {
+ this.yearSelect.emit(year);
+ }
+}
diff --git a/tedi/components/form/date-picker/date-picker.component.html b/tedi/components/form/date-picker/date-picker.component.html
index 3666ea187..34b5229da 100644
--- a/tedi/components/form/date-picker/date-picker.component.html
+++ b/tedi/components/form/date-picker/date-picker.component.html
@@ -59,283 +59,56 @@
class="tedi-date-picker__calendar"
(keydown)="onCalendarKeyDown($event)"
>
-
+
@if (currentView() === "calendar-grid") {
-
- @if (showWeekNumbers()) {
-
- }
- @for (wd of weekDays; track $index; let i = $index) {
-
- {{ wd() }}
-
- }
-
-
-
- @for (week of weekRows(); track row; let row = $index) {
-
- @if (showWeekNumbers()) {
-
- {{ weekNumbers()[row] }}
-
- }
- @for (day of week; track day.date) {
-
- @if (isToday(day.date)) {
-
- {{ day.date.getDate() }}
-
- } @else {
- {{ day.date.getDate() }}
- }
-
- }
-
- }
-
+ [gridId]="uniqueId"
+ [weekRows]="weekRows()"
+ [weekNumbers]="weekNumbers()"
+ [showWeekNumbers]="showWeekNumbers()"
+ [activeDate]="activeDate()"
+ [selected]="selected()"
+ [today]="today"
+ (daySelect)="selectDay($event)"
+ (dayKeydown)="onDayKeydown($event.event, $event.date)"
+ />
} @else if (currentView() === "month-grid") {
-
- @for (monthName of monthShortNames; track i; let i = $index) {
-
- {{ monthName() }}
-
- }
-
+
} @else if (currentView() === "year-grid") {
-
- @for (year of pagedYears(); track year) {
-
- {{ year }}
-
- }
-
+
}
diff --git a/tedi/components/form/date-picker/date-picker.component.scss b/tedi/components/form/date-picker/date-picker.component.scss
index ddb3cb9ae..e37f5bf55 100644
--- a/tedi/components/form/date-picker/date-picker.component.scss
+++ b/tedi/components/form/date-picker/date-picker.component.scss
@@ -1,12 +1,12 @@
tedi-date-picker {
display: flex;
- min-height: var(--form-field-height);
gap: var(--form-field-inner-spacing);
align-self: stretch;
- border-radius: var(--form-field-radius);
- border: var(--borders-01) solid var(--form-input-border-default);
- background: var(--form-input-background-default);
+ min-height: var(--form-field-height);
padding-right: var(--form-field-padding-x-md-default);
+ background: var(--form-input-background-default);
+ border: var(--borders-01) solid var(--form-input-border-default);
+ border-radius: var(--form-field-radius);
&:has(.tedi-date-picker__input:hover):not(
:has(.tedi-date-picker__input:disabled)
@@ -25,9 +25,9 @@ tedi-date-picker {
}
&:has(.tedi-date-picker__input:disabled) {
- border-color: var(--form-input-border-disabled);
- background: var(--form-input-background-disabled);
cursor: not-allowed;
+ background: var(--form-input-background-disabled);
+ border-color: var(--form-input-border-disabled);
}
&:has(.tedi-date-picker__input--valid) {
@@ -47,8 +47,8 @@ tedi-date-picker {
&__input {
flex: 1;
padding-left: var(--form-field-padding-x-md-default);
- color: var(--form-input-text-filled);
font-size: var(--body-regular-size);
+ color: var(--form-input-text-filled);
border: 0;
border-radius: var(--form-field-radius);
@@ -62,11 +62,11 @@ tedi-date-picker {
}
&__input-buttons {
- align-self: center;
display: flex;
+ gap: var(--layout-grid-gutters-04);
align-items: center;
+ align-self: center;
justify-content: center;
- gap: var(--layout-grid-gutters-04);
}
&__clear {
@@ -78,8 +78,8 @@ tedi-date-picker {
&__toggle {
width: var(--button-xs-icon-size) !important;
height: var(--form-field-button-height-sm) !important;
- border-radius: var(--button-radius-sm) !important;
font-size: 1.125rem !important;
+ border-radius: var(--button-radius-sm) !important;
&:disabled {
cursor: not-allowed;
@@ -87,42 +87,42 @@ tedi-date-picker {
}
&__calendar {
- width: fit-content;
display: block;
- border-radius: var(--card-radius-rounded);
- background: var(--card-background-primary);
+ width: fit-content;
user-select: none;
+ background: var(--card-background-primary);
+ border-radius: var(--card-radius-rounded);
}
&__header {
display: flex;
+ gap: var(--layout-grid-gutters-08);
align-items: center;
justify-content: space-between;
- gap: var(--layout-grid-gutters-08);
padding: var(--card-padding-md-default) var(--card-padding-md-default)
var(--card-padding-xs) var(--card-padding-md-default);
}
&__controls {
display: flex;
- align-items: center;
gap: var(--layout-grid-gutters-08);
+ align-items: center;
margin: 0 auto;
}
&__dropdown-trigger {
display: inline-flex;
- align-items: center;
gap: var(--layout-grid-gutters-02);
+ align-items: center;
padding: 0;
padding-left: var(--layout-grid-gutters-04);
font-size: 1rem;
font-weight: 500;
color: var(--general-text-primary);
+ cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--button-radius-sm);
- cursor: pointer;
&:hover {
color: var(--button-main-neutral-text-hover);
@@ -166,8 +166,8 @@ tedi-date-picker {
}
&__label {
- color: var(--general-text-primary);
font-weight: 500;
+ color: var(--general-text-primary);
}
&__nav {
@@ -185,11 +185,11 @@ tedi-date-picker {
}
&__weekday {
- width: var(--form-calendar-date-width);
- height: var(--form-calendar-date-width);
display: flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
+ width: var(--form-calendar-date-width);
+ height: var(--form-calendar-date-width);
font-size: var(--body-small-regular-size);
color: var(--general-text-tertiary);
text-align: center;
@@ -214,29 +214,29 @@ tedi-date-picker {
}
&__weeknumber {
- width: var(--form-calendar-date-width);
- height: var(--form-calendar-date-width);
display: flex;
- justify-content: center;
- align-items: center;
flex-shrink: 0;
- color: var(--general-text-tertiary);
+ align-items: center;
+ justify-content: center;
+ width: var(--form-calendar-date-width);
+ height: var(--form-calendar-date-width);
font-size: var(--body-small-regular-size);
+ color: var(--general-text-tertiary);
border-right: var(--borders-01) solid var(--general-border-primary);
}
&__day {
- width: var(--form-calendar-date-width);
- height: var(--form-calendar-date-width);
display: flex;
- justify-content: center;
- align-items: center;
flex-shrink: 0;
- border: none;
- background: none;
- cursor: pointer;
- color: var(--general-text-primary);
+ align-items: center;
+ justify-content: center;
+ width: var(--form-calendar-date-width);
+ height: var(--form-calendar-date-width);
font-size: var(--body-regular-size);
+ color: var(--general-text-primary);
+ cursor: pointer;
+ background: none;
+ border: none;
border-radius: var(--button-radius-sm);
&:hover {
@@ -263,8 +263,8 @@ tedi-date-picker {
&--selected {
color: var(--form-datepicker-date-text-selected);
- border-radius: var(--button-radius-sm);
background: var(--form-datepicker-date-selected);
+ border-radius: var(--button-radius-sm);
&:hover {
background: var(--form-datepicker-date-selected);
@@ -278,13 +278,13 @@ tedi-date-picker {
&__today {
display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
width: var(--form-calendar-date-width);
height: var(--form-calendar-date-width);
- justify-content: center;
- align-items: center;
- border-radius: var(--button-radius-default);
border: var(--borders-01) solid var(--form-datepicker-today-border);
- flex-shrink: 0;
+ border-radius: var(--button-radius-default);
}
&__month-year-grid {
@@ -296,21 +296,21 @@ tedi-date-picker {
&__month-year-button {
display: flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
padding: var(--form-checkbox-radio-card-radio-padding-y)
var(--form-checkbox-radio-card-radio-padding-x);
- border-radius: var(--form-checkbox-radio-card-radius);
+ font-size: var(--body-regular-size);
+ color: var(--form-checkbox-radio-card-primary-default-text);
+ background: var(--form-checkbox-radio-card-secondary-default-background);
border: var(--borders-01) solid
var(--form-checkbox-radio-card-secondary-default-border);
- background: var(--form-checkbox-radio-card-secondary-default-background);
- color: var(--form-checkbox-radio-card-primary-default-text);
- font-size: var(--body-regular-size);
+ border-radius: var(--form-checkbox-radio-card-radius);
&:hover {
color: var(--form-checkbox-radio-card-secondary-hover-text);
- border-color: var(--form-checkbox-radio-card-secondary-hover-border);
background: var(--form-checkbox-radio-card-secondary-hover-background);
+ border-color: var(--form-checkbox-radio-card-secondary-hover-border);
}
&:focus-visible {
@@ -320,33 +320,33 @@ tedi-date-picker {
&:disabled {
color: var(--form-checkbox-radio-card-secondary-disabled-default-text);
- border-color: var(
- --form-checkbox-radio-card-secondary-disabled-default-border
- );
+ cursor: not-allowed;
background: var(
--form-checkbox-radio-card-secondary-disabled-default-background
);
- cursor: not-allowed;
+ border-color: var(
+ --form-checkbox-radio-card-secondary-disabled-default-border
+ );
}
&--selected {
color: var(--form-checkbox-radio-card-secondary-selected-text);
+ background: var(--form-checkbox-radio-card-secondary-selected-background);
border-color: var(--form-checkbox-radio-card-secondary-selected-border);
box-shadow: 0 0 0 1px
var(--form-checkbox-radio-card-secondary-selected-border);
- background: var(--form-checkbox-radio-card-secondary-selected-background);
&:disabled {
color: var(--form-checkbox-radio-card-secondary-disabled-selected-text);
+ cursor: not-allowed;
+ background: var(
+ --form-checkbox-radio-card-secondary-disabled-selected-background
+ );
border-color: var(
--form-checkbox-radio-card-secondary-disabled-selected-border
);
box-shadow: 0 0 0 1px
var(--form-checkbox-radio-card-secondary-disabled-selected-border);
- background: var(
- --form-checkbox-radio-card-secondary-disabled-selected-background
- );
- cursor: not-allowed;
}
}
}
diff --git a/tedi/components/form/date-picker/date-picker.component.spec.ts b/tedi/components/form/date-picker/date-picker.component.spec.ts
index f7230cf57..996ed7c65 100644
--- a/tedi/components/form/date-picker/date-picker.component.spec.ts
+++ b/tedi/components/form/date-picker/date-picker.component.spec.ts
@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { DatePickerComponent } from "./date-picker.component";
import { TediTranslationService } from "../../../services/translation/translation.service";
import { NgxFloatUiContentComponent } from "ngx-float-ui";
-import { ElementRef } from "@angular/core";
+import { DatePickerCalendarGridComponent } from "./date-picker-calendar-grid/date-picker-calendar-grid.component";
class TranslationMock {
track(key: string) {
@@ -319,31 +319,19 @@ describe("DatePickerComponent", () => {
it("focusDate should focus the correct button after timeout", () => {
jest.useFakeTimers();
- const mockContainer = document.createElement("div");
const date = new Date(2024, 4, 20);
- const key = new Date(
- date.getFullYear(),
- date.getMonth(),
- date.getDate(),
- ).getTime();
-
- const fakeBtn = document.createElement("button");
- fakeBtn.setAttribute("data-date-key", String(key));
-
- const focusSpy = jest.spyOn(fakeBtn, "focus");
+ const mockFocusDate = jest.fn();
- mockContainer.appendChild(fakeBtn);
-
- jest.spyOn(component, "gridElement").mockReturnValue({
- nativeElement: mockContainer,
- } as unknown as ElementRef
);
+ jest.spyOn(component, "calendarGrid").mockReturnValue({
+ focusDate: mockFocusDate,
+ } as unknown as DatePickerCalendarGridComponent);
component["focusDate"](date);
jest.runAllTimers();
jest.useRealTimers();
- expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
+ expect(mockFocusDate).toHaveBeenCalledWith(date);
});
describe("Year page navigation", () => {
@@ -476,38 +464,6 @@ describe("DatePickerComponent", () => {
});
});
- describe("parseDate", () => {
- function parse(str: string) {
- return component["parseDate"](str);
- }
-
- it("should return null for formats not split into 3 parts", () => {
- expect(parse("")).toBeNull();
- expect(parse("12.05")).toBeNull();
- expect(parse("12-05-2024")).toBeNull();
- expect(parse("12/05/2024")).toBeNull();
- });
-
- it("should return null when day, month, or year are not valid numbers", () => {
- expect(parse("aa.bb.cccc")).toBeNull();
- expect(parse("1..2024")).toBeNull();
- expect(parse(".02.2024")).toBeNull();
- expect(parse("15.NaN.2024")).toBeNull();
- });
-
- it("should return null for impossible dates after constructing Date", () => {
- expect(parse("31.02.2024")).toBeNull();
- expect(parse("10.13.2024")).toBeNull();
- expect(parse("00.12.2024")).toBeNull();
- expect(parse("10.00.2024")).toBeNull();
- });
-
- it("should return a valid Date for correct input", () => {
- const result = parse("10.03.2024");
- expect(result).toEqual(new Date(2024, 2, 10));
- });
- });
-
describe("matches()", () => {
type PrivateMatchesAPI = {
matches: (m: unknown, date: Date) => boolean;
@@ -657,4 +613,621 @@ describe("DatePickerComponent", () => {
expect(result).toEqual(new Date(2024, 1, 1));
});
});
+
+ describe("Disabled date matchers ignore time components", () => {
+ it("before matcher should not disable the date itself", () => {
+ const cutoffDate = new Date(2024, 4, 15, 12, 30, 0);
+ fixture.componentRef.setInput("disabled", { before: cutoffDate });
+ fixture.detectChanges();
+
+ const cutoffAtMidnight = new Date(2024, 4, 15, 0, 0, 0);
+ const dayBefore = new Date(2024, 4, 14);
+
+ expect(component.isDisabled(cutoffAtMidnight)).toBe(false);
+ expect(component.isDisabled(dayBefore)).toBe(true);
+ });
+
+ it("after matcher should not disable the date itself", () => {
+ const cutoffDate = new Date(2024, 4, 15, 12, 30, 0);
+ fixture.componentRef.setInput("disabled", { after: cutoffDate });
+ fixture.detectChanges();
+
+ const cutoffAtMidnight = new Date(2024, 4, 15, 0, 0, 0);
+ const dayAfter = new Date(2024, 4, 16);
+
+ expect(component.isDisabled(cutoffAtMidnight)).toBe(false);
+ expect(component.isDisabled(dayAfter)).toBe(true);
+ });
+ });
+
+ describe("Disabled months and years", () => {
+ it("disabledMonths should contain months with no enabled days", () => {
+ fixture.componentRef.setInput("disabled", [
+ { before: new Date(2024, 2, 1) },
+ { after: new Date(2024, 4, 31) },
+ ]);
+ component.month.set(new Date(2024, 3, 1));
+ fixture.detectChanges();
+
+ const disabled = component.disabledMonths();
+
+ expect(disabled.has(0)).toBe(true);
+ expect(disabled.has(1)).toBe(true);
+ expect(disabled.has(2)).toBe(false);
+ expect(disabled.has(3)).toBe(false);
+ expect(disabled.has(4)).toBe(false);
+ expect(disabled.has(5)).toBe(true);
+ });
+
+ it("disabledYears should contain years with no enabled days", () => {
+ fixture.componentRef.setInput("startYear", 2022);
+ fixture.componentRef.setInput("endYear", 2026);
+ fixture.componentRef.setInput("disabled", [
+ { before: new Date(2024, 0, 1) },
+ { after: new Date(2024, 11, 31) },
+ ]);
+ fixture.detectChanges();
+
+ const disabled = component.disabledYears();
+
+ expect(disabled.has(2022)).toBe(true);
+ expect(disabled.has(2023)).toBe(true);
+ expect(disabled.has(2024)).toBe(false);
+ expect(disabled.has(2025)).toBe(true);
+ expect(disabled.has(2026)).toBe(true);
+ });
+ });
+
+ describe("Navigation with disabled months", () => {
+ beforeEach(() => {
+ fixture.componentRef.setInput("startYear", 2024);
+ fixture.componentRef.setInput("endYear", 2024);
+ fixture.componentRef.setInput("disabled", [
+ { before: new Date(2024, 3, 1) },
+ { after: new Date(2024, 8, 30) },
+ ]);
+ component.month.set(new Date(2024, 5, 1));
+ fixture.detectChanges();
+ });
+
+ it("canGoPrev should be true when there is an enabled month before", () => {
+ expect(component.canGoPrev()).toBe(true);
+ });
+
+ it("canGoNext should be true when there is an enabled month after", () => {
+ expect(component.canGoNext()).toBe(true);
+ });
+
+ it("prevMonth should skip disabled months", () => {
+ component.month.set(new Date(2024, 5, 1));
+ fixture.detectChanges();
+
+ component.prevMonth();
+
+ expect(component.month().getMonth()).toBe(4);
+ });
+
+ it("nextMonth should skip disabled months", () => {
+ component.month.set(new Date(2024, 5, 1));
+ fixture.detectChanges();
+
+ component.nextMonth();
+
+ expect(component.month().getMonth()).toBe(6);
+ });
+
+ it("canGoPrev should be false when at first enabled month", () => {
+ component.month.set(new Date(2024, 3, 1));
+ fixture.detectChanges();
+
+ expect(component.canGoPrev()).toBe(false);
+ });
+
+ it("canGoNext should be false when at last enabled month", () => {
+ component.month.set(new Date(2024, 8, 1));
+ fixture.detectChanges();
+
+ expect(component.canGoNext()).toBe(false);
+ });
+
+ it("prevMonth should not change month when canGoPrev is false", () => {
+ component.month.set(new Date(2024, 3, 1));
+ fixture.detectChanges();
+
+ component.prevMonth();
+
+ expect(component.month().getMonth()).toBe(3);
+ });
+
+ it("nextMonth should not change month when canGoNext is false", () => {
+ component.month.set(new Date(2024, 8, 1));
+ fixture.detectChanges();
+
+ component.nextMonth();
+
+ expect(component.month().getMonth()).toBe(8);
+ });
+ });
+
+ describe("Month and year select with disabled periods", () => {
+ beforeEach(() => {
+ fixture.componentRef.setInput("startYear", 2025);
+ fixture.componentRef.setInput("endYear", 2027);
+ fixture.componentRef.setInput("disabled", [
+ { before: new Date(2024, 5, 1) },
+ { after: new Date(2024, 7, 31) },
+ ]);
+ component.month.set(new Date(2024, 6, 1));
+ fixture.detectChanges();
+ });
+
+ it("onMonthSelect should not navigate to fully disabled month", () => {
+ component.onMonthSelect("0");
+
+ expect(component.month().getMonth()).toBe(6);
+ });
+
+ it("onMonthSelect should navigate to enabled month", () => {
+ component.onMonthSelect("7");
+
+ expect(component.month().getMonth()).toBe(7);
+ });
+
+ it("onYearSelect should not navigate to fully disabled year", () => {
+ component.onYearSelect("2025");
+
+ expect(component.month().getFullYear()).toBe(2024);
+ });
+
+ it("onYearSelect should navigate to enabled year", () => {
+ fixture.componentRef.setInput("disabled", null);
+ fixture.detectChanges();
+
+ component.onYearSelect("2027");
+
+ expect(component.month().getFullYear()).toBe(2027);
+ });
+
+ it("onYearSelect should find first enabled month if current month is disabled in new year", () => {
+ // Enable Jun-Aug in 2024, and Jan-Mar in 2027
+ fixture.componentRef.setInput("disabled", [
+ { before: new Date(2024, 5, 1) },
+ { after: new Date(2027, 2, 31) },
+ ]);
+ component.month.set(new Date(2024, 6, 1));
+ fixture.detectChanges();
+
+ component.onYearSelect("2027");
+
+ expect(component.month().getFullYear()).toBe(2027);
+ expect(component.month().getMonth()).toBeLessThanOrEqual(2);
+ });
+ });
+
+ describe("closeOnSelect behavior", () => {
+ it("should close popover after selection when closeOnSelect is true", () => {
+ const hideSpy = jest.spyOn(
+ component.popover().floatUiComponent(),
+ "hide",
+ );
+
+ component.selectDay({
+ date: new Date(2024, 4, 15),
+ disabled: false,
+ inCurrentMonth: true,
+ });
+
+ expect(hideSpy).toHaveBeenCalled();
+ });
+
+ it("should not close popover after selection when closeOnSelect is false", () => {
+ fixture.componentRef.setInput("closeOnSelect", false);
+ fixture.detectChanges();
+
+ const hideSpy = jest.spyOn(
+ component.popover().floatUiComponent(),
+ "hide",
+ );
+
+ component.selectDay({
+ date: new Date(2024, 4, 15),
+ disabled: false,
+ inCurrentMonth: true,
+ });
+
+ expect(hideSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("getTabIndex()", () => {
+ it("should return -1 when activeDate is null", () => {
+ component.activeDate.set(null);
+ fixture.detectChanges();
+
+ expect(component.getTabIndex(new Date(2024, 4, 15))).toBe(-1);
+ });
+
+ it("should return 0 when date matches activeDate", () => {
+ const date = new Date(2024, 4, 15);
+ component.activeDate.set(date);
+ fixture.detectChanges();
+
+ expect(component.getTabIndex(new Date(2024, 4, 15))).toBe(0);
+ });
+
+ it("should return -1 when date does not match activeDate", () => {
+ component.activeDate.set(new Date(2024, 4, 15));
+ fixture.detectChanges();
+
+ expect(component.getTabIndex(new Date(2024, 4, 16))).toBe(-1);
+ });
+ });
+
+ describe("isSelected()", () => {
+ it("should return false when no date is selected", () => {
+ component.selected.set(null);
+ fixture.detectChanges();
+
+ expect(component.isSelected(new Date(2024, 4, 15))).toBe(false);
+ });
+
+ it("should return true when date matches selected", () => {
+ component.selected.set(new Date(2024, 4, 15));
+ fixture.detectChanges();
+
+ expect(component.isSelected(new Date(2024, 4, 15))).toBe(true);
+ });
+
+ it("should return false when date does not match selected", () => {
+ component.selected.set(new Date(2024, 4, 15));
+ fixture.detectChanges();
+
+ expect(component.isSelected(new Date(2024, 4, 16))).toBe(false);
+ });
+ });
+
+ describe("isToday()", () => {
+ it("should return true when date is today", () => {
+ expect(component.isToday(component.today)).toBe(true);
+ });
+
+ it("should return false when date is not today", () => {
+ const notToday = new Date(2020, 0, 1);
+ expect(component.isToday(notToday)).toBe(false);
+ });
+ });
+
+ describe("handleFocusTrap()", () => {
+ it("should wrap focus to last element when shift+tab on first element", () => {
+ const mockContainer = document.createElement("div");
+ mockContainer.className = "tedi-date-picker__calendar";
+
+ const firstButton = document.createElement("button");
+ const lastButton = document.createElement("button");
+ mockContainer.appendChild(firstButton);
+ mockContainer.appendChild(lastButton);
+
+ document.body.appendChild(mockContainer);
+
+ Object.defineProperty(document, "activeElement", {
+ value: firstButton,
+ configurable: true,
+ });
+
+ const focusSpy = jest.spyOn(lastButton, "focus");
+
+ const event = new KeyboardEvent("keydown", {
+ key: "Tab",
+ shiftKey: true,
+ });
+ Object.defineProperty(event, "target", { value: firstButton });
+
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault");
+
+ component.onCalendarKeyDown(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ expect(focusSpy).toHaveBeenCalled();
+
+ document.body.removeChild(mockContainer);
+ });
+
+ it("should wrap focus to first element when tab on last element", () => {
+ const mockContainer = document.createElement("div");
+ mockContainer.className = "tedi-date-picker__calendar";
+
+ const firstButton = document.createElement("button");
+ const lastButton = document.createElement("button");
+ mockContainer.appendChild(firstButton);
+ mockContainer.appendChild(lastButton);
+
+ document.body.appendChild(mockContainer);
+
+ Object.defineProperty(document, "activeElement", {
+ value: lastButton,
+ configurable: true,
+ });
+
+ const focusSpy = jest.spyOn(firstButton, "focus");
+
+ const event = new KeyboardEvent("keydown", {
+ key: "Tab",
+ shiftKey: false,
+ });
+ Object.defineProperty(event, "target", { value: lastButton });
+
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault");
+
+ component.onCalendarKeyDown(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ expect(focusSpy).toHaveBeenCalled();
+
+ document.body.removeChild(mockContainer);
+ });
+
+ it("should not trap focus when container is not found", () => {
+ const mockElement = document.createElement("div");
+
+ const event = new KeyboardEvent("keydown", { key: "Tab" });
+ Object.defineProperty(event, "target", { value: mockElement });
+
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault");
+
+ component.onCalendarKeyDown(event);
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled();
+ });
+
+ it("should not trap focus when no focusable elements exist", () => {
+ const mockContainer = document.createElement("div");
+ mockContainer.className = "tedi-date-picker__calendar";
+
+ document.body.appendChild(mockContainer);
+
+ const event = new KeyboardEvent("keydown", { key: "Tab" });
+ Object.defineProperty(event, "target", { value: mockContainer });
+
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault");
+
+ component.onCalendarKeyDown(event);
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled();
+
+ document.body.removeChild(mockContainer);
+ });
+ });
+
+ describe("openCalendar with disabled dates", () => {
+ it("should find first enabled date when selected is null and today is disabled", () => {
+ jest.useFakeTimers();
+
+ fixture.componentRef.setInput("disabled", { before: new Date(2030, 0, 1) });
+ component.selected.set(null);
+ component.month.set(new Date(2024, 4, 1));
+ fixture.detectChanges();
+
+ component.openCalendar();
+
+ jest.runAllTimers();
+ jest.useRealTimers();
+
+ expect(component.activeDate()).toBeTruthy();
+ });
+
+ it("should use selected date when it is not disabled", () => {
+ jest.useFakeTimers();
+
+ const selected = new Date(2024, 4, 15);
+ component.selected.set(selected);
+ fixture.detectChanges();
+
+ component.openCalendar();
+
+ jest.runAllTimers();
+ jest.useRealTimers();
+
+ expect(component.activeDate()).toEqual(selected);
+ });
+ });
+
+ describe("ngOnInit with disabled initial date", () => {
+ it("should find first enabled date when initial active date is disabled", () => {
+ const newFixture = TestBed.createComponent(DatePickerComponent);
+ const newComponent = newFixture.componentInstance;
+
+ const mockFloatUiElement = document.createElement("div");
+ const mockContainer = document.createElement("div");
+ mockContainer.className = "float-ui-container-popover";
+ mockFloatUiElement.appendChild(mockContainer);
+
+ jest.spyOn(newComponent.popover(), "floatUiComponent").mockReturnValue({
+ state: false,
+ show: jest.fn(),
+ hide: jest.fn(),
+ elRef: {
+ nativeElement: mockFloatUiElement,
+ },
+ } as unknown as NgxFloatUiContentComponent);
+
+ newFixture.componentRef.setInput("disabled", {
+ before: new Date(2030, 0, 1),
+ });
+
+ newFixture.detectChanges();
+
+ expect(newComponent.activeDate()).toBeTruthy();
+ });
+ });
+
+ describe("onMonthSelect and onYearSelect edge cases", () => {
+ it("onMonthSelect should return early when value is undefined", () => {
+ const initialMonth = component.month().getMonth();
+
+ component.onMonthSelect(undefined);
+
+ expect(component.month().getMonth()).toBe(initialMonth);
+ });
+
+ it("onMonthSelect should return early when value is null", () => {
+ const initialMonth = component.month().getMonth();
+
+ component.onMonthSelect(null as unknown as string);
+
+ expect(component.month().getMonth()).toBe(initialMonth);
+ });
+
+ it("onYearSelect should return early when value is undefined", () => {
+ const initialYear = component.month().getFullYear();
+
+ component.onYearSelect(undefined);
+
+ expect(component.month().getFullYear()).toBe(initialYear);
+ });
+
+ it("onYearSelect should return early when value is null", () => {
+ const initialYear = component.month().getFullYear();
+
+ component.onYearSelect(null as unknown as string);
+
+ expect(component.month().getFullYear()).toBe(initialYear);
+ });
+ });
+
+ describe("onInput with allowManualInput=false", () => {
+ it("should not update inputValue when allowManualInput is false", () => {
+ fixture.componentRef.setInput("allowManualInput", false);
+ fixture.detectChanges();
+
+ const initialValue = component.inputValue();
+ const input = el.querySelector("input") as HTMLInputElement;
+ input.value = "15.04.2024";
+
+ input.dispatchEvent(new Event("input"));
+ fixture.detectChanges();
+
+ expect(component.inputValue()).toBe(initialValue);
+ });
+ });
+
+ describe("onInputBlur with allowManualInput=false", () => {
+ it("should not process blur when allowManualInput is false", () => {
+ fixture.componentRef.setInput("allowManualInput", false);
+ component.selected.set(new Date(2024, 0, 1));
+ component.inputValue.set("01.01.2024");
+ fixture.detectChanges();
+
+ const input = el.querySelector("input") as HTMLInputElement;
+ input.value = "invalid";
+
+ input.dispatchEvent(new Event("blur"));
+ fixture.detectChanges();
+
+ expect(component.inputValue()).toBe("01.01.2024");
+ });
+ });
+
+ describe("findNextEnabledDate edge cases", () => {
+ it("should return null when all dates are disabled within max iterations", () => {
+ fixture.componentRef.setInput("disabled", () => true);
+ fixture.detectChanges();
+
+ type FindNextEnabledDateType = {
+ findNextEnabledDate: (from: Date, step: number) => Date | null;
+ };
+
+ const result = (
+ component as unknown as FindNextEnabledDateType
+ ).findNextEnabledDate(new Date(2024, 4, 15), 1);
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("updateActiveDateForMonth edge cases", () => {
+ it("should use selected date when it matches the month and is not disabled", () => {
+ const selected = new Date(2024, 4, 15);
+ component.selected.set(selected);
+ fixture.detectChanges();
+
+ type UpdateActiveDateType = {
+ updateActiveDateForMonth: (year: number, month: number) => void;
+ };
+
+ (component as unknown as UpdateActiveDateType).updateActiveDateForMonth(
+ 2024,
+ 4,
+ );
+
+ expect(component.activeDate()).toEqual(selected);
+ });
+
+ it("should find first enabled date when selected is in different month", () => {
+ component.selected.set(new Date(2024, 3, 15));
+ fixture.detectChanges();
+
+ type UpdateActiveDateType = {
+ updateActiveDateForMonth: (year: number, month: number) => void;
+ };
+
+ (component as unknown as UpdateActiveDateType).updateActiveDateForMonth(
+ 2024,
+ 4,
+ );
+
+ expect(component.activeDate()?.getMonth()).toBe(4);
+ });
+
+ it("should find first enabled date when selected is disabled", () => {
+ const selected = new Date(2024, 4, 15);
+ component.selected.set(selected);
+ fixture.componentRef.setInput("disabled", selected);
+ fixture.detectChanges();
+
+ type UpdateActiveDateType = {
+ updateActiveDateForMonth: (year: number, month: number) => void;
+ };
+
+ (component as unknown as UpdateActiveDateType).updateActiveDateForMonth(
+ 2024,
+ 4,
+ );
+
+ expect(component.activeDate()).not.toEqual(selected);
+ });
+ });
+
+ describe("Escape key in year-grid view", () => {
+ it("should return to calendar-grid from year-grid on Escape", () => {
+ jest.useFakeTimers();
+
+ component.currentView.set("year-grid");
+ fixture.detectChanges();
+
+ const event = new KeyboardEvent("keydown", { key: "Escape" });
+ component.onCalendarKeyDown(event);
+
+ jest.runAllTimers();
+ jest.useRealTimers();
+
+ expect(component.currentView()).toBe("calendar-grid");
+ });
+ });
+
+ describe("focusDate when calendarGrid is undefined", () => {
+ it("should not throw when calendarGrid returns undefined", () => {
+ jest.useFakeTimers();
+
+ jest.spyOn(component, "calendarGrid").mockReturnValue(undefined);
+
+ const date = new Date(2024, 4, 15);
+
+ expect(() => {
+ component["focusDate"](date);
+ jest.runAllTimers();
+ }).not.toThrow();
+
+ jest.useRealTimers();
+ });
+ });
});
diff --git a/tedi/components/form/date-picker/date-picker.component.ts b/tedi/components/form/date-picker/date-picker.component.ts
index a569aac3d..3332a9824 100644
--- a/tedi/components/form/date-picker/date-picker.component.ts
+++ b/tedi/components/form/date-picker/date-picker.component.ts
@@ -15,14 +15,15 @@ import { ButtonComponent } from "../../buttons/button/button.component";
import { ClosingButtonComponent } from "../../buttons/closing-button/closing-button.component";
import { IconComponent } from "../../base/icon/icon.component";
import { TediTranslationService } from "../../../services/translation/translation.service";
-import { DropdownComponent } from "../../overlay/dropdown/dropdown.component";
-import { DropdownTriggerDirective } from "../../overlay/dropdown/dropdown-trigger/dropdown-trigger.directive";
-import { DropdownContentComponent } from "../../overlay/dropdown/dropdown-content/dropdown-content.component";
-import { DropdownItemComponent } from "../../overlay/dropdown/dropdown-item/dropdown-item.component";
import { SeparatorComponent } from "../../helpers/separator/separator.component";
import { PopoverComponent } from "../../overlay/popover/popover.component";
import { PopoverContentComponent } from "../../overlay/popover/popover-content/popover-content.component";
import { PopoverTriggerDirective } from "../../overlay/popover/popover-trigger/popover-trigger.directive";
+import { DatePickerHeaderComponent } from "./date-picker-header/date-picker-header.component";
+import { DatePickerCalendarGridComponent } from "./date-picker-calendar-grid/date-picker-calendar-grid.component";
+import { DatePickerMonthGridComponent } from "./date-picker-month-grid/date-picker-month-grid.component";
+import { DatePickerYearGridComponent } from "./date-picker-year-grid/date-picker-year-grid.component";
+import { formatDate, parseDate, isSameDay, isBeforeDay, isAfterDay, getISOWeek } from "../../../utils/date.util";
export interface DatePickerDay {
date: Date;
@@ -55,19 +56,19 @@ let datePickerId = 0;
imports: [
ButtonComponent,
IconComponent,
- DropdownComponent,
- DropdownTriggerDirective,
- DropdownContentComponent,
- DropdownItemComponent,
ClosingButtonComponent,
SeparatorComponent,
PopoverComponent,
PopoverTriggerDirective,
PopoverContentComponent,
+ DatePickerHeaderComponent,
+ DatePickerCalendarGridComponent,
+ DatePickerMonthGridComponent,
+ DatePickerYearGridComponent,
],
})
export class DatePickerComponent implements OnInit {
- private readonly today = new Date();
+ readonly today = new Date();
readonly uniqueId = `tedi-date-picker-id-${datePickerId++}`;
/** Selected date */
@@ -117,6 +118,9 @@ export class DatePickerComponent implements OnInit {
/** Should show week numbers before calendar grid? */
readonly showWeekNumbers = input(false);
+ /** Close calendar popover after date selection, default true */
+ readonly closeOnSelect = input(true);
+
/** Current view of datepicker (months grid, years grid or calendar grid) */
readonly currentView = signal("calendar-grid");
@@ -159,6 +163,31 @@ export class DatePickerComponent implements OnInit {
return (this.yearPageIndex() + 1) * this.YEARS_PER_PAGE < all;
});
+ readonly disabledMonths = computed(() => {
+ const year = this.month().getFullYear();
+ const disabled = new Set();
+
+ for (let month = 0; month < 12; month++) {
+ if (this.getFirstEnabledDayOfMonth(year, month) === null) {
+ disabled.add(month);
+ }
+ }
+
+ return disabled;
+ });
+
+ readonly disabledYears = computed(() => {
+ const disabled = new Set();
+
+ for (const year of this.years()) {
+ if (this.isYearDisabled(year)) {
+ disabled.add(year);
+ }
+ }
+
+ return disabled;
+ });
+
readonly weekRows = computed(() => {
const cells = this.days();
const rows: DatePickerDay[][] = [];
@@ -171,31 +200,15 @@ export class DatePickerComponent implements OnInit {
});
readonly weekNumbers = computed(() => {
- return this.weekRows().map((week) => this.getISOWeek(week[0].date));
+ return this.weekRows().map((week) => getISOWeek(week[0].date));
});
readonly canGoPrev = computed(() => {
- const current = this.month();
- const year = current.getFullYear();
- const month = current.getMonth();
-
- const prevMonth = month - 1;
- const prevYear = prevMonth < 0 ? year - 1 : year;
- const finalPrevMonth = (prevMonth + 12) % 12;
-
- return this.getFirstEnabledDayOfMonth(prevYear, finalPrevMonth) !== null;
+ return this.findPrevEnabledMonth() !== null;
});
readonly canGoNext = computed(() => {
- const current = this.month();
- const year = current.getFullYear();
- const month = current.getMonth();
-
- const nextMonth = month + 1;
- const nextYear = nextMonth > 11 ? year + 1 : year;
- const finalNextMonth = nextMonth % 12;
-
- return this.getFirstEnabledDayOfMonth(nextYear, finalNextMonth) !== null;
+ return this.findNextEnabledMonth() !== null;
});
readonly days = computed(() => {
@@ -254,56 +267,29 @@ export class DatePickerComponent implements OnInit {
readonly inputElement =
viewChild.required>("inputElement");
- readonly gridElement =
- viewChild.required>("gridElement");
+ readonly calendarGrid = viewChild("gridElement");
readonly popover = viewChild.required(PopoverComponent);
readonly translationService = inject(TediTranslationService);
- readonly weekDays = [
- this.translationService.track("date-picker.monday-short"),
- this.translationService.track("date-picker.tuesday-short"),
- this.translationService.track("date-picker.wednesday-short"),
- this.translationService.track("date-picker.thursday-short"),
- this.translationService.track("date-picker.friday-short"),
- this.translationService.track("date-picker.saturday-short"),
- this.translationService.track("date-picker.sunday-short"),
- ];
-
- readonly monthShortNames = [
- this.translationService.track("date-picker.january-short"),
- this.translationService.track("date-picker.february-short"),
- this.translationService.track("date-picker.march-short"),
- this.translationService.track("date-picker.april-short"),
- this.translationService.track("date-picker.may-short"),
- this.translationService.track("date-picker.june-short"),
- this.translationService.track("date-picker.july-short"),
- this.translationService.track("date-picker.august-short"),
- this.translationService.track("date-picker.september-short"),
- this.translationService.track("date-picker.october-short"),
- this.translationService.track("date-picker.november-short"),
- this.translationService.track("date-picker.december-short"),
- ];
-
- readonly monthNames = [
- this.translationService.track("date-picker.january"),
- this.translationService.track("date-picker.february"),
- this.translationService.track("date-picker.march"),
- this.translationService.track("date-picker.april"),
- this.translationService.track("date-picker.may"),
- this.translationService.track("date-picker.june"),
- this.translationService.track("date-picker.july"),
- this.translationService.track("date-picker.august"),
- this.translationService.track("date-picker.september"),
- this.translationService.track("date-picker.october"),
- this.translationService.track("date-picker.november"),
- this.translationService.track("date-picker.december"),
- ];
-
ngOnInit(): void {
const selected = this.selected();
- this.inputValue.set(selected ? this.format(selected) : "");
- this.activeDate.set(selected ?? this.today);
+ this.inputValue.set(selected ? formatDate(selected) : "");
+
+ let active = selected ?? this.today;
+
+ // If the initial active date is disabled, find the first enabled date
+ if (this.isDisabled(active)) {
+ const firstEnabled = this.getFirstEnabledDayOfMonth(
+ active.getFullYear(),
+ active.getMonth(),
+ );
+ if (firstEnabled) {
+ active = firstEnabled;
+ }
+ }
+
+ this.activeDate.set(active);
}
getTabIndex(date: Date): number {
@@ -312,15 +298,19 @@ export class DatePickerComponent implements OnInit {
}
prevMonth() {
- const date = new Date(this.month());
- date.setMonth(date.getMonth() - 1);
- this.month.set(date);
+ const prev = this.findPrevEnabledMonth();
+ if (prev) {
+ this.month.set(prev);
+ this.updateActiveDateForMonth(prev.getFullYear(), prev.getMonth());
+ }
}
nextMonth() {
- const date = new Date(this.month());
- date.setMonth(date.getMonth() + 1);
- this.month.set(date);
+ const next = this.findNextEnabledMonth();
+ if (next) {
+ this.month.set(next);
+ this.updateActiveDateForMonth(next.getFullYear(), next.getMonth());
+ }
}
prevYearPage() {
@@ -339,7 +329,11 @@ export class DatePickerComponent implements OnInit {
if (day.disabled) return;
this.selected.set(day.date);
- this.inputValue.set(this.format(day.date));
+ this.inputValue.set(formatDate(day.date));
+
+ if (this.closeOnSelect()) {
+ this.closeCalendar();
+ }
}
isSelected(date: Date): boolean {
@@ -365,12 +359,20 @@ export class DatePickerComponent implements OnInit {
this.currentView.set("month-grid");
}
- onMonthSelect(index?: string) {
- if (!index) return;
+ onMonthSelect(value?: string | number) {
+ if (value === undefined || value === null) return;
+
+ const monthIndex = typeof value === "number" ? value : Number(value);
+ const year = this.month().getFullYear();
+
+ if (this.getFirstEnabledDayOfMonth(year, monthIndex) === null) {
+ return;
+ }
const updated = new Date(this.month());
- updated.setMonth(Number(index));
+ updated.setMonth(monthIndex);
this.month.set(updated);
+ this.updateActiveDateForMonth(year, monthIndex);
if (this.currentView() === "month-grid") {
this.currentView.set("calendar-grid");
@@ -384,10 +386,32 @@ export class DatePickerComponent implements OnInit {
this.currentView.set("year-grid");
}
- onYearSelect(index?: string) {
+ onYearSelect(value?: string | number) {
+ if (value === undefined || value === null) return;
+
+ const year = typeof value === "number" ? value : Number(value);
+
+ if (this.isYearDisabled(year)) {
+ return;
+ }
+
const updated = new Date(this.month());
- updated.setFullYear(Number(index));
+ updated.setFullYear(year);
+
+ // If the current month is disabled in the new year, find the first enabled month
+ let targetMonth = updated.getMonth();
+ if (this.getFirstEnabledDayOfMonth(year, targetMonth) === null) {
+ for (let month = 0; month < 12; month++) {
+ if (this.getFirstEnabledDayOfMonth(year, month) !== null) {
+ targetMonth = month;
+ updated.setMonth(month);
+ break;
+ }
+ }
+ }
+
this.month.set(updated);
+ this.updateActiveDateForMonth(year, targetMonth);
if (this.currentView() === "year-grid") {
this.currentView.set("calendar-grid");
@@ -395,84 +419,113 @@ export class DatePickerComponent implements OnInit {
}
onDayKeydown(event: KeyboardEvent, current: Date) {
- let target: Date | null = null;
-
- switch (event.key) {
- case "ArrowLeft":
- target = new Date(current);
- target.setDate(current.getDate() - 1);
- break;
-
- case "ArrowRight":
- target = new Date(current);
- target.setDate(current.getDate() + 1);
- break;
-
- case "ArrowUp":
- target = new Date(current);
- target.setDate(current.getDate() - 7);
- break;
-
- case "ArrowDown":
- target = new Date(current);
- target.setDate(current.getDate() + 7);
- break;
-
- case "Home":
- target = new Date(current);
- target.setDate(current.getDate() - ((current.getDay() + 6) % 7));
- break;
-
- case "End":
- target = new Date(current);
- target.setDate(current.getDate() + (6 - ((current.getDay() + 6) % 7)));
- break;
-
- case "PageUp":
- target = new Date(current);
- target.setMonth(current.getMonth() - 1);
- break;
-
- case "PageDown":
- target = new Date(current);
- target.setMonth(current.getMonth() + 1);
- break;
-
- case "Enter":
- case " ":
- event.preventDefault();
- this.selectDay({
- date: current,
- disabled: false,
- inCurrentMonth: true,
- });
- return;
-
- case "Escape":
- this.popover().floatUiComponent().hide();
- this.inputElement().nativeElement.focus();
- return;
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ this.selectDay({ date: current, disabled: false, inCurrentMonth: true });
+ return;
+ }
- default:
- return;
+ if (event.key === "Escape") {
+ this.closeCalendar();
+ return;
}
+ const target = this.handleDayNavigation(event.key, current);
if (target) {
event.preventDefault();
this.focusDate(target);
}
}
- onCalendarKeyDown(event: KeyboardEvent) {
- if (this.currentView() === "calendar-grid") return;
+ private handleDayNavigation(key: string, current: Date): Date | null {
+ const arrowSteps: Record = {
+ ArrowLeft: -1,
+ ArrowRight: 1,
+ ArrowUp: -7,
+ ArrowDown: 7,
+ };
- if (event.key === "Escape") {
+ if (key in arrowSteps) {
+ return this.findNextEnabledDate(current, arrowSteps[key]);
+ }
+
+ if (key === "Home") {
+ const target = new Date(current);
+ target.setDate(current.getDate() - ((current.getDay() + 6) % 7));
+ return this.isDisabled(target)
+ ? this.findNextEnabledDate(target, 1)
+ : target;
+ }
+
+ if (key === "End") {
+ const target = new Date(current);
+ target.setDate(current.getDate() + (6 - ((current.getDay() + 6) % 7)));
+ return this.isDisabled(target)
+ ? this.findNextEnabledDate(target, -1)
+ : target;
+ }
+
+ if (key === "PageUp") {
+ const target = new Date(current);
+ target.setMonth(current.getMonth() - 1);
+ return this.isDisabled(target)
+ ? (this.findNextEnabledDate(target, 1) ??
+ this.findNextEnabledDate(target, -1))
+ : target;
+ }
+
+ if (key === "PageDown") {
+ const target = new Date(current);
+ target.setMonth(current.getMonth() + 1);
+ return this.isDisabled(target)
+ ? (this.findNextEnabledDate(target, -1) ??
+ this.findNextEnabledDate(target, 1))
+ : target;
+ }
+
+ return null;
+ }
+
+ onCalendarKeyDown(event: KeyboardEvent) {
+ if (this.currentView() !== "calendar-grid" && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.currentView.set("calendar-grid");
const active = this.selected() ?? this.today;
setTimeout(() => this.focusDate(active));
+ return;
+ }
+
+ if (event.key === "Tab") {
+ this.handleFocusTrap(event);
+ }
+ }
+
+ private handleFocusTrap(event: KeyboardEvent) {
+ const container = (event.target as HTMLElement).closest(
+ ".tedi-date-picker__calendar",
+ );
+ if (!container) return;
+
+ const focusableSelector = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]:not([disabled])';
+ const focusableElements = Array.from(
+ container.querySelectorAll(focusableSelector),
+ );
+
+ if (!focusableElements.length) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ if (event.shiftKey) {
+ if (document.activeElement === firstElement) {
+ event.preventDefault();
+ lastElement.focus();
+ }
+ } else if (document.activeElement === lastElement) {
+ event.preventDefault();
+ firstElement.focus();
}
}
@@ -491,13 +544,13 @@ export class DatePickerComponent implements OnInit {
if (!this.allowManualInput()) return;
const selected = this.selected();
- const parsed = this.parseDate(this.inputValue());
+ const parsed = parseDate(this.inputValue());
if (parsed) {
this.selected.set(parsed);
this.month.set(parsed);
} else {
- this.inputValue.set(selected ? this.format(selected) : "");
+ this.inputValue.set(selected ? formatDate(selected) : "");
}
}
@@ -518,8 +571,26 @@ export class DatePickerComponent implements OnInit {
this.selected.set(null);
}
+ closeCalendar() {
+ this.popover().floatUiComponent().hide();
+ this.inputElement().nativeElement.focus();
+ }
+
openCalendar() {
- const active = this.selected() ?? this.today;
+ let active = this.selected() ?? this.today;
+
+ // If active date is disabled, find the first enabled date
+ if (this.isDisabled(active)) {
+ const currentMonth = this.month();
+ const firstEnabled = this.getFirstEnabledDayOfMonth(
+ currentMonth.getFullYear(),
+ currentMonth.getMonth(),
+ );
+ if (firstEnabled) {
+ active = firstEnabled;
+ }
+ }
+
this.activeDate.set(active);
setTimeout(() => this.focusDate(active));
}
@@ -536,67 +607,17 @@ export class DatePickerComponent implements OnInit {
}
setTimeout(() => {
- const container = this.gridElement().nativeElement;
- if (!container) return;
-
- const key = new Date(
- date.getFullYear(),
- date.getMonth(),
- date.getDate(),
- ).getTime();
-
- const btn = container.querySelector(
- `[data-date-key="${key}"]`,
- );
-
- if (btn && document.activeElement !== btn) {
- btn.focus({ preventScroll: true });
- }
+ this.calendarGrid()?.focusDate(date);
});
}
- private parseDate(str: string): Date | null {
- const parts = str.trim().split(".");
- if (parts.length !== 3) return null;
-
- const [dd, mm, yyyy] = parts.map(Number);
- if (!dd || !mm || !yyyy) return null;
-
- const date = new Date(yyyy, mm - 1, dd);
-
- if (
- date.getFullYear() !== yyyy ||
- date.getMonth() !== mm - 1 ||
- date.getDate() !== dd
- ) {
- return null;
- }
-
- return date;
- }
-
- private format(date: Date): string {
- const d = String(date.getDate()).padStart(2, "0");
- const m = String(date.getMonth() + 1).padStart(2, "0");
- const y = date.getFullYear();
- return `${d}.${m}.${y}`;
- }
-
- private isSameDay(a: Date, b: Date): boolean {
- return (
- a.getDate() === b.getDate() &&
- a.getMonth() === b.getMonth() &&
- a.getFullYear() === b.getFullYear()
- );
- }
-
private matches(m: DatePickerMatcher, date: Date): boolean {
if (m instanceof Date) {
- return this.isSameDay(m, date);
+ return isSameDay(m, date);
}
if (Array.isArray(m)) {
- return m.some((d) => this.isSameDay(d, date));
+ return m.some((d) => isSameDay(d, date));
}
if (typeof m === "function") {
@@ -604,23 +625,50 @@ export class DatePickerComponent implements OnInit {
}
if ("before" in m) {
- return date < m.before;
+ return isBeforeDay(date, m.before);
}
if ("after" in m) {
- return date > m.after;
+ return isAfterDay(date, m.after);
}
if ("from" in m) {
const { from, to } = m;
- if (to) return date >= from && date <= to;
+ if (to) {
+ return (isSameDay(date, from) || isAfterDay(date, from)) &&
+ (isSameDay(date, to) || isBeforeDay(date, to));
+ }
- return date >= from;
+ return isSameDay(date, from) || isAfterDay(date, from);
}
return false;
}
+ /**
+ * Updates activeDate to the selected date if it's in the given month,
+ * otherwise to the first enabled date in the month.
+ * Needed for correct WCAG focus handling
+ */
+ private updateActiveDateForMonth(year: number, month: number) {
+ const selected = this.selected();
+
+ if (
+ selected &&
+ selected.getFullYear() === year &&
+ selected.getMonth() === month &&
+ !this.isDisabled(selected)
+ ) {
+ this.activeDate.set(selected);
+ return;
+ }
+
+ const firstEnabled = this.getFirstEnabledDayOfMonth(year, month);
+ if (firstEnabled) {
+ this.activeDate.set(firstEnabled);
+ }
+ }
+
private getFirstEnabledDayOfMonth(year: number, month: number): Date | null {
const daysInMonth = new Date(year, month + 1, 0).getDate();
@@ -635,19 +683,85 @@ export class DatePickerComponent implements OnInit {
return null;
}
- private getISOWeek(date: Date): number {
- const target = new Date(date);
- target.setHours(0, 0, 0, 0);
+ private isYearDisabled(year: number): boolean {
+ for (let month = 0; month < 12; month++) {
+ if (this.getFirstEnabledDayOfMonth(year, month) !== null) {
+ return false;
+ }
+ }
- const day = target.getDay();
- const isoDay = day === 0 ? 7 : day;
+ return true;
+ }
- target.setDate(target.getDate() + (4 - isoDay));
- const yearStart = new Date(target.getFullYear(), 0, 1);
+ private findPrevEnabledMonth(): Date | null {
+ const current = this.month();
+ const years = this.years();
+ const minYear = years.length > 0 ? years[0] : current.getFullYear() - 100;
- const diffInDays = Math.floor(
- (target.getTime() - yearStart.getTime()) / 86400000,
- );
- return Math.floor(diffInDays / 7) + 1;
+ let year = current.getFullYear();
+ let month = current.getMonth() - 1;
+
+ while (year >= minYear) {
+ if (month < 0) {
+ month = 11;
+ year--;
+ }
+
+ if (year < minYear) break;
+
+ if (this.getFirstEnabledDayOfMonth(year, month) !== null) {
+ return new Date(year, month, 1);
+ }
+
+ month--;
+ }
+
+ return null;
+ }
+
+ private findNextEnabledMonth(): Date | null {
+ const current = this.month();
+ const years = this.years();
+ const maxYear = years.length > 0 ? years[years.length - 1] : current.getFullYear() + 20;
+
+ let year = current.getFullYear();
+ let month = current.getMonth() + 1;
+
+ while (year <= maxYear) {
+ if (month > 11) {
+ month = 0;
+ year++;
+ }
+
+ if (year > maxYear) break;
+
+ if (this.getFirstEnabledDayOfMonth(year, month) !== null) {
+ return new Date(year, month, 1);
+ }
+
+ month++;
+ }
+
+ return null;
+ }
+
+ /**
+ * Finds the next enabled date starting from the given date, moving by the given step.
+ * Returns null if no enabled date is found within a reasonable range.
+ * Needed for correct WCAG arrow movement handling
+ */
+ private findNextEnabledDate(from: Date, step: number): Date | null {
+ const maxIterations = 365;
+ const target = new Date(from);
+
+ for (let i = 0; i < maxIterations; i++) {
+ target.setDate(target.getDate() + step);
+
+ if (!this.isDisabled(target)) {
+ return new Date(target);
+ }
+ }
+
+ return null;
}
}
diff --git a/tedi/components/form/date-picker/date-picker.stories.ts b/tedi/components/form/date-picker/date-picker.stories.ts
index 694db26c9..0866a94f6 100644
--- a/tedi/components/form/date-picker/date-picker.stories.ts
+++ b/tedi/components/form/date-picker/date-picker.stories.ts
@@ -14,6 +14,11 @@ import { DatePickerComponent } from "./date-picker.component";
export default {
title: "TEDI-Ready/Components/Form/DatePicker",
component: DatePickerComponent,
+ parameters: {
+ status: {
+ type: ["partiallyTediReady"],
+ },
+ },
decorators: [
moduleMetadata({
imports: [DatePickerComponent],
@@ -51,7 +56,7 @@ export default {
defaultValue: { summary: "null" },
},
},
- showControls: {
+ showNavigation: {
description:
"Shows or hides the calendar navigation controls (previous/next month buttons).",
control: { type: "boolean" },
@@ -173,6 +178,15 @@ export default {
defaultValue: { summary: "false" },
},
},
+ closeOnSelect: {
+ description: "Close calendar popover after date selection",
+ control: { type: "boolean" },
+ table: {
+ category: "inputs",
+ type: { summary: "boolean" },
+ defaultValue: { summary: "true" },
+ },
+ },
},
} as Meta;
@@ -189,7 +203,7 @@ export const Default: StoryObj = {
return {
selected: next,
month: today,
- showControls: true,
+ showNavigation: true,
monthMode: "dropdown",
yearMode: "dropdown",
disabled: null,
@@ -202,6 +216,7 @@ export const Default: StoryObj = {
inputDisabled: false,
allowManualInput: true,
showWeekNumbers: false,
+ closeOnSelect: true,
};
})(),
render: (args) => ({
diff --git a/tedi/components/form/feedback-text/feedback-text.component.scss b/tedi/components/form/feedback-text/feedback-text.component.scss
index d525abaa7..39d88635e 100644
--- a/tedi/components/form/feedback-text/feedback-text.component.scss
+++ b/tedi/components/form/feedback-text/feedback-text.component.scss
@@ -1,7 +1,7 @@
.tedi-feedback-text {
display: block;
- color: var(--general-text-tertiary);
font-size: var(--body-small-regular-size);
+ color: var(--general-text-tertiary);
&--valid {
color: var(--general-status-success-text);
diff --git a/tedi/components/form/label/label.component.html b/tedi/components/form/label/label.component.html
new file mode 100644
index 000000000..1f681a3f4
--- /dev/null
+++ b/tedi/components/form/label/label.component.html
@@ -0,0 +1,5 @@
+
+@if (required()) {
+ *
+ , {{ 'required' | tediTranslate }}
+}
diff --git a/tedi/components/form/label/label.component.scss b/tedi/components/form/label/label.component.scss
index 427408153..f618b9c76 100644
--- a/tedi/components/form/label/label.component.scss
+++ b/tedi/components/form/label/label.component.scss
@@ -1,16 +1,14 @@
.tedi-label {
- font-size: var(--body-regular-size);
font-family: var(--family-default);
+ font-size: var(--body-regular-size);
&--small {
font-size: var(--body-small-regular-size);
}
&--required {
- &::after {
- content: " *";
- color: var(--form-general-feedback-error-border);
- }
+ margin-left: var(--content-label-inner-spacing-x);
+ color: var(--form-general-feedback-error-border);
}
&--primary {
diff --git a/tedi/components/form/label/label.component.spec.ts b/tedi/components/form/label/label.component.spec.ts
index 593e6ee60..8576da91e 100644
--- a/tedi/components/form/label/label.component.spec.ts
+++ b/tedi/components/form/label/label.component.spec.ts
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { LabelComponent } from "./label.component";
+import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
describe("LabelComponent", () => {
let fixture: ComponentFixture;
@@ -8,6 +9,7 @@ describe("LabelComponent", () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [LabelComponent],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
});
fixture = TestBed.createComponent(LabelComponent);
@@ -35,6 +37,11 @@ describe("LabelComponent", () => {
fixture.componentRef.setInput("required", true);
fixture.detectChanges();
- expect(element.classList).toContain("tedi-label--required");
+ const requiredSpan = element.querySelector(".tedi-label--required");
+ expect(requiredSpan).toBeTruthy();
+ expect(requiredSpan?.getAttribute("aria-hidden")).toBe("true");
+
+ const srOnlySpan = element.querySelector(".sr-only");
+ expect(srOnlySpan).toBeTruthy();
});
});
diff --git a/tedi/components/form/label/label.component.ts b/tedi/components/form/label/label.component.ts
index 7f17f23af..ddc1603f9 100644
--- a/tedi/components/form/label/label.component.ts
+++ b/tedi/components/form/label/label.component.ts
@@ -5,17 +5,21 @@ import {
input,
ViewEncapsulation,
} from "@angular/core";
+import { TediTranslationPipe } from "../../../services";
export type LabelSize = "small" | "default";
export type LabelColor = "primary" | "secondary";
@Component({
- selector: "label[tedi-label]",
- template: " ",
+ selector: "[tedi-label]",
+ templateUrl: "./label.component.html",
styleUrl: "./label.component.scss",
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ TediTranslationPipe
+ ],
host: {
"[class]": "classes()",
},
@@ -44,10 +48,6 @@ export class LabelComponent {
classList.push("tedi-label--small");
}
- if (this.required()) {
- classList.push("tedi-label--required");
- }
-
return classList.join(" ");
});
}
diff --git a/tedi/components/form/number-field/number-field.component.html b/tedi/components/form/number-field/number-field.component.html
index 964ac8501..94a798cae 100644
--- a/tedi/components/form/number-field/number-field.component.html
+++ b/tedi/components/form/number-field/number-field.component.html
@@ -1,5 +1,5 @@
@if (label()) {
-
+
{{ label() }}
}
@@ -20,13 +20,10 @@
'tedi-number-field__button--small': size() === 'small',
}"
[disabled]="decrementDisabled()"
+ [attr.aria-label]="'numberField.decrement' | tediTranslate: step()"
(click)="handleButtonClick('decrement')"
>
-
+
@@ -70,17 +68,15 @@
'tedi-number-field__button--small': size() === 'small',
}"
[disabled]="incrementDisabled()"
+ [attr.aria-label]="'numberField.increment' | tediTranslate: step()"
(click)="handleButtonClick('increment')"
>
-
+
@if (feedbackText(); as feedback) {
{
let fixture: ComponentFixture;
let component: NumberFieldComponent;
let el: HTMLElement;
+ let mockLiveAnnouncer: jest.Mocked;
beforeEach(() => {
+ mockLiveAnnouncer = {
+ announce: jest.fn().mockResolvedValue(undefined),
+ clear: jest.fn(),
+ } as unknown as jest.Mocked;
+
TestBed.configureTestingModule({
imports: [
NumberFieldComponent,
@@ -22,11 +29,14 @@ describe("NumberFieldComponent", () => {
TextComponent,
FeedbackTextComponent,
],
- providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ providers: [
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ { provide: LiveAnnouncer, useValue: mockLiveAnnouncer },
+ ],
});
fixture = TestBed.createComponent(NumberFieldComponent);
- fixture.componentRef.setInput("id", "test-id");
+ fixture.componentRef.setInput("inputId", "test-id");
component = fixture.componentInstance;
el = fixture.nativeElement;
fixture.detectChanges();
@@ -57,6 +67,19 @@ describe("NumberFieldComponent", () => {
expect(onTouched).toHaveBeenCalled();
});
+ it("should announce value change on increment button click", () => {
+ const buttons = el.querySelectorAll("button");
+ const incrementBtn = buttons[1] as HTMLButtonElement;
+
+ incrementBtn.click();
+ fixture.detectChanges();
+
+ expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
+ expect.any(String),
+ "polite"
+ );
+ });
+
it("should decrement the value on decrement button click", () => {
component.writeValue(5);
fixture.detectChanges();
@@ -74,6 +97,39 @@ describe("NumberFieldComponent", () => {
expect(onChange).toHaveBeenCalledWith(4);
});
+ it("should announce value change on decrement button click", () => {
+ component.writeValue(5);
+ fixture.detectChanges();
+
+ const decrementBtn = el.querySelector("button") as HTMLButtonElement;
+
+ decrementBtn.click();
+ fixture.detectChanges();
+
+ expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
+ expect.any(String),
+ "polite"
+ );
+ });
+
+ it("should have aria-label on decrement button", () => {
+ const decrementBtn = el.querySelector("button") as HTMLButtonElement;
+ expect(decrementBtn.getAttribute("aria-label")).toBeTruthy();
+ });
+
+ it("should have aria-label on increment button", () => {
+ const buttons = el.querySelectorAll("button");
+ const incrementBtn = buttons[1] as HTMLButtonElement;
+ expect(incrementBtn.getAttribute("aria-label")).toBeTruthy();
+ });
+
+ it("should have aria-hidden icons inside buttons", () => {
+ const icons = el.querySelectorAll("tedi-icon");
+ icons.forEach((icon) => {
+ expect(icon.getAttribute("aria-hidden")).toBe("true");
+ });
+ });
+
it("should disable decrement button when value === min", () => {
component.writeValue(3);
fixture.componentRef.setInput("min", 3);
@@ -93,6 +149,32 @@ describe("NumberFieldComponent", () => {
expect(incrementBtn.disabled).toBeTruthy();
});
+ it("should set aria-invalid to true when value is below min", () => {
+ component.writeValue(2);
+ fixture.componentRef.setInput("min", 5);
+ fixture.detectChanges();
+
+ const inputEl = el.querySelector("input") as HTMLInputElement;
+ expect(inputEl.getAttribute("aria-invalid")).toBe("true");
+ });
+
+ it("should set aria-invalid to true when value is above max", () => {
+ component.writeValue(10);
+ fixture.componentRef.setInput("max", 5);
+ fixture.detectChanges();
+
+ const inputEl = el.querySelector("input") as HTMLInputElement;
+ expect(inputEl.getAttribute("aria-invalid")).toBe("true");
+ });
+
+ it("should set aria-invalid to true when invalid input is true", () => {
+ fixture.componentRef.setInput("invalid", true);
+ fixture.detectChanges();
+
+ const inputEl = el.querySelector("input") as HTMLInputElement;
+ expect(inputEl.getAttribute("aria-invalid")).toBe("true");
+ });
+
it("should call onChange when input is changed", () => {
const onChange = jest.fn();
component.registerOnChange(onChange);
@@ -187,4 +269,31 @@ describe("NumberFieldComponent", () => {
expect(blurSpy).toHaveBeenCalled();
expect(onTouched).toHaveBeenCalled();
});
+
+ it("should set aria-describedby when feedbackText is provided", () => {
+ fixture.componentRef.setInput("feedbackText", {
+ text: "Error message",
+ type: "error",
+ });
+ fixture.detectChanges();
+
+ const inputEl = el.querySelector("input") as HTMLInputElement;
+ expect(inputEl.getAttribute("aria-describedby")).toBe("test-id-feedback");
+ });
+
+ it("should not set aria-describedby when feedbackText is not provided", () => {
+ const inputEl = el.querySelector("input") as HTMLInputElement;
+ expect(inputEl.getAttribute("aria-describedby")).toBeNull();
+ });
+
+ it("should set id on feedback-text element matching aria-describedby", () => {
+ fixture.componentRef.setInput("feedbackText", {
+ text: "Error message",
+ type: "error",
+ });
+ fixture.detectChanges();
+
+ const feedbackEl = el.querySelector("tedi-feedback-text");
+ expect(feedbackEl?.getAttribute("id")).toBe("test-id-feedback");
+ });
});
diff --git a/tedi/components/form/number-field/number-field.component.ts b/tedi/components/form/number-field/number-field.component.ts
index 7d7642155..c31d0e589 100644
--- a/tedi/components/form/number-field/number-field.component.ts
+++ b/tedi/components/form/number-field/number-field.component.ts
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
+ inject,
input,
model,
ViewEncapsulation,
@@ -10,8 +11,10 @@ import {
signal,
ViewChild,
} from "@angular/core";
+import { LiveAnnouncer } from "@angular/cdk/a11y";
import { ButtonComponent } from "../../buttons/button/button.component";
import { TediTranslationPipe } from "../../../services/translation/translation.pipe";
+import { TediTranslationService } from "../../../services/translation/translation.service";
import { ComponentInputs } from "../../../types/inputs.type";
import { IconComponent } from "../../base/icon/icon.component";
import { TextComponent } from "../../base/text/text.component";
@@ -48,7 +51,7 @@ export class NumberFieldComponent implements ControlValueAccessor {
/**
* The unique identifier for the input element that this label is associated with. This ID should match the input element's id attribute to ensure accessibility.
*/
- id = input.required();
+ inputId = input.required();
/**
* The text content of the label that describes the input field.
*/
@@ -107,8 +110,10 @@ export class NumberFieldComponent implements ControlValueAccessor {
@ViewChild("inputElement") inputRef!: ElementRef;
private formDisabled = signal(false);
- private onChange: (value: number) => void = () => {};
- private onTouched: () => void = () => {};
+ private onChange: (value: number) => void = () => { };
+ private onTouched: () => void = () => { };
+ private translationService = inject(TediTranslationService);
+ private liveAnnouncer = inject(LiveAnnouncer);
readonly isInvalid = computed(() => {
const min = this.min();
@@ -135,6 +140,10 @@ export class NumberFieldComponent implements ControlValueAccessor {
return this.isDisabled() || (max !== undefined && this.value() >= max);
});
+ readonly feedbackId = computed(() =>
+ this.feedbackText() ? `${this.inputId()}-feedback` : null
+ );
+
writeValue(value?: number): void {
this.value.set(value ? (isNaN(value) ? 0 : value) : 0);
}
@@ -166,13 +175,23 @@ export class NumberFieldComponent implements ControlValueAccessor {
this.value.set(nextValue);
this.onChange(nextValue);
this.onTouched();
+ this.announceValue(nextValue);
+ }
+
+ announceValue(value: number) {
+ this.liveAnnouncer.announce(
+ this.translationService.translate("numberField.quantityUpdated", value),
+ "polite"
+ );
}
handleInputChange(event: Event) {
const input = event.target as HTMLInputElement;
const value = isNaN(input.valueAsNumber) ? 0 : input.valueAsNumber;
- this.value.set(value);
- this.onChange(value);
+ if (value !== this.value()) {
+ this.value.set(value);
+ this.onChange(value);
+ }
}
handleBlur() {
diff --git a/tedi/components/form/number-field/number-field.stories.ts b/tedi/components/form/number-field/number-field.stories.ts
index e775c8a9f..03ae66fe7 100644
--- a/tedi/components/form/number-field/number-field.stories.ts
+++ b/tedi/components/form/number-field/number-field.stories.ts
@@ -25,10 +25,10 @@ export default {
],
render: (args) => ({
props: args,
- template: ` `,
+ template: ` `,
}),
argTypes: {
- id: {
+ inputId: {
description:
"The unique identifier for the input element that this label is associated with. This ID should match the input element's id attribute to ensure accessibility.",
control: {
@@ -166,7 +166,7 @@ export default {
export const Default: StoryObj = {
args: {
- id: "example-id",
+ inputId: "example-id",
label: "Label",
},
render: (args) => ({
@@ -182,11 +182,11 @@ export const Sizes: StoryObj = {
Default
-
+
Small
-
+
`,
@@ -200,23 +200,23 @@ export const States: StoryObj = {
Default
-
+
Min value
-
+
Max value
-
+
Disabled
-
+
Error
-
+
`,
@@ -225,7 +225,7 @@ export const States: StoryObj = {
export const WithHint: StoryObj = {
args: {
- id: "example-hint",
+ inputId: "example-hint",
label: "Label",
feedbackText: {
text: "Hint text",
@@ -241,7 +241,7 @@ export const WithHint: StoryObj = {
export const Decimal: StoryObj = {
args: {
- id: "example-decimal",
+ inputId: "example-decimal",
label: "Label",
value: 1.5,
},
@@ -253,7 +253,7 @@ export const Decimal: StoryObj = {
export const WithUnit: StoryObj = {
args: {
- id: "example-unit",
+ inputId: "example-unit",
label: "Label",
suffix: "unit",
value: 2,
@@ -266,7 +266,7 @@ export const WithUnit: StoryObj = {
export const FullWidth: StoryObj = {
args: {
- id: "example-full-width",
+ inputId: "example-full-width",
label: "Label",
suffix: "unit",
fullWidth: true,
@@ -279,7 +279,7 @@ export const FullWidth: StoryObj = {
export const CustomLabelAndFeedbackText: StoryObj = {
args: {
- id: "example-custom",
+ inputId: "example-custom",
},
render: (args) => ({
props: args,
diff --git a/tedi/components/form/toggle/toggle.component.html b/tedi/components/form/toggle/toggle.component.html
index 45230308f..0cfb2b2ab 100644
--- a/tedi/components/form/toggle/toggle.component.html
+++ b/tedi/components/form/toggle/toggle.component.html
@@ -1,6 +1,6 @@
@if (icon() && size() === 'large') {
-
}
-
\ No newline at end of file
+
diff --git a/tedi/components/form/toggle/toggle.component.scss b/tedi/components/form/toggle/toggle.component.scss
index 5bfc7bd01..d80e717a9 100644
--- a/tedi/components/form/toggle/toggle.component.scss
+++ b/tedi/components/form/toggle/toggle.component.scss
@@ -11,17 +11,17 @@
}
&:focus-visible {
- background-color: var(--form-toggl-#{$variant}-inactive-default);
outline: calc(2 * var(--borders-01))
solid
var(--form-toggl-primary-active-default);
outline-offset: var(--borders-01);
+ background-color: var(--form-toggl-#{$variant}-inactive-default);
}
&:disabled {
- opacity: 0.5;
- background-color: var(--form-toggl-#{$variant}-inactive-default);
cursor: not-allowed;
+ background-color: var(--form-toggl-#{$variant}-inactive-default);
+ opacity: 0.5;
+ .tedi-toggle__slider .tedi-toggle__icon {
opacity: 0.5;
@@ -44,17 +44,17 @@
}
&:focus-visible {
- background-color: var(--form-toggl-#{$variant}-active-default);
outline: calc(2 * var(--borders-01))
solid
var(--form-toggl-primary-active-default);
outline-offset: var(--borders-01);
+ background-color: var(--form-toggl-#{$variant}-active-default);
}
&:disabled {
- opacity: 0.5;
- background-color: var(--form-toggl-#{$variant}-active-default);
cursor: not-allowed;
+ background-color: var(--form-toggl-#{$variant}-active-default);
+ opacity: 0.5;
+ .tedi-toggle__slider .tedi-toggle__icon {
opacity: 0.5;
@@ -97,13 +97,13 @@
}
&:focus-visible {
- border: var(--borders-01)
- solid
- var(--form-toggl-#{$variant}-inactive-default);
outline: calc(2 * var(--borders-01))
solid
var(--form-toggl-primary-active-default);
outline-offset: var(--borders-01);
+ border: var(--borders-01)
+ solid
+ var(--form-toggl-#{$variant}-inactive-default);
+ .tedi-toggle__slider {
background-color: var(--form-toggl-#{$variant}-inactive-default);
@@ -111,11 +111,11 @@
}
&:disabled {
- opacity: 0.5;
cursor: not-allowed;
border: var(--borders-01)
solid
var(--form-toggl-#{$variant}-inactive-default);
+ opacity: 0.5;
+ .tedi-toggle__slider {
background-color: var(--form-toggl-#{$variant}-inactive-default);
@@ -156,13 +156,13 @@
}
&:focus-visible {
- border: var(--borders-01)
- solid
- var(--form-toggl-#{$variant}-active-default);
outline: calc(2 * var(--borders-01))
solid
var(--form-toggl-primary-active-default);
outline-offset: var(--borders-01);
+ border: var(--borders-01)
+ solid
+ var(--form-toggl-#{$variant}-active-default);
+ .tedi-toggle__slider {
background-color: var(--form-toggl-#{$variant}-active-default);
@@ -170,11 +170,11 @@
}
&:disabled {
- opacity: 0.5;
cursor: not-allowed;
border: var(--borders-01)
solid
var(--form-toggl-#{$variant}-active-default);
+ opacity: 0.5;
+ .tedi-toggle__slider {
background-color: var(--form-toggl-#{$variant}-active-default);
@@ -193,9 +193,9 @@
display: block;
&--size {
- --_toggle-indicator: var(--form-toggl-default-indicator);
-
&-default {
+ --_toggle-indicator: var(--form-toggl-default-indicator);
+
width: var(--form-toggl-default-width);
height: var(--form-toggl-default-height);
@@ -207,6 +207,7 @@
&-large {
--_toggle-indicator: var(--form-toggl-large-indicator);
+
width: var(--form-toggl-large-width);
height: var(--form-toggl-large-height);
@@ -248,27 +249,27 @@
&__input {
width: 100%;
height: 100%;
+ margin: 0;
appearance: none;
cursor: pointer;
- margin: 0;
border-radius: var(--form-toggl-radius);
&:checked + .tedi-toggle__slider {
- transform: translateY(-50%);
left: calc(100% - var(--_toggle-indicator) - var(--form-toggl-padding));
+ transform: translateY(-50%);
}
}
&__slider {
position: absolute;
top: 50%;
+ left: var(--form-toggl-padding);
display: flex;
- justify-content: center;
align-items: center;
- transform: translateY(-50%);
+ justify-content: center;
+ pointer-events: none;
border-radius: 50%;
+ transform: translateY(-50%);
transition: left 0.17s ease-out;
- pointer-events: none;
- left: var(--form-toggl-padding);
}
}
diff --git a/tedi/components/form/toggle/toggle.component.spec.ts b/tedi/components/form/toggle/toggle.component.spec.ts
index 99ace2fc6..c0b3b9c93 100644
--- a/tedi/components/form/toggle/toggle.component.spec.ts
+++ b/tedi/components/form/toggle/toggle.component.spec.ts
@@ -14,7 +14,7 @@ describe("ToggleComponent", () => {
});
fixture = TestBed.createComponent(ToggleComponent);
- fixture.componentRef.setInput("id", "test-toggle-id");
+ fixture.componentRef.setInput("inputId", "test-toggle-id");
toggleElement = fixture.nativeElement;
fixture.detectChanges();
diff --git a/tedi/components/form/toggle/toggle.component.ts b/tedi/components/form/toggle/toggle.component.ts
index 2336b1035..c6cccc005 100644
--- a/tedi/components/form/toggle/toggle.component.ts
+++ b/tedi/components/form/toggle/toggle.component.ts
@@ -39,7 +39,7 @@ export class ToggleComponent implements ControlValueAccessor {
/**
* The unique identifier for the input element that is associated with label.
*/
- id = input.required();
+ inputId = input.required();
/**
* Is toggle checked? Supports two-way binding, use with form controls.
*/
@@ -76,8 +76,8 @@ export class ToggleComponent implements ControlValueAccessor {
icon = input(false);
@ViewChild('inputElement') inputRef!: ElementRef;
- private onChange: (checked: boolean) => void = () => {};
- private onTouched: () => void = () => {};
+ private onChange: (checked: boolean) => void = () => { };
+ private onTouched: () => void = () => { };
writeValue(checked: boolean): void {
this.checked.set(checked);
@@ -121,7 +121,7 @@ export class ToggleComponent implements ControlValueAccessor {
switch (this.variant()) {
case "primary":
- return this.checked() ? "brand": "tertiary";
+ return this.checked() ? "brand" : "tertiary";
case "colored":
return this.checked() ? "success" : "danger";
}
diff --git a/tedi/components/form/toggle/toggle.stories.ts b/tedi/components/form/toggle/toggle.stories.ts
index 74059c6e2..53e04ca2d 100644
--- a/tedi/components/form/toggle/toggle.stories.ts
+++ b/tedi/components/form/toggle/toggle.stories.ts
@@ -26,7 +26,7 @@ export default {
}),
],
argTypes: {
- id: {
+ inputId: {
description:
"The unique identifier for the input element that is associated with label.",
control: {
@@ -124,14 +124,16 @@ export default {
export const Default: StoryObj = {
args: {
- id: "example-toggle-1",
+ inputId: "example-toggle-1",
variant: "primary",
type: "filled",
size: "default",
},
render: (args) => ({
props: args,
- template: ` `,
+ template: `
+ Default toggle
+ `,
}),
};
@@ -141,9 +143,11 @@ export const Size: StoryObj = {
template: `
Default
-
+ Default size
+
Large
-
+ Large size
+
`,
}),
@@ -156,10 +160,10 @@ export const LabelPosition: StoryObj = {
Toggle button
-
+
-
+
Toggle button
@@ -175,16 +179,16 @@ const Template: StoryFn = (args) => ({
{{ state }}
-
-
+
+
-
-
+
+
-
-
+
+
diff --git a/tedi/components/helpers/grid/col/col.component.scss b/tedi/components/helpers/grid/col/col.component.scss
index d989e5e9e..8779cf9f3 100644
--- a/tedi/components/helpers/grid/col/col.component.scss
+++ b/tedi/components/helpers/grid/col/col.component.scss
@@ -1,10 +1,20 @@
$column-width: 12;
-$justify-self: ("start", "end", "center", "stretch");
-$align-self: ("start", "end", "center", "stretch");
+$justify-self: (
+ "start",
+ "end",
+ "center",
+ "stretch"
+);
+$align-self: (
+ "start",
+ "end",
+ "center",
+ "stretch"
+);
-.col {
+.tedi-col {
display: block;
-
+
@for $i from 1 through $column-width {
&--width-#{$i} {
grid-column: span #{$i};
diff --git a/tedi/components/helpers/grid/col/col.component.spec.ts b/tedi/components/helpers/grid/col/col.component.spec.ts
index b5035f422..29acf2b9e 100644
--- a/tedi/components/helpers/grid/col/col.component.spec.ts
+++ b/tedi/components/helpers/grid/col/col.component.spec.ts
@@ -25,8 +25,8 @@ describe("ColComponent", () => {
});
it("should render the col with default props", () => {
- expect(colElement.classList).toContain("col");
- expect(colElement.classList).toContain("col--width-1");
+ expect(colElement.classList).toContain("tedi-col");
+ expect(colElement.classList).toContain("tedi-col--width-1");
});
it("should apply different column widths", () => {
@@ -36,7 +36,7 @@ describe("ColComponent", () => {
fixture.componentRef.setInput("width", width);
fixture.detectChanges();
- expect(colElement.classList).toContain(`col--width-${width}`);
+ expect(colElement.classList).toContain(`tedi-col--width-${width}`);
}
});
@@ -47,7 +47,7 @@ describe("ColComponent", () => {
fixture.componentRef.setInput("justifySelf", justifySelf);
fixture.detectChanges();
- expect(colElement.classList).toContain(`col--justify-self-${justifySelf}`);
+ expect(colElement.classList).toContain(`tedi-col--justify-self-${justifySelf}`);
}
});
@@ -58,7 +58,7 @@ describe("ColComponent", () => {
fixture.componentRef.setInput("alignSelf", alignSelf);
fixture.detectChanges();
- expect(colElement.classList).toContain(`col--align-self-${alignSelf}`);
+ expect(colElement.classList).toContain(`tedi-col--align-self-${alignSelf}`);
}
});
diff --git a/tedi/components/helpers/grid/col/col.component.ts b/tedi/components/helpers/grid/col/col.component.ts
index 433437852..f230fa837 100644
--- a/tedi/components/helpers/grid/col/col.component.ts
+++ b/tedi/components/helpers/grid/col/col.component.ts
@@ -70,16 +70,16 @@ export class ColComponent implements BreakpointInputs {
});
classes = computed(() => {
- const classList = ["col", `col--width-${this.breakpointInputs().width}`];
+ const classList = ["tedi-col", `tedi-col--width-${this.breakpointInputs().width}`];
if (this.breakpointInputs().justifySelf) {
classList.push(
- `col--justify-self-${this.breakpointInputs().justifySelf}`,
+ `tedi-col--justify-self-${this.breakpointInputs().justifySelf}`,
);
}
if (this.breakpointInputs().alignSelf) {
- classList.push(`col--align-self-${this.breakpointInputs().alignSelf}`);
+ classList.push(`tedi-col--align-self-${this.breakpointInputs().alignSelf}`);
}
return classList.join(" ");
diff --git a/tedi/components/helpers/grid/row/row.component.scss b/tedi/components/helpers/grid/row/row.component.scss
index b97a8ef9c..0012b39b3 100644
--- a/tedi/components/helpers/grid/row/row.component.scss
+++ b/tedi/components/helpers/grid/row/row.component.scss
@@ -8,17 +8,25 @@ $gaps: (
4: $spacer * 1.5,
5: $spacer * 3,
);
-
$justify-items: ("start", "end", "center", "stretch");
$align-items: ("start", "end", "center", "stretch");
-.row {
+.tedi-row {
display: grid;
&--cols-auto {
- --_grid-col-size-calc: calc((100% - var(--_grid-gap) * (#{$grid-columns} - 1)) / #{$grid-columns});
- --_grid-col-min-size-calc: min(100%, max(var(--_grid-col-width), var(--_grid-col-size-calc)));
- grid-template-columns: repeat(auto-fit, minmax(var(--_grid-col-min-size-calc), 1fr));
+ --_grid-col-size-calc: calc(
+ (100% - var(--_grid-gap) * (#{$grid-columns} - 1)) / #{$grid-columns}
+ );
+ --_grid-col-min-size-calc: min(
+ 100%,
+ max(var(--_grid-col-width), var(--_grid-col-size-calc))
+ );
+
+ grid-template-columns: repeat(
+ auto-fit,
+ minmax(var(--_grid-col-min-size-calc), 1fr)
+ );
}
@for $i from 1 through $grid-columns {
diff --git a/tedi/components/helpers/grid/row/row.component.spec.ts b/tedi/components/helpers/grid/row/row.component.spec.ts
index 454a1afc0..53739018a 100644
--- a/tedi/components/helpers/grid/row/row.component.spec.ts
+++ b/tedi/components/helpers/grid/row/row.component.spec.ts
@@ -26,8 +26,8 @@ describe("RowComponent", () => {
});
it("should apply default cols class", () => {
- expect(rowElement.classList).toContain("row");
- expect(rowElement.classList).toContain("row--cols-auto");
+ expect(rowElement.classList).toContain("tedi-row");
+ expect(rowElement.classList).toContain("tedi-row--cols-auto");
});
it("should apply different column values", () => {
@@ -37,7 +37,7 @@ describe("RowComponent", () => {
fixture.componentRef.setInput("cols", col);
fixture.detectChanges();
- expect(rowElement.classList).toContain(`row--cols-${col}`);
+ expect(rowElement.classList).toContain(`tedi-row--cols-${col}`);
}
});
@@ -48,7 +48,7 @@ describe("RowComponent", () => {
fixture.componentRef.setInput("justifyItems", justifyItems);
fixture.detectChanges();
- expect(rowElement.classList).toContain(`row--justify-items-${justifyItems}`);
+ expect(rowElement.classList).toContain(`tedi-row--justify-items-${justifyItems}`);
}
});
@@ -59,7 +59,7 @@ describe("RowComponent", () => {
fixture.componentRef.setInput("alignItems", alignItems);
fixture.detectChanges();
- expect(rowElement.classList).toContain(`row--align-items-${alignItems}`);
+ expect(rowElement.classList).toContain(`tedi-row--align-items-${alignItems}`);
}
});
diff --git a/tedi/components/helpers/grid/row/row.component.ts b/tedi/components/helpers/grid/row/row.component.ts
index 3b82e6369..d6514a04b 100644
--- a/tedi/components/helpers/grid/row/row.component.ts
+++ b/tedi/components/helpers/grid/row/row.component.ts
@@ -114,16 +114,16 @@ export class RowComponent implements BreakpointInputs {
});
classes = computed(() => {
- const classList = ["row", `row--cols-${this.breakpointInputs().cols}`];
+ const classList = ["tedi-row", `tedi-row--cols-${this.breakpointInputs().cols}`];
if (this.breakpointInputs().justifyItems) {
classList.push(
- `row--justify-items-${this.breakpointInputs().justifyItems}`,
+ `tedi-row--justify-items-${this.breakpointInputs().justifyItems}`,
);
}
if (this.breakpointInputs().alignItems) {
- classList.push(`row--align-items-${this.breakpointInputs().alignItems}`);
+ classList.push(`tedi-row--align-items-${this.breakpointInputs().alignItems}`);
}
if (this.breakpointInputs().gap !== undefined) {
diff --git a/tedi/components/helpers/separator/separator.component.scss b/tedi/components/helpers/separator/separator.component.scss
index 288d7c82b..f5522ef64 100644
--- a/tedi/components/helpers/separator/separator.component.scss
+++ b/tedi/components/helpers/separator/separator.component.scss
@@ -15,12 +15,12 @@ $sizes: (
);
.tedi-separator {
- display: block;
position: relative;
+ display: block;
margin: 0;
border: 0;
- border-top-width: 1px;
border-style: solid;
+ border-top-width: 1px;
@include mixins.print-grayscale;
@@ -55,12 +55,12 @@ $sizes: (
&::before {
position: absolute;
top: 1.25rem;
+ width: var(--separator-dotted-dot-lg);
+ height: var(--separator-dotted-dot-lg);
content: "";
background-color: var(--general-border-primary);
border-radius: 100%;
transform: translateX(-8px);
- width: var(--separator-dotted-dot-lg);
- height: var(--separator-dotted-dot-lg);
@include mixins.print-grayscale;
}
@@ -76,9 +76,9 @@ $sizes: (
.tedi-separator--dotted-small::before {
top: 1.5rem;
- transform: translateX(-5px);
width: var(--separator-dotted-dot-md);
height: var(--separator-dotted-dot-md);
+ transform: translateX(-5px);
}
@each $size, $value in $sizes {
@@ -138,10 +138,10 @@ $thicknesses: (
&::before {
position: relative;
display: block;
- content: "";
- border-radius: 100%;
width: var(--separator-dotted-dot-sm);
height: var(--separator-dotted-dot-sm);
+ content: "";
+ border-radius: 100%;
@include mixins.print-grayscale;
}
diff --git a/tedi/components/helpers/timeline/timeline-description/timeline-description.component.scss b/tedi/components/helpers/timeline/timeline-description/timeline-description.component.scss
index 12dc4576e..7f30c6d2e 100644
--- a/tedi/components/helpers/timeline/timeline-description/timeline-description.component.scss
+++ b/tedi/components/helpers/timeline/timeline-description/timeline-description.component.scss
@@ -1,12 +1,12 @@
tedi-timeline-description {
display: flex;
+ gap: var(--layout-grid-gutters-04);
align-items: center;
- color: var(--general-text-tertiary);
min-height: var(--timeline-text-min-height);
- gap: var(--layout-grid-gutters-04);
font-size: var(--body-small-regular-size);
font-weight: var(--body-small-regular-weight);
line-height: var(--body-small-regular-line-height);
+ color: var(--general-text-tertiary);
> * {
font-size: inherit;
diff --git a/tedi/components/helpers/timeline/timeline-item/timeline-item.component.scss b/tedi/components/helpers/timeline/timeline-item/timeline-item.component.scss
index d6703b8d5..dae43e4e0 100644
--- a/tedi/components/helpers/timeline/timeline-item/timeline-item.component.scss
+++ b/tedi/components/helpers/timeline/timeline-item/timeline-item.component.scss
@@ -9,17 +9,17 @@ tedi-timeline-item {
display: contents;
.tedi-timeline__timings {
- grid-column: 2;
- grid-row: auto;
display: flex;
- align-items: center;
+ grid-row: auto;
+ grid-column: 2;
gap: var(--layout-grid-gutters-04);
+ align-items: center;
@include breakpoints.media-breakpoint-up(lg) {
- grid-column: 1;
flex-direction: column;
- align-items: end;
+ grid-column: 1;
gap: 0;
+ align-items: end;
padding-bottom: var(--timeline-padding-y);
}
@@ -39,13 +39,13 @@ tedi-timeline-item {
@include breakpoints.media-breakpoint-down(lg) {
&::before {
- content: "";
display: none;
width: 4px;
height: 4px;
- border-radius: 50%;
- background-color: currentColor;
margin-inline: var(--layout-grid-gutters-04);
+ content: "";
+ background-color: currentcolor;
+ border-radius: 50%;
}
&:not(:first-child)::before {
@@ -56,21 +56,21 @@ tedi-timeline-item {
}
.tedi-timeline__marker {
- grid-column: 1;
- grid-row: span 2;
- align-self: stretch;
- padding-top: 6px;
display: flex;
flex-direction: column;
+ grid-row: span 2;
+ grid-column: 1;
align-items: center;
+ align-self: stretch;
+ padding-top: 6px;
&:not(:last-child) {
margin-bottom: -6px;
}
@include breakpoints.media-breakpoint-up(lg) {
- grid-column: 2;
grid-row: auto;
+ grid-column: 2;
}
&--large {
@@ -79,10 +79,10 @@ tedi-timeline-item {
}
.tedi-timeline__info {
- grid-column: 2;
- grid-row: auto;
display: flex;
flex-direction: column;
+ grid-row: auto;
+ grid-column: 2;
gap: var(--timeline-padding-y);
padding-bottom: var(--timeline-padding-y);
diff --git a/tedi/components/helpers/timeline/timeline-title/timeline-title.component.scss b/tedi/components/helpers/timeline/timeline-title/timeline-title.component.scss
index b93bce265..0352a2071 100644
--- a/tedi/components/helpers/timeline/timeline-title/timeline-title.component.scss
+++ b/tedi/components/helpers/timeline/timeline-title/timeline-title.component.scss
@@ -1,12 +1,12 @@
tedi-timeline-title {
display: flex;
+ gap: var(--layout-grid-gutters-04);
align-items: center;
- color: var(--general-text-secondary);
min-height: var(--timeline-text-min-height);
- gap: var(--layout-grid-gutters-04);
font-size: var(--body-small-bold-size);
- line-height: var(--body-small-bold-line-height);
font-weight: var(--body-bold-weight);
+ line-height: var(--body-small-bold-line-height);
+ color: var(--general-text-secondary);
> * {
font-size: inherit;
diff --git a/tedi/components/helpers/timeline/timeline.component.scss b/tedi/components/helpers/timeline/timeline.component.scss
index f9c429d5d..3699444d3 100644
--- a/tedi/components/helpers/timeline/timeline.component.scss
+++ b/tedi/components/helpers/timeline/timeline.component.scss
@@ -3,13 +3,13 @@
tedi-timeline {
display: grid;
grid-template-columns: max-content 1fr;
- grid-auto-flow: row dense;
grid-auto-rows: auto;
+ grid-auto-flow: row dense;
column-gap: var(--layout-grid-gutters-12);
@include breakpoints.media-breakpoint-up(lg) {
grid-template-columns: max-content max-content 1fr;
- row-gap: 0;
grid-auto-flow: row;
+ row-gap: 0;
}
}
diff --git a/tedi/components/layout/footer/footer-body/footer-body.component.scss b/tedi/components/layout/footer/footer-body/footer-body.component.scss
index aeba64d01..0a9942ed9 100644
--- a/tedi/components/layout/footer/footer-body/footer-body.component.scss
+++ b/tedi/components/layout/footer/footer-body/footer-body.component.scss
@@ -1,15 +1,15 @@
.tedi-footer-body {
display: flex;
+ flex-wrap: wrap;
+ gap: var(--layout-grid-gutters-24);
align-items: flex-start;
justify-content: space-around;
width: 100%;
- flex-wrap: wrap;
- gap: var(--layout-grid-gutters-24);
padding: var(--layout-footer-padding-y) var(--layout-footer-padding-x);
&--mobile {
- gap: 0.75rem;
flex-direction: column;
+ gap: 0.75rem;
padding: var(--layout-footer-padding-y) var(--layout-footer-padding-x);
}
}
diff --git a/tedi/components/layout/footer/footer-bottom/footer-bottom.component.scss b/tedi/components/layout/footer/footer-bottom/footer-bottom.component.scss
index a42c35318..76038b81d 100644
--- a/tedi/components/layout/footer/footer-bottom/footer-bottom.component.scss
+++ b/tedi/components/layout/footer/footer-bottom/footer-bottom.component.scss
@@ -1,12 +1,12 @@
.tedi-footer-bottom {
display: flex;
- justify-content: center;
- align-items: center;
- background-color: var(--footer-bottom-background);
flex-wrap: wrap;
gap: var(--layout-grid-gutters-24);
+ align-items: center;
+ justify-content: center;
padding: var(--layout-footer-bottom-padding-y)
var(--layout-footer-bottom-padding-x);
+ background-color: var(--footer-bottom-background);
&--mobile {
gap: var(--layout-grid-gutters-16);
diff --git a/tedi/components/layout/footer/footer-section/footer-section.component.scss b/tedi/components/layout/footer/footer-section/footer-section.component.scss
index 0c1454106..53a6e20c0 100644
--- a/tedi/components/layout/footer/footer-section/footer-section.component.scss
+++ b/tedi/components/layout/footer/footer-section/footer-section.component.scss
@@ -3,31 +3,33 @@
@mixin footer-links {
display: flex;
flex-direction: column;
- align-items: flex-start;
gap: var(--layout-footer-item-vertical-spacing);
+ align-items: flex-start;
}
.tedi-footer-section {
display: flex;
- align-items: flex-start;
gap: var(--layout-footer-col-padding-x);
+ align-items: flex-start;
&--collapse {
- border-bottom: 1px solid var(--tedi-alpha-white-10);
width: 100%;
+ border-bottom: 1px solid var(--tedi-alpha-white-10);
}
&__icon {
- background: var(--footer-icon-background);
+ gap: var(--layout-footer-item-vertical-spacing);
padding: var(--icon-background-padding-lg);
+ background: var(--footer-icon-background);
border-radius: var(--icon-background-radius);
- gap: var(--layout-footer-item-vertical-spacing);
}
&__container {
padding-bottom: 0.75rem;
+
@include footer-links;
}
+
&__content {
@include footer-links;
}
@@ -38,14 +40,15 @@
&__button {
@include mixins.button-reset;
+
font-weight: 700;
color: var(--general-text-white);
&:focus-visible {
color: var(--link-inverted-focus);
- border-radius: 0;
outline: 2px solid var(--link-inverted-focus);
outline-offset: 1px;
+ border-radius: 0;
}
}
}
diff --git a/tedi/components/layout/footer/footer-side/footer-side.component.scss b/tedi/components/layout/footer/footer-side/footer-side.component.scss
index 93ce73a9d..08b4d3647 100644
--- a/tedi/components/layout/footer/footer-side/footer-side.component.scss
+++ b/tedi/components/layout/footer/footer-side/footer-side.component.scss
@@ -1,6 +1,6 @@
.tedi-footer-side {
- flex-shrink: 0;
display: flex;
+ flex-shrink: 0;
flex-direction: column;
align-items: center;
justify-content: center;
diff --git a/tedi/components/layout/header/header-actions/header-actions.component.scss b/tedi/components/layout/header/header-actions/header-actions.component.scss
index ef27fa99e..76b99c57d 100644
--- a/tedi/components/layout/header/header-actions/header-actions.component.scss
+++ b/tedi/components/layout/header/header-actions/header-actions.component.scss
@@ -7,12 +7,12 @@
> * {
display: flex;
- height: 100%;
align-items: center;
+ height: 100%;
&:not(:first-child) {
- border-left: var(--borders-01) solid var(--general-border-primary);
padding-left: var(--layout-header-items-right-gutter-x);
+ border-left: var(--borders-01) solid var(--general-border-primary);
}
&:not(:last-child) {
diff --git a/tedi/components/layout/header/header-content/header-content.component.scss b/tedi/components/layout/header/header-content/header-content.component.scss
index 85d110868..32f70bbc6 100644
--- a/tedi/components/layout/header/header-content/header-content.component.scss
+++ b/tedi/components/layout/header/header-content/header-content.component.scss
@@ -1,7 +1,7 @@
tedi-header-content {
display: flex;
- align-items: center;
gap: var(--layout-header-items-center-gutter-x);
+ align-items: center;
a,
.tedi-link {
diff --git a/tedi/components/layout/header/header-language/header-language.component.scss b/tedi/components/layout/header/header-language/header-language.component.scss
index a2ebe7ed5..aeeccb303 100644
--- a/tedi/components/layout/header/header-language/header-language.component.scss
+++ b/tedi/components/layout/header/header-language/header-language.component.scss
@@ -8,8 +8,8 @@
.tedi-header-language {
display: flex;
flex-direction: column;
- justify-content: center;
align-items: flex-start;
+ justify-content: center;
tedi-popover-trigger {
tedi-icon {
diff --git a/tedi/components/layout/header/header-login/header-login.component.scss b/tedi/components/layout/header/header-login/header-login.component.scss
index 4990287f6..f2535c728 100644
--- a/tedi/components/layout/header/header-login/header-login.component.scss
+++ b/tedi/components/layout/header/header-login/header-login.component.scss
@@ -8,16 +8,16 @@ tedi-header-login {
&--mobile {
flex-direction: column;
- justify-content: center;
- align-items: center;
gap: 0;
- border: 0;
- border-radius: 0;
- font-size: var(--body-extra-small-size);
- line-height: var(--body-regular-line-height);
+ align-items: center;
+ justify-content: center;
min-width: var(--layout-header-mobile-button-size);
min-height: var(--layout-header-mobile-button-size);
padding: var(--layout-grid-gutters-08);
+ font-size: var(--body-extra-small-size);
+ line-height: var(--body-regular-line-height);
+ border: 0;
+ border-radius: 0;
&:focus-visible {
outline: var(--borders-02) solid var(--tedi-primary-500);
diff --git a/tedi/components/layout/header/header-logout/header-logout.component.scss b/tedi/components/layout/header/header-logout/header-logout.component.scss
index f846b0fdf..0a30e7b61 100644
--- a/tedi/components/layout/header/header-logout/header-logout.component.scss
+++ b/tedi/components/layout/header/header-logout/header-logout.component.scss
@@ -1,11 +1,11 @@
.tedi-header-logout {
display: flex;
+ gap: var(--link-inner-spacing-x);
align-items: center;
- background: transparent;
padding: 0;
- border: 0;
font-size: var(--body-regular-size);
- gap: var(--link-inner-spacing-x);
+ background: transparent;
+ border: 0;
&:hover {
span {
@@ -15,16 +15,16 @@
&--mobile {
flex-direction: column;
- justify-content: center;
- align-items: center;
gap: 0;
- border: 0;
- border-radius: 0;
- font-size: var(--body-extra-small-size);
- line-height: var(--body-regular-line-height);
+ align-items: center;
+ justify-content: center;
min-width: var(--layout-header-mobile-button-size);
min-height: var(--layout-header-mobile-button-size);
padding: var(--layout-grid-gutters-08);
+ font-size: var(--body-extra-small-size);
+ line-height: var(--body-regular-line-height);
+ border: 0;
+ border-radius: 0;
&:focus-visible {
outline: var(--borders-02) solid var(--tedi-primary-500);
@@ -32,9 +32,9 @@
}
tedi-icon {
- font-size: var(--icon-05) !important;
margin: 0 !important;
margin-bottom: 4px !important;
+ font-size: var(--icon-05) !important;
}
}
diff --git a/tedi/components/layout/header/header-profile/header-profile.component.html b/tedi/components/layout/header/header-profile/header-profile.component.html
index 189783c63..8af025e11 100644
--- a/tedi/components/layout/header/header-profile/header-profile.component.html
+++ b/tedi/components/layout/header/header-profile/header-profile.component.html
@@ -7,7 +7,7 @@
[variant]="buttonVariant()"
[attr.aria-label]="
breakpointService.isBelowBreakpoint('sm')() || !name()
- ? translationService.track('header.profile')
+ ? translationService.track('header.profile')()
: null
"
(click)="handleModalOpen()"
diff --git a/tedi/components/layout/header/header-profile/header-profile.component.scss b/tedi/components/layout/header/header-profile/header-profile.component.scss
index 268065c2c..602da3967 100644
--- a/tedi/components/layout/header/header-profile/header-profile.component.scss
+++ b/tedi/components/layout/header/header-profile/header-profile.component.scss
@@ -3,16 +3,16 @@ tedi-header-profile {
.tedi-header-profile--mobile {
flex-direction: column;
- justify-content: center;
- align-items: center;
gap: 0;
- border: 0;
- border-radius: 0;
- font-size: 12px;
- line-height: 16px;
+ align-items: center;
+ justify-content: center;
min-width: var(--layout-header-mobile-button-size);
min-height: var(--layout-header-mobile-button-size);
padding: var(--layout-grid-gutters-08);
+ font-size: 12px;
+ line-height: 16px;
+ border: 0;
+ border-radius: 0;
&:focus-visible {
outline: var(--borders-02) solid var(--tedi-primary-500);
@@ -30,26 +30,26 @@ tedi-header-profile {
.tedi-header-profile__overlay {
position: absolute;
- left: 0;
top: var(--layout-header-min-height);
+ left: 0;
+ z-index: calc(var(--z-index-header) - 1);
width: 100%;
min-height: calc(100dvh - var(--layout-header-min-height));
background: rgb(0 0 0 / 25%);
- z-index: calc(var(--z-index-header) - 1);
}
.tedi-header-profile__modal {
- min-height: calc(100dvh - var(--layout-header-min-height));
- max-height: 100%;
position: absolute;
- right: 0;
top: var(--layout-header-min-height);
- overflow-y: auto;
+ right: 0;
+ z-index: var(--z-index-header);
display: flex;
flex-direction: column;
- background: var(--general-surface-primary);
- z-index: var(--z-index-header);
width: var(--navigation-vertical-item-width-default);
+ min-height: calc(100dvh - var(--layout-header-min-height));
+ max-height: 100%;
+ overflow-y: auto;
+ background: var(--general-surface-primary);
> * {
padding: var(--layout-header-modal-item-padding);
@@ -65,9 +65,9 @@ tedi-header-profile {
.tedi-header-logout--mobile {
flex-direction: row;
+ gap: var(--link-inner-spacing-x);
justify-content: flex-start;
font-size: var(--body-regular-size);
- gap: var(--link-inner-spacing-x);
tedi-icon {
font-size: inherit !important;
diff --git a/tedi/components/layout/header/header-role/header-role.component.scss b/tedi/components/layout/header/header-role/header-role.component.scss
index 288ea34fd..e44e7bc63 100644
--- a/tedi/components/layout/header/header-role/header-role.component.scss
+++ b/tedi/components/layout/header/header-role/header-role.component.scss
@@ -7,15 +7,15 @@
.tedi-header-role {
display: flex;
flex-direction: column;
- justify-content: center;
align-items: flex-start;
+ justify-content: center;
&__head {
- width: 100%;
display: flex;
- justify-content: space-between;
- align-items: center;
gap: 4px;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
@include breakpoints.media-breakpoint-up(lg) {
justify-content: flex-start;
@@ -28,12 +28,10 @@
&[data-multiple="true"] {
flex-direction: column;
- gap: 0px;
+ gap: 0;
- .tedi-header-role__description {
- &::before {
- display: none;
- }
+ .tedi-header-role__description::before {
+ display: none;
}
}
@@ -41,14 +39,14 @@
position: relative;
&::before {
- content: "";
- height: 1rem;
- width: 1px;
position: absolute;
top: 50%;
left: calc(-1 * var(--layout-grid-gutters-16) / 2);
- transform: translateY(-50%);
+ width: 1px;
+ height: 1rem;
+ content: "";
background: var(--general-border-secondary);
+ transform: translateY(-50%);
}
}
}
@@ -86,12 +84,12 @@
position: relative;
&::after {
- content: "";
position: absolute;
top: calc(-1 * (var(--layout-grid-gutters-16) / 2 + 1px));
left: 0;
width: 100%;
height: 1px;
+ content: "";
background-color: var(--general-border-primary);
}
}
@@ -99,27 +97,27 @@
}
&__representative {
- width: 100%;
display: flex;
gap: 8px;
align-items: center;
+ width: 100%;
+ padding: var(--card-padding-xs);
+ font-size: var(--body-regular-size);
+ color: var(--general-text-secondary);
text-align: start;
cursor: pointer;
- color: var(--general-text-secondary);
background: transparent;
border: 0;
- font-size: var(--body-regular-size);
border-radius: var(--card-radius-rounded);
- padding: var(--card-padding-xs);
&:not([data-selected="true"]):hover {
- background: var(--header-popover-item-hover);
color: var(--general-text-primary);
+ background: var(--header-popover-item-hover);
}
&:not([data-selected="true"]):active {
- background: var(--header-popover-item-active);
color: var(--general-text-white);
+ background: var(--header-popover-item-active);
}
&:focus-visible {
@@ -128,8 +126,8 @@
}
&[data-selected="true"] {
- background: var(--header-popover-item-selected);
color: var(--general-text-white);
+ background: var(--header-popover-item-selected);
}
tedi-icon {
@@ -138,27 +136,27 @@
}
&__input {
+ gap: var(--form-field-inner-spacing);
width: 100%;
- border: var(--borders-01) solid var(--form-input-border-default);
- background: var(--form-input-background-default);
padding: var(--form-field-padding-y-md-default)
var(--form-field-padding-x-md-default);
+ background: var(--form-input-background-default);
+ border: var(--borders-01) solid var(--form-input-border-default);
border-radius: var(--form-field-radius);
- gap: var(--form-field-inner-spacing);
}
&__no-results {
+ padding-top: var(--layout-grid-gutters-08);
color: var(--general-text-secondary);
text-align: center;
- padding-top: var(--layout-grid-gutters-08);
}
&__collapse {
- width: 100%;
display: grid;
+ visibility: hidden;
grid-template-rows: 0fr;
+ width: 100%;
overflow: hidden;
- visibility: hidden;
transition: grid-template-rows var(--_header-role-transition-duration) ease;
&[data-open="true"] {
@@ -171,24 +169,24 @@
}
&__items {
- min-height: 0;
display: flex;
+ visibility: hidden;
flex-direction: column;
gap: var(--layout-grid-gutters-16);
+ min-height: 0;
transition: visibility var(--_header-role-transition-duration) ease;
- visibility: hidden;
> * {
&:not(:first-child) {
position: relative;
&::after {
- content: "";
position: absolute;
top: calc(-1 * (var(--layout-grid-gutters-16) / 2 + 1px));
left: 0;
width: 100%;
height: 1px;
+ content: "";
background-color: var(--general-border-primary);
}
}
diff --git a/tedi/components/layout/header/header.component.scss b/tedi/components/layout/header/header.component.scss
index d5ca1655e..c674b8cbd 100644
--- a/tedi/components/layout/header/header.component.scss
+++ b/tedi/components/layout/header/header.component.scss
@@ -2,24 +2,24 @@
display: flex;
&__main {
- width: 100%;
display: flex;
- justify-content: space-between;
align-items: center;
- background: var(--header-background);
- box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20, rgba(0, 0, 0, 0.2));
+ justify-content: space-between;
+ width: 100%;
height: var(--layout-header-min-height);
padding: var(--layout-header-padding-y) var(--layout-header-padding-right)
var(--layout-header-padding-y) var(--layout-header-padding-left);
+ background: var(--header-background);
+ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%));
}
&__link-button {
display: inline-flex;
align-items: center;
- background-color: transparent;
- border: 0;
padding: 0;
font-size: var(--body-regular-size);
+ background-color: transparent;
+ border: 0;
&:hover {
span {
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.html b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.html
new file mode 100644
index 000000000..6912a8f25
--- /dev/null
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.html
@@ -0,0 +1,52 @@
+@if (firstItem(); as first) {
+
+
+
+ @if (restItems().length > 0) {
+
+ }
+
+}
+
+
+
+
+
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss
index 7a9a30ae1..515b22ccf 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss
@@ -1,25 +1,62 @@
.tedi-sidenav-dropdown-group {
- .tedi-sidenav-dropdown-item {
- &:first-of-type {
- &::before {
- top: 50%;
- transform: translateY(-50%) translateX(calc(-50% + var(--borders-01)));
- width: var(--_sidenav-tree-bullet-size);
- height: var(--_sidenav-tree-bullet-size);
- border-radius: 50%;
- }
+ --_group-padding-left: var(--navigation-vertical-item-padding-left-level-2);
- &::after {
- width: 0;
- }
+ &__parent-wrapper {
+ position: relative;
+ display: block;
+ list-style: none;
+
+ &::before {
+ position: absolute;
+ top: 0;
+ left: calc(
+ var(--_group-padding-left) + var(--_sidenav-tree-left-padding)
+ );
+ width: var(--_sidenav-tree-trunk-width);
+ height: calc(var(--_sidenav-dropdown-item-height) / 2);
+ content: "";
+ background-color: var(--navigation-vertical-tree-brand-default);
+ }
+ }
+
+ &__list {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
- &:not(:only-child) {
- &::after {
- top: 50%;
- width: var(--_sidenav-tree-trunk-width);
- height: 50%;
- transform: translateY(0);
- }
+ .tedi-sidenav-dropdown-item.tedi-sidenav-dropdown-group__parent {
+ position: relative;
+
+ &::before {
+ position: absolute;
+ top: 50%;
+ left: calc(var(--_padding-left) + var(--_sidenav-tree-left-padding));
+ width: var(--_sidenav-tree-bullet-size);
+ height: var(--_sidenav-tree-bullet-size);
+ content: "";
+ background-color: var(--navigation-vertical-tree-brand-default);
+ border-radius: 50%;
+ transform: translateY(-50%)
+ translateX(calc(-50% + calc(var(--_sidenav-tree-trunk-width) / 2)));
+ }
+
+ &::after {
+ position: absolute;
+ top: 0;
+ left: calc(var(--_padding-left) + var(--_sidenav-tree-left-padding));
+ width: var(--_sidenav-tree-trunk-width);
+ height: 100%;
+ content: "";
+ background-color: var(--navigation-vertical-tree-brand-default);
+ transform: translateY(50%);
+ }
+ }
+
+ &__item {
+ &:last-of-type {
+ &::before {
+ height: 50%;
}
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts
index c4ca795b6..b19b9285e 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts
@@ -1,5 +1,7 @@
+import { QueryList } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { SideNavDropdownGroupComponent } from "./sidenav-dropdown-group.component";
+import { SideNavDropdownItemComponent } from "../sidenav-dropdown-item/sidenav-dropdown-item.component";
describe("SideNavDropdownGroupComponent", () => {
let fixture: ComponentFixture;
@@ -22,4 +24,40 @@ describe("SideNavDropdownGroupComponent", () => {
it("should have the base CSS class", () => {
expect(groupEl.classList.contains("tedi-sidenav-dropdown-group")).toBe(true);
});
+
+ it("should set itemsArray from ContentChildren in ngAfterContentInit", () => {
+ const mockItems = {
+ toArray: () => [{ id: 1 }, { id: 2 }],
+ changes: { subscribe: jest.fn() },
+ } as unknown as QueryList;
+
+ fixture.componentInstance.items = mockItems;
+ fixture.componentInstance.ngAfterContentInit();
+
+ expect(fixture.componentInstance.firstItem()).toEqual({ id: 1 });
+ expect(fixture.componentInstance.restItems()).toEqual([{ id: 2 }]);
+ });
+
+ it("should update itemsArray when items.changes emits", () => {
+ let changeCallback: () => void = () => { };
+ const mockItems = {
+ toArray: jest.fn().mockReturnValue([{ id: 1 }]),
+ changes: {
+ subscribe: (cb: () => void) => {
+ changeCallback = cb;
+ },
+ },
+ } as unknown as QueryList;
+
+ fixture.componentInstance.items = mockItems;
+ fixture.componentInstance.ngAfterContentInit();
+
+ expect(fixture.componentInstance.firstItem()).toEqual({ id: 1 });
+
+ mockItems.toArray = jest.fn().mockReturnValue([{ id: 1 }, { id: 2 }, { id: 3 }]);
+ changeCallback();
+
+ expect(fixture.componentInstance.firstItem()).toEqual({ id: 1 });
+ expect(fixture.componentInstance.restItems()).toEqual([{ id: 2 }, { id: 3 }]);
+ });
});
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts
index e09802dd1..d5c0c9c55 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts
@@ -1,18 +1,45 @@
import {
+ AfterContentInit,
ChangeDetectionStrategy,
Component,
+ computed,
+ ContentChildren,
+ QueryList,
+ signal,
ViewEncapsulation,
} from "@angular/core";
+import { RouterLink } from "@angular/router";
+import { SideNavDropdownItemComponent } from "../sidenav-dropdown-item/sidenav-dropdown-item.component";
@Component({
selector: "tedi-sidenav-dropdown-group",
standalone: true,
- template: " ",
+ templateUrl: "./sidenav-dropdown-group.component.html",
styleUrl: "./sidenav-dropdown-group.component.scss",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
+ imports: [RouterLink],
host: {
"class": "tedi-sidenav-dropdown-group",
+ "role": "presentation",
+ "style": "display: contents",
},
})
-export class SideNavDropdownGroupComponent {}
+export class SideNavDropdownGroupComponent implements AfterContentInit {
+ @ContentChildren(SideNavDropdownItemComponent)
+ items!: QueryList;
+
+ private itemsArray = signal([]);
+
+ firstItem = computed(() => this.itemsArray()[0]);
+ restItems = computed(() => this.itemsArray().slice(1));
+
+ // to keep same component composition structure but rearrange dom elements inside the group for correct html semantics
+ ngAfterContentInit(): void {
+ this.itemsArray.set(this.items.toArray());
+
+ this.items.changes.subscribe(() => {
+ this.itemsArray.set(this.items.toArray());
+ });
+ }
+}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html
index 39e23eebf..2d6f9108a 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html
@@ -1,14 +1,18 @@
-@if (href()) {
-
-
-
-} @else if (route()) {
-
-
-
-} @else {
-
-}
+
+ @if (href()) {
+
+
+
+ } @else if (route()) {
+
+
+
+ } @else {
+
+
+
+ }
+
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss
index 0703eaee1..ec6e28d91 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss
@@ -2,65 +2,71 @@
--_padding-left: var(--navigation-vertical-item-padding-left-level-2);
--_gap: var(--navigation-vertical-item-inner-spacing);
- min-height: var(--_sidenav-dropdown-item-height);
position: relative;
display: block;
+ min-height: var(--_sidenav-dropdown-item-height);
color: var(--navigation-vertical-item-text);
- padding-top: var(--_sidenav-item-padding-y);
- padding-bottom: var(--_sidenav-item-padding-y);
- padding-right: var(--_sidenav-item-padding-right);
- padding-left: calc(
- var(--_padding-left) + var(--_sidenav-tree-container) + var(--_gap)
- );
- cursor: pointer;
&:hover {
background: var(--navigation-vertical-item-background-hover);
}
&--selected {
- background: var(--navigation-vertical-item-background-active);
font-weight: var(--body-bold-weight);
+ background: var(--navigation-vertical-item-background-active);
+ }
+
+ &__trigger {
+ display: block;
+ padding: var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-right)
+ var(--_sidenav-item-padding-y)
+ calc(var(--_padding-left) + var(--_sidenav-tree-container) + var(--_gap));
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: none;
+ background: var(--navigation-vertical-item-background-focus);
+ box-shadow: var(--_sidenav-focus-ring);
+ }
}
&::before {
- content: "";
position: absolute;
top: 0;
left: calc(var(--_padding-left) + var(--_sidenav-tree-left-padding));
width: var(--_sidenav-tree-trunk-width);
height: 100%;
+ content: "";
background-color: var(--navigation-vertical-tree-brand-default);
}
&::after {
- content: "";
position: absolute;
top: 50%;
left: calc(var(--_padding-left) + var(--_sidenav-tree-left-padding));
width: var(--_sidenav-tree-branch-width);
height: var(--_sidenav-tree-trunk-width);
- transform: translateY(-50%);
+ content: "";
background-color: var(--navigation-vertical-tree-brand-default);
+ transform: translateY(-50%);
}
&--parent {
- padding-left: var(--dropdown-item-padding-x);
+ .tedi-sidenav-dropdown-item__trigger {
+ padding-left: var(--dropdown-item-padding-x);
+ }
&::before,
&::after {
display: none;
}
}
+}
- &:last-of-type {
- &::before {
- height: 50%;
- }
- }
-
- a {
- text-decoration: none;
- color: inherit;
- }
+tedi-sidenav-dropdown-item:last-child > .tedi-sidenav-dropdown-item::before,
+tedi-sidenav-dropdown-item:has(+ tedi-sidenav-dropdown-group)
+ > .tedi-sidenav-dropdown-item::before {
+ height: 50%;
}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts
index 423a02596..bd52b0313 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts
@@ -3,7 +3,7 @@ import { SideNavDropdownItemComponent } from "./sidenav-dropdown-item.component"
describe("SideNavDropdownItemComponent", () => {
let fixture: ComponentFixture;
- let dropdownItemEl: HTMLElement;
+ let liElement: HTMLLIElement;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -11,21 +11,30 @@ describe("SideNavDropdownItemComponent", () => {
});
fixture = TestBed.createComponent(SideNavDropdownItemComponent);
- dropdownItemEl = fixture.nativeElement;
fixture.detectChanges();
+ liElement = fixture.nativeElement.querySelector("li");
});
it("should create the component", () => {
expect(fixture.componentInstance).toBeTruthy();
});
- it("should have the base CSS class", () => {
- expect(dropdownItemEl.classList.contains("tedi-sidenav-dropdown-item")).toBe(true);
+ it("should have the base CSS class on li element", () => {
+ expect(liElement.classList.contains("tedi-sidenav-dropdown-item")).toBe(true);
});
it("should add selected class when `selected` input is true", () => {
fixture.componentRef.setInput("selected", true);
fixture.detectChanges();
- expect(dropdownItemEl.classList.contains("tedi-sidenav-dropdown-item--selected")).toBe(true);
+ expect(liElement.classList.contains("tedi-sidenav-dropdown-item--selected")).toBe(true);
});
+
+ it("should set textContent value in ngAfterViewInit when text exists", () => {
+ fixture.nativeElement.textContent = "Test Item Text";
+
+ fixture.componentInstance.ngAfterViewInit();
+
+ expect(fixture.componentInstance.textContent()).toBe("Test Item Text");
+ });
+
});
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts
index 78f11c87f..3ec55687e 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts
@@ -1,9 +1,13 @@
import { NgTemplateOutlet } from "@angular/common";
import {
+ AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
+ ElementRef,
+ inject,
input,
+ signal,
ViewEncapsulation,
} from "@angular/core";
import { RouterLink } from "@angular/router";
@@ -17,11 +21,11 @@ import { RouterLink } from "@angular/router";
encapsulation: ViewEncapsulation.None,
imports: [RouterLink, NgTemplateOutlet],
host: {
- role: "menuitem",
- "[class]": "classes()",
+ "role": "presentation",
+ "style": "display: contents",
},
})
-export class SideNavDropdownItemComponent {
+export class SideNavDropdownItemComponent implements AfterViewInit {
/**
* Is navigation item selected
* @default false
@@ -36,6 +40,19 @@ export class SideNavDropdownItemComponent {
*/
route = input();
+ textContent = signal("");
+
+ private readonly host = inject(ElementRef);
+
+ ngAfterViewInit(): void {
+ if (this.host.nativeElement) {
+ const text = this.host.nativeElement.textContent?.trim();
+ if (text) {
+ this.textContent.set(text);
+ }
+ }
+ }
+
classes = computed(() => {
const classList = ["tedi-sidenav-dropdown-item"];
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html
index 992f44017..541fca773 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html
@@ -1,4 +1,4 @@
-
+
@if (sidenavService.isCollapsed() && !!sidenavItem.href()) {
}
-
+
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss
index 2015fd548..7b1858fff 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss
@@ -1,22 +1,26 @@
-.tedi-sidenav-dropdown {
+.tedi-sidenav-dropdown-wrapper {
display: grid;
grid-template-rows: 0fr;
- overflow: hidden;
- visibility: hidden;
transition: grid-template-rows var(--_sidenav-transition-duration) ease;
- &--open {
- visibility: visible;
+ &:has(.tedi-sidenav-dropdown--open) {
grid-template-rows: 1fr;
-
- .tedi-sidenav-dropdown__items {
- visibility: visible;
- }
}
+}
- &__items {
- min-height: 0;
- transition: visibility var(--_sidenav-transition-duration) ease;
- visibility: hidden;
+.tedi-sidenav-dropdown {
+ visibility: hidden;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+ list-style: none;
+
+ // z-index and block offsets for correct focus visuals
+ &--open {
+ position: relative;
+ z-index: 1;
+ visibility: visible;
+ padding-block: 4px;
+ margin-block: -4px;
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts
index c1266c6cf..0c33db271 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts
@@ -51,15 +51,19 @@ describe("SideNavDropdownComponent", () => {
});
fixture = TestBed.createComponent(SideNavDropdownComponent);
- dropdownElement = fixture.nativeElement;
fixture.detectChanges();
+ dropdownElement = fixture.nativeElement.querySelector("ul");
});
it("should create the component", () => {
expect(fixture.componentInstance).toBeTruthy();
});
- it("should have the base CSS class", () => {
+ it("should have wrapper class on host element", () => {
+ expect(fixture.nativeElement.classList.contains("tedi-sidenav-dropdown-wrapper")).toBe(true);
+ });
+
+ it("should have the base CSS class on ul element", () => {
expect(dropdownElement.classList.contains("tedi-sidenav-dropdown")).toBe(true);
});
@@ -72,6 +76,6 @@ describe("SideNavDropdownComponent", () => {
it("ngAfterViewInit should set the `element` signal to the host element", () => {
fixture.componentInstance.element.set(null);
fixture.componentInstance.ngAfterViewInit();
- expect(fixture.componentInstance.element()).toBe(dropdownElement);
+ expect(fixture.componentInstance.element()).toBe(fixture.nativeElement);
});
});
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts
index bedae79ed..ba87c6e2e 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts
@@ -22,8 +22,7 @@ import { SideNavService } from "../../../../services/sidenav/sidenav.service";
encapsulation: ViewEncapsulation.None,
imports: [SideNavDropdownItemComponent],
host: {
- role: "menubar",
- "[class]": "classes()",
+ "class": "tedi-sidenav-dropdown-wrapper",
},
})
export class SideNavDropdownComponent implements AfterViewInit {
diff --git a/tedi/components/layout/sidenav/sidenav-group-title/sidenav-group-title.component.scss b/tedi/components/layout/sidenav/sidenav-group-title/sidenav-group-title.component.scss
index 034eb7c56..7bf2c9b54 100644
--- a/tedi/components/layout/sidenav/sidenav-group-title/sidenav-group-title.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-group-title/sidenav-group-title.component.scss
@@ -1,13 +1,13 @@
.tedi-sidenav-group-title {
.tedi-sidenav-group-title__text {
- color: var(--navigation-vertical-group-title-text);
- font-size: var(--body-extra-small-bold-size);
- font-weight: var(--body-extra-small-bold-weight);
- line-height: var(--body-extra-small-bold-line-height);
- text-transform: uppercase;
padding: var(--layout-grid-gutters-16)
var(--navigation-vertical-item-padding-right)
var(--layout-grid-gutters-02)
var(--navigation-vertical-item-padding-left-level-1);
+ font-size: var(--body-extra-small-bold-size);
+ font-weight: var(--body-extra-small-bold-weight);
+ line-height: var(--body-extra-small-bold-line-height);
+ color: var(--navigation-vertical-group-title-text);
+ text-transform: uppercase;
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html
index aa027ba41..94194c593 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html
@@ -1,45 +1,51 @@
-@if (sidenavService.isMobile()) {
- @if (sidenavService.isMobileItemOpen() && dropdown?.open()) {
-
-
-
-
-
-
-
-
+
+ @if (sidenavService.isMobile()) {
+ @if (sidenavService.isMobileItemOpen() && dropdown?.open()) {
+
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+ } @else {
+
+
+
+ }
+ } @else if (sidenavService.tooltipEnabled()) {
+
+
+
+
+
+
+
+ {{ textContent() }}
+
+
} @else {
}
-} @else if (sidenavService.tooltipEnabled()) {
-
-
-
-
-
- {{ textContent() }}
-
-
-} @else {
-
-
-
-}
+
+
+
-
+
@if (dropdown) {
-
+
-
+ @if (route()) {
-
-
+ } @else {
-
+ }
@if (dropdown) {
+ {{ 'sidenav.toggleSubmenu' | tediTranslate: textContent(): dropdown.open() }}
-
-
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss
index 076126338..f3238cde7 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss
@@ -21,11 +21,12 @@
&__trigger {
--_gap: var(--navigation-vertical-item-inner-spacing);
- width: 100%;
+
position: relative;
display: flex;
- cursor: pointer;
gap: var(--_gap);
+ width: 100%;
+ cursor: pointer;
&:hover {
background: var(--navigation-vertical-item-background-hover);
@@ -37,24 +38,27 @@
}
&__title {
- width: 100%;
- min-height: var(--_sidenav-item-min-height);
display: flex;
- align-items: center;
gap: var(--_gap);
- text-decoration: none;
- text-align: start;
- border: 0;
- background: transparent;
- font-size: inherit;
- cursor: pointer;
- color: var(--navigation-vertical-item-text);
+ align-items: center;
+ width: 100%;
+ min-height: var(--_sidenav-item-min-height);
padding: var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-right)
var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-left);
+ font-size: inherit;
+ color: var(--navigation-vertical-item-text);
+ text-align: start;
+ text-decoration: none;
+ cursor: pointer;
+ background: transparent;
+ border: 0;
&:focus-visible {
- outline: var(--borders-01) solid var(--tedi-neutral-100);
- outline-offset: -2px;
+ position: relative;
+ z-index: 1;
+ outline: none;
+ background: var(--navigation-vertical-item-background-focus);
+ box-shadow: var(--_sidenav-focus-ring);
}
&:not(:only-child) {
@@ -72,15 +76,14 @@
}
&__caret-button {
- min-height: var(--_sidenav-item-min-height);
display: flex;
- align-items: center;
- justify-items: center;
- border: 0;
- background: transparent;
+ place-items: center center;
+ min-height: var(--_sidenav-item-min-height);
+ padding: 0;
font-size: inherit;
cursor: pointer;
- padding: 0;
+ background: transparent;
+ border: 0;
&:hover {
.tedi-sidenav-item__caret-container {
@@ -91,9 +94,12 @@
}
&:focus-visible {
+ position: relative;
+ z-index: 1;
+ outline: none;
+
.tedi-sidenav-item__caret-container {
- outline: var(--borders-02) solid var(--tedi-neutral-100);
- outline-offset: var(--borders-01);
+ box-shadow: var(--_sidenav-focus-ring);
}
}
@@ -104,17 +110,17 @@
&__caret-container {
display: flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
width: fit-content;
height: fit-content;
border-radius: var(--button-radius-sm);
}
&__caret {
+ margin-left: auto;
font-size: var(--_sidenav-item-caret-size) !important;
transition: transform var(--_sidenav-transition-duration) ease;
- margin-left: auto;
&[data-open="true"] {
transform: rotate(180deg);
@@ -122,22 +128,30 @@
}
&__text {
- white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ white-space: nowrap;
animation: enable-wrap 0s var(--_sidenav-transition-duration) forwards;
}
&__link {
- text-decoration: none;
- color: var(--navigation-vertical-item-text);
padding: var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-right)
var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-left);
+ color: var(--navigation-vertical-item-text);
+ text-decoration: none;
&:hover {
background: var(--navigation-vertical-item-background-hover);
}
+ &:focus-visible {
+ position: relative;
+ z-index: 1;
+ outline: none;
+ background: var(--navigation-vertical-item-background-focus);
+ box-shadow: var(--_sidenav-focus-ring);
+ }
+
.tedi-sidenav-item__text {
white-space: wrap;
}
@@ -146,7 +160,7 @@
@keyframes enable-wrap {
to {
- white-space: normal;
max-width: none;
+ white-space: normal;
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts
index 3ded8a917..216a92e11 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts
@@ -3,7 +3,23 @@ import { signal } from "@angular/core";
import { SideNavItemComponent } from "./sidenav-item.component";
import { SideNavService } from "../../../../services/sidenav/sidenav.service";
+const mockCallbackHolder: { callback: (() => void) | null } = { callback: null };
+
+jest.mock("@angular/core", () => {
+ const actual = jest.requireActual("@angular/core");
+ return {
+ ...actual,
+ afterNextRender: jest.fn((callback: () => void, _options?: unknown) => {
+ mockCallbackHolder.callback = callback;
+ return { destroy: jest.fn() };
+ }),
+ };
+});
+
describe("SideNavItemComponent", () => {
+ afterEach(() => {
+ mockCallbackHolder.callback = null;
+ });
let fixture: ComponentFixture;
let itemElement: HTMLElement;
let sidenavService: {
@@ -21,16 +37,16 @@ describe("SideNavItemComponent", () => {
beforeEach(() => {
sidenavService = {
- items: signal([]),
- isCollapsed: signal(false),
- isMobile: signal(false),
- isMobileItemOpen: signal(false),
- isMobileOpen: signal(false),
- tooltipEnabled: signal(false),
- registerItem: jest.fn(),
- unregisterItem: jest.fn(),
- handleGoToMainMenu: jest.fn(),
- handleCollapse: jest.fn()
+ items: signal([]),
+ isCollapsed: signal(false),
+ isMobile: signal(false),
+ isMobileItemOpen: signal(false),
+ isMobileOpen: signal(false),
+ tooltipEnabled: signal(false),
+ registerItem: jest.fn(),
+ unregisterItem: jest.fn(),
+ handleGoToMainMenu: jest.fn(),
+ handleCollapse: jest.fn()
};
TestBed.configureTestingModule({
@@ -41,8 +57,8 @@ describe("SideNavItemComponent", () => {
});
fixture = TestBed.createComponent(SideNavItemComponent);
- itemElement = fixture.nativeElement;
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
});
it("should register on init and unregister on destroy", () => {
@@ -51,12 +67,15 @@ describe("SideNavItemComponent", () => {
expect(sidenavService.unregisterItem).toHaveBeenCalledWith(fixture.componentInstance);
});
- it("should always have base class", () => {
+ it("should always have base class on li element", () => {
expect(itemElement.classList.contains("tedi-sidenav-item")).toBe(true);
});
it("should read textContent in ngAfterViewInit", () => {
- itemElement.innerHTML = `Item Text `;
+ const textSpan = itemElement.querySelector(".tedi-sidenav-item__text");
+ if (textSpan) {
+ textSpan.textContent = "Item Text";
+ }
fixture.componentInstance.ngAfterViewInit();
expect(fixture.componentInstance.textContent()).toBe("Item Text");
});
@@ -64,12 +83,14 @@ describe("SideNavItemComponent", () => {
it("should add selected class when selected input is true", () => {
fixture.componentRef.setInput("selected", true);
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
expect(itemElement.classList.contains("tedi-sidenav-item--selected")).toBe(true);
});
it("should add hidden class when mobile item open and no dropdown open", () => {
sidenavService.isMobileItemOpen.set(true);
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
expect(itemElement.classList.contains("tedi-sidenav-item--hidden")).toBe(true);
});
@@ -79,6 +100,7 @@ describe("SideNavItemComponent", () => {
fixture.componentInstance.dropdown = dropdownStub as any;
sidenavService.isMobileItemOpen.set(true);
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
expect(itemElement.classList.contains("tedi-sidenav-item--hidden")).toBe(false);
});
@@ -113,4 +135,140 @@ describe("SideNavItemComponent", () => {
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(dropdownStub.open()).toBe(false);
});
+
+ it("Escape key should close dropdown and focus trigger when collapsed", async () => {
+ const dropdownStub = {
+ open: signal(true),
+ element: () => document.createElement("div"),
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+ fixture.componentInstance.ngAfterViewInit();
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+
+ expect(dropdownStub.open()).toBe(false);
+ });
+
+
+ it("toggleDropdown should do nothing when no dropdown", () => {
+ fixture.componentInstance.dropdown = undefined;
+ expect(() => fixture.componentInstance.toggleDropdown()).not.toThrow();
+ });
+
+ it("toggleDropdown should focus first dropdown item when opening in collapsed mode", () => {
+ const openSignal = signal(false);
+ const mockDropdownEl = document.createElement("div");
+ const mockUl = document.createElement("ul");
+ mockUl.className = "tedi-sidenav-dropdown";
+ const mockTrigger = document.createElement("a");
+ mockTrigger.className = "tedi-sidenav-dropdown-item__trigger";
+ Object.defineProperty(mockTrigger, "offsetParent", { value: document.body, configurable: true });
+ const focusSpy = jest.spyOn(mockTrigger, "focus");
+ mockUl.appendChild(mockTrigger);
+ mockDropdownEl.appendChild(mockUl);
+ document.body.appendChild(mockDropdownEl);
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ fixture.componentInstance.toggleDropdown();
+ expect(openSignal()).toBe(true);
+
+ // run afterNextRender
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(focusSpy).toHaveBeenCalled();
+ document.body.removeChild(mockDropdownEl);
+ });
+
+ it("toggleDropdown should focus trigger when closing in collapsed mode", () => {
+ const openSignal = signal(true);
+ const mockDropdownEl = document.createElement("div");
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ const actualTriggerBtn = fixture.nativeElement.querySelector(".tedi-sidenav-item__title") as HTMLElement;
+ const focusSpy = jest.spyOn(actualTriggerBtn, "focus");
+
+ fixture.componentInstance.toggleDropdown();
+ expect(openSignal()).toBe(false);
+
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(focusSpy).toHaveBeenCalled();
+ });
+
+ it("toggleDropdown should trigger focus management when mobile", () => {
+ const openSignal = signal(false);
+ const mockDropdownEl = document.createElement("div");
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+
+ sidenavService.isMobile.set(true);
+ fixture.detectChanges();
+
+ fixture.componentInstance.toggleDropdown();
+
+ expect(openSignal()).toBe(true);
+ expect(mockCallbackHolder.callback).not.toBeNull();
+ });
+
+ it("Escape key handler should focus trigger after closing", () => {
+ jest.useFakeTimers();
+
+ const openSignal = signal(true);
+ const mockDropdownEl = document.createElement("div");
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+ fixture.componentInstance.ngAfterViewInit();
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ const actualTriggerBtn = fixture.nativeElement.querySelector(".tedi-sidenav-item__title") as HTMLElement;
+ const focusSpy = jest.spyOn(actualTriggerBtn, "focus");
+
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+
+ expect(openSignal()).toBe(false);
+
+ jest.runAllTimers();
+
+ expect(focusSpy).toHaveBeenCalled();
+
+ jest.useRealTimers();
+ });
});
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts
index c64025550..fa1b00537 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts
@@ -1,4 +1,5 @@
import {
+ afterNextRender,
AfterViewInit,
ChangeDetectionStrategy,
Component,
@@ -7,6 +8,7 @@ import {
ElementRef,
forwardRef,
inject,
+ Injector,
input,
OnDestroy,
OnInit,
@@ -23,6 +25,7 @@ import { SideNavService } from "../../../../services/sidenav/sidenav.service";
import { TooltipComponent } from "../../../overlay/tooltip/tooltip.component";
import { TooltipContentComponent } from "../../../overlay/tooltip/tooltip-content/tooltip-content.component";
import { TooltipTriggerComponent } from "../../../overlay/tooltip/tooltip-trigger/tooltip-trigger.component";
+import { TediTranslationPipe } from "../../../../services/translation/translation.pipe";
@Component({
selector: "tedi-sidenav-item",
@@ -40,10 +43,11 @@ import { TooltipTriggerComponent } from "../../../overlay/tooltip/tooltip-trigge
TooltipComponent,
TooltipTriggerComponent,
TooltipContentComponent,
+ TediTranslationPipe,
],
host: {
- role: "menuitem",
- "[class]": "classes()",
+ "role": "presentation",
+ "style": "display: contents",
},
})
export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
@@ -68,11 +72,12 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
@ContentChild(forwardRef(() => SideNavDropdownComponent))
dropdown?: SideNavDropdownComponent;
- textContent = signal("");
+ textContent = signal('');
sidenavService = inject(SideNavService);
private readonly host = inject(ElementRef);
private readonly renderer = inject(Renderer2);
+ private readonly injector = inject(Injector);
private readonly eventListeners: (() => void)[] = [];
ngOnInit() {
@@ -81,6 +86,7 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
ngOnDestroy() {
this.sidenavService.unregisterItem(this);
+ this.eventListeners.forEach((unlisten) => unlisten());
}
ngAfterViewInit(): void {
@@ -112,6 +118,19 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
}
}),
);
+
+ this.eventListeners.push(
+ this.renderer.listen("document", "keydown", (event: KeyboardEvent) => {
+ if (event.key === "Escape" && this.sidenavService.isCollapsed() && dropdown.open()) {
+ dropdown.open.set(false);
+ setTimeout(() => {
+ const hostEl = this.host.nativeElement as HTMLElement;
+ const trigger = hostEl.querySelector('.tedi-sidenav-item__title') as HTMLElement | null;
+ trigger?.focus();
+ }, 0);
+ }
+ }),
+ );
}
classes = computed(() => {
@@ -133,6 +152,29 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
return;
}
+ const wasOpen = this.dropdown.open();
+ const dropdown = this.dropdown;
+
this.dropdown.open.update((prev) => !prev);
+
+ if (this.sidenavService.isCollapsed() || this.sidenavService.isMobile()) {
+ afterNextRender(() => {
+ if (!wasOpen) {
+ // Opening - focus first item in dropdown
+ const dropdownEl = dropdown.element();
+ const openDropdown = dropdownEl?.querySelector('ul.tedi-sidenav-dropdown');
+ const allTriggers = openDropdown?.querySelectorAll('.tedi-sidenav-dropdown-item__trigger');
+ const firstFocusable = Array.from(allTriggers ?? []).find(
+ (el) => (el as HTMLElement).offsetParent !== null
+ ) as HTMLElement | null;
+ firstFocusable?.focus();
+ } else {
+ // Closing - focus on parent item
+ const hostEl = this.host.nativeElement as HTMLElement;
+ const trigger = hostEl.querySelector('.tedi-sidenav-item__title') as HTMLElement | null;
+ trigger?.focus();
+ }
+ }, { injector: this.injector });
+ }
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-overlay/sidenav-overlay.component.scss b/tedi/components/layout/sidenav/sidenav-overlay/sidenav-overlay.component.scss
index 1772f6851..dac4d7eb5 100644
--- a/tedi/components/layout/sidenav/sidenav-overlay/sidenav-overlay.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-overlay/sidenav-overlay.component.scss
@@ -2,13 +2,13 @@
display: none;
&--visible {
- width: 100%;
- height: 100%;
- display: block;
position: fixed;
top: 0;
left: 0;
- background: rgb(0 0 0 / 25%);
z-index: calc(var(--z-index-sidenav) - 1);
+ display: block;
+ width: 100%;
+ height: 100%;
+ background: rgb(0 0 0 / 25%);
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss
index 7ec33673a..c1543e4df 100644
--- a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss
@@ -1,15 +1,15 @@
.tedi-sidenav-toggle {
position: relative;
- width: 3.5rem;
- height: 3.5rem;
+ z-index: var(--z-index-sidenav);
display: inline-flex;
- justify-content: center;
align-items: center;
+ justify-content: center;
+ width: 3.5rem;
+ height: 3.5rem;
+ cursor: pointer;
background: var(--button-main-primary-background-default);
border: 0;
border-radius: 0;
- cursor: pointer;
- z-index: var(--z-index-sidenav);
&--hidden {
display: none;
diff --git a/tedi/components/layout/sidenav/sidenav.component.html b/tedi/components/layout/sidenav/sidenav.component.html
index 4f7f188f4..ffdb323e1 100644
--- a/tedi/components/layout/sidenav/sidenav.component.html
+++ b/tedi/components/layout/sidenav/sidenav.component.html
@@ -15,12 +15,12 @@
@if (sidenavService.isMobileItemOpen()) {
{{ "sidenav.backToMainMenu" | tediTranslate }}
}
-
+
diff --git a/tedi/components/layout/sidenav/sidenav.component.scss b/tedi/components/layout/sidenav/sidenav.component.scss
index 458a31c68..97484b907 100644
--- a/tedi/components/layout/sidenav/sidenav.component.scss
+++ b/tedi/components/layout/sidenav/sidenav.component.scss
@@ -7,59 +7,65 @@
var(--_sidenav-tree-container) - var(--_sidenav-tree-trunk-width)
) /
2;
-
--_sidenav-dropdown-collapsed-min-width: 16.5rem;
--_sidenav-dropdown-collapsed-left: 4px;
--_sidenav-dropdown-item-large-height: 48px;
--_sidenav-dropdown-item-medium-height: 44px;
--_sidenav-dropdown-item-small-height: 36px;
--_sidenav-dropdown-item-collapsed-height: 40px;
-
--_sidenav-collapsed-text-width: 4.5rem;
--_sidenav-transition-duration: 300ms;
+ --_sidenav-focus-ring:
+ 0 0 0 1px var(--tedi-neutral-100), 0 0 0 3px var(--tedi-primary-400);
}
.tedi-sidenav {
+ &__list {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+
position: relative;
+ z-index: var(--z-index-sidenav);
display: flex;
flex-direction: column;
+ width: var(--navigation-vertical-item-width-default);
max-width: 100%;
min-height: 100%;
background-color: var(--navigation-vertical-item-background-default);
transition: all var(--_sidenav-transition-duration) ease;
- z-index: var(--z-index-sidenav);
- width: var(--navigation-vertical-item-width-default);
&__collapse {
position: absolute;
+ top: var(--navigation-vertical-item-padding-y);
right: 0;
- transform: translateX(50%);
+ z-index: var(--z-index-sidenav);
display: flex;
- justify-content: center;
align-items: center;
- border: var(--borders-01) solid
- var(--button-floating-secondary-border-default);
- background: var(--button-floating-secondary-background-default);
- z-index: var(--z-index-sidenav);
- cursor: pointer;
- top: var(--navigation-vertical-item-padding-y);
+ justify-content: center;
width: var(--button-sm-height);
height: var(--button-sm-height);
padding: var(--button-md-icon-padding);
+ cursor: pointer;
+ background: var(--button-floating-secondary-background-default);
+ border: var(--borders-01) solid
+ var(--button-floating-secondary-border-default);
border-radius: var(--button-radius-default);
+ transform: translateX(50%);
&:hover {
color: var(--button-floating-secondary-text-hover);
+ background: var(--button-floating-secondary-background-hover);
border: var(--borders-01) solid
var(--button-floating-secondary-border-hover);
- background: var(--button-floating-secondary-background-hover);
}
&:active {
color: var(--button-floating-secondary-text-active);
+ background: var(--button-floating-secondary-background-active);
border: var(--borders-01) solid
var(--button-floating-secondary-border-active);
- background: var(--button-floating-secondary-background-active);
}
&:focus-visible {
@@ -127,13 +133,13 @@
}
&--dividers {
- .tedi-sidenav-item {
+ tedi-sidenav-item > .tedi-sidenav-item {
border-bottom: var(--borders-01) solid
var(--navigation-vertical-item-border);
+ }
- &:last-of-type {
- border-bottom: 0;
- }
+ tedi-sidenav-item:last-child > .tedi-sidenav-item {
+ border-bottom: 0;
}
}
@@ -173,15 +179,20 @@
width: var(--navigation-vertical-item-width-collapsed);
+ tedi-tooltip-trigger {
+ display: block;
+ width: 100%;
+ }
+
.tedi-sidenav-item {
--_sidenav-item-font-size: var(--navigation-vertical-text-size-sm);
.tedi-sidenav-item__title {
flex-direction: column;
- text-align: center;
min-height: var(--navigation-vertical-item-min-height-large);
padding: var(--navigation-vertical-item-padding-y)
var(--navigation-vertical-item-padding-x-sm);
+ text-align: center;
}
.tedi-sidenav-item__caret {
@@ -196,8 +207,8 @@
}
.tedi-sidenav-item__text {
- white-space: nowrap;
max-width: var(--_sidenav-collapsed-text-width);
+ white-space: nowrap;
animation: none;
}
}
@@ -212,16 +223,16 @@
}
.tedi-sidenav-dropdown {
- min-width: var(--_sidenav-dropdown-collapsed-min-width);
position: absolute;
top: 0;
left: calc(100% + var(--_sidenav-dropdown-collapsed-left));
+ visibility: hidden;
+ min-width: var(--_sidenav-dropdown-collapsed-min-width);
background: var(--dropdown-item-default-background);
border: var(--borders-01) solid var(--card-border-primary);
- box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20);
- transition: none;
- visibility: hidden;
border-radius: var(--form-select-area-radius);
+ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20);
+ transition: none;
&--open {
visibility: visible;
@@ -232,12 +243,12 @@
.tedi-sidenav-group-title__text {
display: block;
- color: var(--general-text-tertiary);
- font-size: 14px;
- line-height: 20px;
padding: var(--dropdown-group-label-padding-y)
var(--dropdown-group-label-padding-x) var(--layout-grid-gutters-04)
var(--dropdown-group-label-padding-x);
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--general-text-tertiary);
}
}
}
@@ -245,9 +256,19 @@
.tedi-sidenav-dropdown-item {
--_gap: var(--dropdown-item-inner-spacing);
- color: var(--dropdown-item-default-text);
- padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
font-size: var(--body-regular-size);
+ color: var(--dropdown-item-default-text);
+
+ &__trigger {
+ padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
+
+ &:focus-visible {
+ color: var(--dropdown-item-hover-text);
+ outline: none;
+ background-color: var(--dropdown-item-hover-background);
+ box-shadow: var(--_sidenav-focus-ring);
+ }
+ }
&.tedi-sidenav-dropdown-item--selected,
&:hover {
@@ -257,49 +278,49 @@
&::before,
&::after {
- height: 0;
width: 0;
+ height: 0;
}
}
.tedi-sidenav-dropdown-group {
- .tedi-sidenav-dropdown-item {
- padding-left: calc(
- var(--dropdown-item-padding-x) + var(--_sidenav-tree-container) +
- var(--_gap)
- );
+ &__parent-wrapper::before {
+ display: none;
+ }
+
+ .tedi-sidenav-dropdown-item.tedi-sidenav-dropdown-group__parent {
+ .tedi-sidenav-dropdown-item__trigger {
+ padding-left: var(--dropdown-item-padding-x);
+ }
+
+ &::before,
+ &::after {
+ display: none;
+ }
+ }
+
+ .tedi-sidenav-dropdown-item.tedi-sidenav-dropdown-group__item {
+ .tedi-sidenav-dropdown-item__trigger {
+ padding-left: calc(
+ var(--dropdown-item-padding-x) + var(--_sidenav-tree-container) +
+ var(--_gap)
+ );
+ }
&::after {
+ top: 50%;
width: var(--_sidenav-tree-branch-width);
height: var(--_sidenav-tree-trunk-width);
- top: 50%;
- transform: translateY(-50%);
background-color: var(--navigation-vertical-tree-neutral-default);
+ transform: translateY(-50%);
}
&::before {
- height: 100%;
width: var(--_sidenav-tree-trunk-width);
+ height: 100%;
background-color: var(--navigation-vertical-tree-neutral-default);
}
- &:first-of-type {
- padding-left: var(--dropdown-item-padding-x);
-
- &::after {
- width: 0;
- height: 0;
- }
-
- &:not(:only-child) {
- &::after,
- &::before {
- width: 0;
- height: 0;
- }
- }
- }
-
&:last-of-type {
&::before {
height: 50%;
@@ -311,18 +332,18 @@
.tedi-sidenav-back {
min-height: var(--_sidenav-item-min-height);
- font-size: var(--_sidenav-item-font-size);
- line-height: var(--_sidenav-item-line-height);
padding: var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-right)
var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-left);
+ font-size: var(--_sidenav-item-font-size);
+ font-size: inherit;
+ line-height: var(--_sidenav-item-line-height);
color: var(--navigation-vertical-item-text);
text-align: start;
+ cursor: pointer;
+ background: transparent;
border: 0;
border-bottom: var(--borders-01) solid
var(--navigation-vertical-item-border);
- background: transparent;
- font-size: inherit;
- cursor: pointer;
&:hover {
background: var(--navigation-vertical-item-background-hover);
diff --git a/tedi/components/layout/sidenav/sidenav.component.spec.ts b/tedi/components/layout/sidenav/sidenav.component.spec.ts
index e3605ad1c..5a85585c5 100644
--- a/tedi/components/layout/sidenav/sidenav.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav.component.spec.ts
@@ -4,6 +4,19 @@ import { SideNavService } from "../../../services/sidenav/sidenav.service";
import { signal } from "@angular/core";
import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+const mockCallbackHolder: { callback: (() => void) | null } = { callback: null };
+
+jest.mock("@angular/core", () => {
+ const actual = jest.requireActual("@angular/core");
+ return {
+ ...actual,
+ afterNextRender: jest.fn((callback: () => void, _options?: unknown) => {
+ mockCallbackHolder.callback = callback;
+ return { destroy: jest.fn() };
+ }),
+ };
+});
+
describe("SideNavComponent", () => {
let fixture: ComponentFixture;
let sidenavElement: HTMLElement;
@@ -112,4 +125,65 @@ describe("SideNavComponent", () => {
true,
);
});
+
+ describe("handleBackToMainMenu", () => {
+ afterEach(() => {
+ mockCallbackHolder.callback = null;
+ });
+
+ it("should call service.handleGoToMainMenu", () => {
+ fixture.componentInstance.handleBackToMainMenu();
+ expect(sidenavService.handleGoToMainMenu).toHaveBeenCalled();
+ });
+
+ it("should find the open item before closing", () => {
+ const openSignal = signal(true);
+ const mockItem = {
+ dropdown: { open: openSignal },
+ host: { nativeElement: document.createElement("div") },
+ };
+
+ sidenavService.items.set([mockItem as never]);
+
+ fixture.componentInstance.handleBackToMainMenu();
+ expect(sidenavService.handleGoToMainMenu).toHaveBeenCalled();
+ });
+
+ it("should focus the trigger of the previously open item after closing", () => {
+ const openSignal = signal(true);
+ const mockHostEl = document.createElement("div");
+ const mockTriggerBtn = document.createElement("button");
+ mockTriggerBtn.className = "tedi-sidenav-item__title";
+ mockHostEl.appendChild(mockTriggerBtn);
+
+ const focusSpy = jest.spyOn(mockTriggerBtn, "focus");
+
+ const mockItem = {
+ dropdown: { open: openSignal },
+ host: { nativeElement: mockHostEl },
+ };
+
+ sidenavService.items.set([mockItem as never]);
+
+ fixture.componentInstance.handleBackToMainMenu();
+
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(focusSpy).toHaveBeenCalled();
+ });
+
+ it("should not throw when no item is open", () => {
+ sidenavService.items.set([]);
+
+ fixture.componentInstance.handleBackToMainMenu();
+
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(sidenavService.handleGoToMainMenu).toHaveBeenCalled();
+ });
+ });
});
diff --git a/tedi/components/layout/sidenav/sidenav.component.ts b/tedi/components/layout/sidenav/sidenav.component.ts
index 620cea6bf..87f875158 100644
--- a/tedi/components/layout/sidenav/sidenav.component.ts
+++ b/tedi/components/layout/sidenav/sidenav.component.ts
@@ -1,8 +1,11 @@
import {
+ afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
effect,
+ inject,
+ Injector,
input,
ViewEncapsulation,
} from "@angular/core";
@@ -47,12 +50,29 @@ export class SideNavComponent {
*/
desktopBreakpoint = input("lg");
+ private readonly injector = inject(Injector);
+
constructor(public sidenavService: SideNavService) {
effect(() => {
this.sidenavService.desktopBreakpoint.set(this.desktopBreakpoint())
})
}
+ handleBackToMainMenu() {
+ // Find the parent menu item to focus on
+ const openItem = this.sidenavService.items().find(item => item.dropdown?.open());
+
+ this.sidenavService.handleGoToMainMenu();
+
+ afterNextRender(() => {
+ if (openItem) {
+ const itemEl = openItem['host']?.nativeElement as HTMLElement;
+ const trigger = itemEl?.querySelector('.tedi-sidenav-item__title') as HTMLElement | null;
+ trigger?.focus();
+ }
+ }, { injector: this.injector });
+ }
+
classes = computed(() => {
const classList = ["tedi-sidenav", `tedi-sidenav--${this.size()}`];
diff --git a/tedi/components/loader/spinner/spinner.component.scss b/tedi/components/loader/spinner/spinner.component.scss
index 1091147ef..933ee0674 100644
--- a/tedi/components/loader/spinner/spinner.component.scss
+++ b/tedi/components/loader/spinner/spinner.component.scss
@@ -1,6 +1,6 @@
$spinner-colors: (
- 'primary': 'loader-spinner-color-primary',
- 'secondary': 'loader-spinner-color-secondary',
+ "primary": "loader-spinner-color-primary",
+ "secondary": "loader-spinner-color-secondary",
);
$spinner-sizes: (10 16 48);
@@ -9,10 +9,11 @@ $spinner-sizes: (10 16 48);
animation: 1.4s linear 0s infinite normal none running tedi-spinner-outer;
&--inner {
+ stroke-width: 4px;
stroke-dasharray: 80px, 200px;
stroke-dashoffset: 0;
- stroke-width: 4px;
- animation: 1.4s ease-in-out 0s infinite normal none running tedi-spinner-inner;
+ animation: 1.4s ease-in-out 0s infinite normal none running
+ tedi-spinner-inner;
@media (prefers-reduced-motion: reduce) {
animation: none;
diff --git a/tedi/components/navigation/link/link.component.scss b/tedi/components/navigation/link/link.component.scss
index c07f9665c..3a78ff4cc 100644
--- a/tedi/components/navigation/link/link.component.scss
+++ b/tedi/components/navigation/link/link.component.scss
@@ -19,9 +19,9 @@
&:focus-visible:not(:disabled) {
color: $color-focus;
- border-radius: 0;
outline: 2px solid $color-focus;
outline-offset: 1px;
+ border-radius: 0;
.tedi-link__text {
text-decoration: underline;
@@ -31,8 +31,8 @@
.tedi-link {
display: inline-flex;
- cursor: pointer;
text-decoration: none;
+ cursor: pointer;
@include link-variant(
var(--link-primary-default),
@@ -68,11 +68,11 @@
tedi-icon {
height: fit-content;
+ margin-right: var(--button-sm-inner-spacing);
+ margin-left: var(--button-sm-inner-spacing);
font-size: 16px;
- color: inherit;
line-height: inherit;
- margin-left: var(--button-sm-inner-spacing);
- margin-right: var(--button-sm-inner-spacing);
+ color: inherit;
&:first-child {
margin-left: 0;
diff --git a/tedi/components/navigation/link/link.stories.ts b/tedi/components/navigation/link/link.stories.ts
index 5ed8131a5..bbe6f0fcd 100644
--- a/tedi/components/navigation/link/link.stories.ts
+++ b/tedi/components/navigation/link/link.stories.ts
@@ -93,7 +93,7 @@ export const Default: StoryObj = {
},
render: ({ ngContent, ...args }) => ({
props: { ngContent, ...args },
- template: `${ngContent} `,
+ template: `${ngContent} `,
}),
};
@@ -104,11 +104,11 @@ export const Sizes: StoryObj = {
`,
@@ -120,17 +120,17 @@ export const Colors: StoryObj = {
props: args,
template: `
-
+
Rebane on väikese koera suurune ja pika koheva sabaga. Joostes hoiab ta saba horisontaalselt. Tema selja karvad on oranžid. Eestis eelistab ta elupaigana metsatukkasid.
-
+
Rebane on väikese koera suurune ja pika koheva sabaga. Joostes hoiab ta saba horisontaalselt. Tema selja karvad on oranžid. Eestis eelistab ta elupaigana metsatukkasid.
-
+
Rebane on väikese koera suurune ja pika koheva sabaga. Joostes hoiab ta saba horisontaalselt. Tema selja karvad on oranžid. Eestis eelistab ta elupaigana metsatukkasid.
-
+
Rebane on väikese koera suurune ja pika koheva sabaga. Joostes hoiab ta saba horisontaalselt. Tema selja karvad on oranžid. Eestis eelistab ta elupaigana metsatukkasid.
@@ -153,16 +153,16 @@ const LinkTemplate: StoryFn = ({
{{ state }}
- View result
+ View result
-
+
Continue
-
+
Back
@@ -174,16 +174,16 @@ const LinkTemplate: StoryFn = ({
{{ state }}
- View result
+ View result
-
+
Continue
-
+
Back
@@ -259,7 +259,7 @@ export const WithIcons: StoryObj = {
Multiple icons
-
+
This text contains
@@ -269,7 +269,7 @@ export const WithIcons: StoryObj = {
Long Text Icon Inline
-
+
This is very long text with inline icon
@@ -278,7 +278,7 @@ export const WithIcons: StoryObj
= {
Long Text Icon Flexed
-
+
This is very long text with flexed icon
diff --git a/tedi/components/notifications/alert/alert.component.html b/tedi/components/notifications/alert/alert.component.html
index d02b40c3c..1706a53da 100644
--- a/tedi/components/notifications/alert/alert.component.html
+++ b/tedi/components/notifications/alert/alert.component.html
@@ -4,15 +4,6 @@
}
- @if (showClose()) {
-
- }
} @else {
@@ -21,17 +12,17 @@
}
- @if (showClose()) {
-
- }
}
+@if (showClose()) {
+
+}
@if (titleElement() === "h1") {
diff --git a/tedi/components/notifications/alert/alert.component.scss b/tedi/components/notifications/alert/alert.component.scss
index 43a90181f..b11289961 100644
--- a/tedi/components/notifications/alert/alert.component.scss
+++ b/tedi/components/notifications/alert/alert.component.scss
@@ -5,33 +5,40 @@
}
.tedi-alert {
+ --_close-button-size: 18px;
+
+ position: relative;
display: flex;
flex-direction: column;
- border: 1px solid;
+ gap: var(--layout-grid-gutters-04);
+ padding: var(--alert-default-padding-y) var(--alert-default-padding-x);
font-size: var(--body-regular-size);
+ border: 1px solid;
border-radius: var(--alert-radius);
- padding: var(--alert-default-padding-y) var(--alert-default-padding-x);
- gap: var(--layout-grid-gutters-04);
- @include alert-variant(
- var(--alert-default-background-info),
- var(--alert-default-border-info)
- );
+ @include alert-variant(var(--alert-default-background-info),
+ var(--alert-default-border-info));
&__head {
display: flex;
- align-items: center;
gap: var(--layout-grid-gutters-04);
+ align-items: center;
+ }
+
+ &:has(&__close) {
+ padding-right: calc(var(--layout-grid-gutters-04) + var(--_close-button-size) + var(--alert-default-padding-x));
}
&__close {
- margin-left: auto;
+ position: absolute;
+ top: var(--alert-default-padding-y);
+ right: var(--alert-default-padding-x);
}
&__content {
display: flex;
- align-items: flex-start;
gap: var(--layout-grid-gutters-08);
+ align-items: flex-start;
.tedi-alert__content-icon {
line-height: var(--body-regular-line-height);
@@ -45,31 +52,23 @@
}
&--info {
- @include alert-variant(
- var(--alert-default-background-info),
- var(--alert-default-border-info)
- );
+ @include alert-variant(var(--alert-default-background-info),
+ var(--alert-default-border-info));
}
&--success {
- @include alert-variant(
- var(--alert-default-background-success),
- var(--alert-default-border-success)
- );
+ @include alert-variant(var(--alert-default-background-success),
+ var(--alert-default-border-success));
}
&--warning {
- @include alert-variant(
- var(--alert-default-background-warning),
- var(--alert-default-border-warning)
- );
+ @include alert-variant(var(--alert-default-background-warning),
+ var(--alert-default-border-warning));
}
&--danger {
- @include alert-variant(
- var(--alert-default-background-danger),
- var(--alert-default-border-danger)
- );
+ @include alert-variant(var(--alert-default-background-danger),
+ var(--alert-default-border-danger));
}
&--global {
diff --git a/tedi/components/notifications/alert/alert.component.spec.ts b/tedi/components/notifications/alert/alert.component.spec.ts
index abb54051a..ba642b3aa 100644
--- a/tedi/components/notifications/alert/alert.component.spec.ts
+++ b/tedi/components/notifications/alert/alert.component.spec.ts
@@ -1,6 +1,12 @@
-import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
-import { AlertComponent, AlertRole, AlertType } from "./alert.component";
+import {
+ AlertComponent,
+ AlertRole,
+ AlertType,
+ AlertTitleType,
+ AlertVariant,
+} from "./alert.component";
import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
describe("AlertComponent", () => {
@@ -109,4 +115,217 @@ describe("AlertComponent", () => {
expect((fixture.nativeElement as HTMLElement).style.display).toBe("none");
});
+
+ describe("title", () => {
+ it("should display title when provided", () => {
+ fixture.componentRef.setInput("title", "Test Alert Title");
+ fixture.detectChanges();
+
+ const titleElement = fixture.debugElement.query(
+ By.css(".tedi-alert__title")
+ );
+ expect(titleElement).toBeTruthy();
+ expect(titleElement.nativeElement.textContent).toContain(
+ "Test Alert Title"
+ );
+ });
+
+ it("should not display title element when title is not provided", () => {
+ fixture.componentRef.setInput("title", undefined);
+ fixture.detectChanges();
+
+ const titleElement = fixture.debugElement.query(
+ By.css(".tedi-alert__title")
+ );
+ expect(titleElement).toBeNull();
+ });
+ });
+
+ describe("titleElement", () => {
+ it("should use h2 as default title element", () => {
+ fixture.componentRef.setInput("title", "Test Title");
+ fixture.detectChanges();
+
+ const h2Element = fixture.debugElement.query(
+ By.css("h2.tedi-alert__title")
+ );
+ expect(h2Element).toBeTruthy();
+ });
+
+ it("should use specified title element tag", () => {
+ const titleElements: AlertTitleType[] = [
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "div",
+ ];
+
+ for (const tag of titleElements) {
+ fixture.componentRef.setInput("title", "Test Title");
+ fixture.componentRef.setInput("titleElement", tag);
+ fixture.detectChanges();
+
+ const titleTag = fixture.debugElement.query(
+ By.css(`${tag}.tedi-alert__title`)
+ );
+ expect(titleTag).toBeTruthy();
+ }
+ });
+ });
+
+ describe("icon", () => {
+ it("should display icon in head when title and icon are provided", () => {
+ fixture.componentRef.setInput("title", "Test Title");
+ fixture.componentRef.setInput("icon", "info");
+ fixture.detectChanges();
+
+ const iconElement = fixture.debugElement.query(
+ By.css(".tedi-alert__head > tedi-icon")
+ );
+ expect(iconElement).toBeTruthy();
+ expect(iconElement.nativeElement.textContent).toBe("info");
+ });
+
+ it("should display icon in content when no title but icon is provided", () => {
+ fixture.componentRef.setInput("icon", "warning");
+ fixture.detectChanges();
+
+ const iconElement = fixture.debugElement.query(
+ By.css(".tedi-alert__content-icon")
+ );
+ expect(iconElement).toBeTruthy();
+ expect(iconElement.nativeElement.textContent).toBe("warning");
+ });
+
+ it("should not display icon when not provided", () => {
+ fixture.componentRef.setInput("icon", "");
+ fixture.detectChanges();
+
+ const iconElement = fixture.debugElement.query(By.css("tedi-icon"));
+ expect(iconElement).toBeNull();
+ });
+ });
+
+ describe("open", () => {
+ it("should be visible when open is true", () => {
+ fixture.componentRef.setInput("open", true);
+ fixture.detectChanges();
+
+ expect(element.style.display).toBe("flex");
+ });
+
+ it("should be hidden when open is false", () => {
+ fixture.componentRef.setInput("open", false);
+ fixture.detectChanges();
+
+ expect(element.style.display).toBe("none");
+ });
+ });
+
+ describe("closeDelay", () => {
+ it("should close immediately when closeDelay is 0", () => {
+ fixture.componentRef.setInput("showClose", true);
+ fixture.componentRef.setInput("closeDelay", 0);
+ fixture.detectChanges();
+
+ const closeButton = fixture.debugElement.query(
+ By.css(".tedi-alert__close")
+ ).nativeElement as HTMLButtonElement;
+
+ closeButton.click();
+ fixture.detectChanges();
+
+ expect(element.style.display).toBe("none");
+ });
+
+ it("should delay close when closeDelay is set", fakeAsync(() => {
+ fixture.componentRef.setInput("showClose", true);
+ fixture.componentRef.setInput("closeDelay", 300);
+ fixture.detectChanges();
+
+ const closeButton = fixture.debugElement.query(
+ By.css(".tedi-alert__close")
+ ).nativeElement as HTMLButtonElement;
+
+ closeButton.click();
+ fixture.detectChanges();
+
+ expect(element.style.display).toBe("flex");
+ tick(300);
+ fixture.detectChanges();
+ expect(element.style.display).toBe("none");
+ }));
+ });
+
+ describe("closeClick", () => {
+ it("should emit closeClick event when close button is clicked", () => {
+ fixture.componentRef.setInput("showClose", true);
+ fixture.detectChanges();
+
+ const closeClickSpy = jest.fn();
+ component.closeClick.subscribe(closeClickSpy);
+
+ const closeButton = fixture.debugElement.query(
+ By.css(".tedi-alert__close")
+ ).nativeElement as HTMLButtonElement;
+
+ closeButton.click();
+ fixture.detectChanges();
+
+ expect(closeClickSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("aria-label", () => {
+ it("should set aria-label with type only when no title", () => {
+ fixture.componentRef.setInput("type", "warning");
+ fixture.componentRef.setInput("title", undefined);
+ fixture.detectChanges();
+
+ expect(element.getAttribute("aria-label")).toBe("warning alert");
+ });
+
+ it("should set aria-label with type and title when title is provided", () => {
+ fixture.componentRef.setInput("type", "danger");
+ fixture.componentRef.setInput("title", "Error occurred");
+ fixture.detectChanges();
+
+ expect(element.getAttribute("aria-label")).toBe(
+ "danger alert: Error occurred"
+ );
+ });
+ });
+
+ describe("variant", () => {
+ it("should apply default variant without extra classes", () => {
+ fixture.componentRef.setInput("variant", "default");
+ fixture.detectChanges();
+
+ expect(element.classList.contains("tedi-alert")).toBe(true);
+ expect(element.classList.contains("tedi-alert--global")).toBe(false);
+ expect(element.classList.contains("tedi-alert--no-side-borders")).toBe(
+ false
+ );
+ });
+
+ it("should apply all variant classes correctly", () => {
+ const variants: AlertVariant[] = ["default", "global", "noSideBorders"];
+
+ for (const variant of variants) {
+ fixture.componentRef.setInput("variant", variant);
+ fixture.detectChanges();
+
+ if (variant === "global") {
+ expect(element.classList.contains("tedi-alert--global")).toBe(true);
+ } else if (variant === "noSideBorders") {
+ expect(
+ element.classList.contains("tedi-alert--no-side-borders")
+ ).toBe(true);
+ }
+ }
+ });
+ });
});
diff --git a/tedi/components/notifications/alert/alert.component.ts b/tedi/components/notifications/alert/alert.component.ts
index 81843cdc3..e2f497230 100644
--- a/tedi/components/notifications/alert/alert.component.ts
+++ b/tedi/components/notifications/alert/alert.component.ts
@@ -3,6 +3,7 @@ import {
computed,
input,
model,
+ output,
ChangeDetectionStrategy,
ViewEncapsulation,
} from "@angular/core";
@@ -78,6 +79,17 @@ export class AlertComponent {
*/
open = model(true);
+ /**
+ * Delay in milliseconds before setting "open" to false when close is triggered.
+ * @default 0
+ */
+ closeDelay = input(0);
+
+ /**
+ * Close click output
+ */
+ readonly closeClick = output();
+
getAriaLive = computed(() => {
switch (this.role()) {
case "alert":
@@ -106,6 +118,12 @@ export class AlertComponent {
});
handleClose() {
- this.open.set(false);
+ this.closeClick.emit();
+ const delay = this.closeDelay();
+ if (delay > 0) {
+ setTimeout(() => this.open.set(false), delay);
+ } else {
+ this.open.set(false);
+ }
}
}
diff --git a/tedi/components/notifications/index.ts b/tedi/components/notifications/index.ts
index 5ade4eac2..54eb4d1e4 100644
--- a/tedi/components/notifications/index.ts
+++ b/tedi/components/notifications/index.ts
@@ -1 +1,2 @@
export * from "./alert/alert.component";
+export * from "./toast/toast.component";
diff --git a/tedi/components/notifications/toast/toast-container.component.spec.ts b/tedi/components/notifications/toast/toast-container.component.spec.ts
new file mode 100644
index 000000000..181f585c5
--- /dev/null
+++ b/tedi/components/notifications/toast/toast-container.component.spec.ts
@@ -0,0 +1,263 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { signal, Signal, WritableSignal } from "@angular/core";
+import { ToastContainerComponent, ToastItem } from "./toast-container.component";
+import { ToastService } from "../../../services/toast/toast.service";
+import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+
+describe("ToastContainerComponent", () => {
+ let component: ToastContainerComponent;
+ let fixture: ComponentFixture;
+ let toastsSignal: WritableSignal;
+ let mockToastService: {
+ toasts$: Signal;
+ getToasts: jest.Mock;
+ close: jest.Mock;
+ pause: jest.Mock;
+ resume: jest.Mock;
+ };
+
+ const createMockToast = (overrides: Partial = {}): ToastItem => ({
+ id: "test-toast-1",
+ title: "Test Toast",
+ content: "Test content",
+ type: "info",
+ role: "status",
+ position: "bottom-right",
+ ...overrides,
+ });
+
+ beforeEach(async () => {
+ toastsSignal = signal([]);
+ mockToastService = {
+ toasts$: toastsSignal.asReadonly(),
+ getToasts: jest.fn().mockImplementation(() => toastsSignal()),
+ close: jest.fn(),
+ pause: jest.fn(),
+ resume: jest.fn(),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ToastContainerComponent],
+ providers: [
+ { provide: ToastService, useValue: mockToastService },
+ { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ToastContainerComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should have all positions defined", () => {
+ expect(component.positions).toEqual([
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+ ]);
+ });
+
+ describe("hasToastsForPosition", () => {
+ it("should return true when there are toasts for position", () => {
+ toastsSignal.set([createMockToast({ position: "top-left" })]);
+
+ expect(component.hasToastsForPosition("top-left")).toBe(true);
+ });
+
+ it("should return false when there are no toasts for position", () => {
+ toastsSignal.set([createMockToast({ position: "top-left" })]);
+
+ expect(component.hasToastsForPosition("bottom-right")).toBe(false);
+ });
+
+ it("should return false when there are no toasts at all", () => {
+ toastsSignal.set([]);
+
+ expect(component.hasToastsForPosition("top-left")).toBe(false);
+ });
+ });
+
+ describe("getToastsForPosition", () => {
+ it("should return toasts for specific position", () => {
+ const topLeftToast = createMockToast({
+ id: "toast-1",
+ position: "top-left",
+ });
+ const bottomRightToast = createMockToast({
+ id: "toast-2",
+ position: "bottom-right",
+ });
+
+ toastsSignal.set([topLeftToast, bottomRightToast]);
+
+ const result = component.getToastsForPosition("top-left");
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(topLeftToast);
+ });
+
+ it("should return empty array when no toasts for position", () => {
+ toastsSignal.set([createMockToast({ position: "top-left" })]);
+
+ const result = component.getToastsForPosition("bottom-right");
+
+ expect(result).toHaveLength(0);
+ });
+
+ it("should return multiple toasts for same position", () => {
+ toastsSignal.set([
+ createMockToast({ id: "toast-1", position: "top-left" }),
+ createMockToast({ id: "toast-2", position: "top-left" }),
+ createMockToast({ id: "toast-3", position: "top-left" }),
+ ]);
+
+ const result = component.getToastsForPosition("top-left");
+
+ expect(result).toHaveLength(3);
+ });
+ });
+
+ describe("event handlers", () => {
+ it("should call toastService.close when onClosed is called", () => {
+ const toastId = "test-toast-id";
+
+ component.onClosed(toastId);
+
+ expect(mockToastService.close).toHaveBeenCalledWith(toastId);
+ });
+
+ it("should call toastService.pause when onMouseEnter is called", () => {
+ const toastId = "test-toast-id";
+
+ component.onMouseEnter(toastId);
+
+ expect(mockToastService.pause).toHaveBeenCalledWith(toastId);
+ });
+
+ it("should call toastService.resume when onMouseLeave is called", () => {
+ const toastId = "test-toast-id";
+
+ component.onMouseLeave(toastId);
+
+ expect(mockToastService.resume).toHaveBeenCalledWith(toastId);
+ });
+ });
+
+ describe("rendering", () => {
+ it("should render toast container", () => {
+ const container = fixture.nativeElement;
+ expect(container.classList.contains("tedi-toast-container")).toBe(true);
+ });
+
+ it("should render position container when toasts exist", () => {
+ toastsSignal.set([createMockToast({ position: "top-right" })]);
+
+ fixture.detectChanges();
+
+ const positionContainer = fixture.nativeElement.querySelector(
+ ".tedi-toast-container__position--top-right"
+ );
+ expect(positionContainer).toBeTruthy();
+ });
+
+ it("should not render position container when no toasts", () => {
+ toastsSignal.set([]);
+
+ fixture.detectChanges();
+
+ const positionContainer = fixture.nativeElement.querySelector(
+ ".tedi-toast-container__position"
+ );
+ expect(positionContainer).toBeFalsy();
+ });
+
+ it("should render toast component for each toast", () => {
+ toastsSignal.set([
+ createMockToast({ id: "toast-1", position: "bottom-right" }),
+ createMockToast({ id: "toast-2", position: "bottom-right" }),
+ ]);
+
+ fixture.detectChanges();
+
+ const toasts = fixture.nativeElement.querySelectorAll("tedi-toast");
+ expect(toasts.length).toBe(2);
+ });
+
+ it("should pass correct props to toast component", () => {
+ toastsSignal.set([
+ createMockToast({
+ title: "Test Title",
+ type: "success",
+ icon: "check",
+ role: "alert",
+ duration: 5000,
+ showProgressBar: true,
+ paused: true,
+ position: "top-right",
+ }),
+ ]);
+
+ fixture.detectChanges();
+
+ const toast = fixture.nativeElement.querySelector("tedi-toast");
+ expect(toast).toBeTruthy();
+ });
+
+ it("should apply exiting class when toast is exiting", () => {
+ toastsSignal.set([
+ createMockToast({ exiting: true, position: "bottom-right" }),
+ ]);
+
+ fixture.detectChanges();
+
+ const toast = fixture.nativeElement.querySelector("tedi-toast");
+ expect(toast.classList.contains("tedi-toast--exiting")).toBe(true);
+ });
+
+ it("should render toast content when provided", () => {
+ toastsSignal.set([
+ createMockToast({ content: "Toast content text", position: "bottom-right" }),
+ ]);
+
+ fixture.detectChanges();
+
+ const container = fixture.nativeElement.querySelector(
+ ".tedi-toast-container__position"
+ );
+ expect(container.textContent).toContain("Toast content text");
+ });
+ });
+
+ describe("multiple positions", () => {
+ it("should render toasts in multiple positions", () => {
+ toastsSignal.set([
+ createMockToast({ id: "toast-1", position: "top-left" }),
+ createMockToast({ id: "toast-2", position: "top-right" }),
+ createMockToast({ id: "toast-3", position: "bottom-right" }),
+ ]);
+
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector(
+ ".tedi-toast-container__position--top-left"
+ )
+ ).toBeTruthy();
+ expect(
+ fixture.nativeElement.querySelector(
+ ".tedi-toast-container__position--top-right"
+ )
+ ).toBeTruthy();
+ expect(
+ fixture.nativeElement.querySelector(
+ ".tedi-toast-container__position--bottom-right"
+ )
+ ).toBeTruthy();
+ });
+ });
+});
diff --git a/tedi/components/notifications/toast/toast-container.component.ts b/tedi/components/notifications/toast/toast-container.component.ts
new file mode 100644
index 000000000..c0ed4d3cc
--- /dev/null
+++ b/tedi/components/notifications/toast/toast-container.component.ts
@@ -0,0 +1,116 @@
+import {
+ Component,
+ ChangeDetectionStrategy,
+ ViewEncapsulation,
+ inject,
+ computed,
+} from "@angular/core";
+import { ToastComponent, ToastPosition, ToastRole } from "./toast.component";
+import { ToastService } from "../../../services/toast/toast.service";
+
+export interface ToastItem {
+ id: string;
+ title: string;
+ content?: string;
+ type?: "info" | "success" | "warning" | "danger";
+ icon?: string;
+ role?: ToastRole;
+ duration?: number;
+ showProgressBar?: boolean;
+ pauseOnHover?: boolean;
+ paused?: boolean;
+ exiting?: boolean;
+ position: ToastPosition;
+}
+
+const POSITIONS: ToastPosition[] = [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+];
+
+/**
+ * Internal toast container component that renders toast notifications.
+ * This component is automatically created by ToastService using CDK Overlay.
+ *
+ * @internal
+ */
+@Component({
+ selector: "tedi-toast-container",
+ standalone: true,
+ imports: [ToastComponent],
+ template: `
+ @for (position of positions; track position) {
+ @if (toastsByPosition()[position].length > 0) {
+
+ @for (toast of toastsByPosition()[position]; track toast.id) {
+
+ @if (toast.content) {
+ {{ toast.content }}
+ }
+
+ }
+
+ }
+ }
+ `,
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ class: "tedi-toast-container",
+ },
+})
+export class ToastContainerComponent {
+ private readonly toastService = inject(ToastService);
+
+ readonly positions = POSITIONS;
+
+ readonly toastsByPosition = computed(() => {
+ const toasts = this.toastService.toasts$();
+ const grouped: Record = {
+ "top-left": [],
+ "top-right": [],
+ "bottom-left": [],
+ "bottom-right": [],
+ };
+
+ for (const toast of toasts) {
+ grouped[toast.position].push(toast);
+ }
+
+ return grouped;
+ });
+
+ hasToastsForPosition(position: ToastPosition): boolean {
+ return this.toastService.getToasts().some((t) => t.position === position);
+ }
+
+ getToastsForPosition(position: ToastPosition): ToastItem[] {
+ return this.toastService.getToasts().filter((t) => t.position === position);
+ }
+
+ onClosed(id: string): void {
+ this.toastService.close(id);
+ }
+
+ onMouseEnter(id: string): void {
+ this.toastService.pause(id);
+ }
+
+ onMouseLeave(id: string): void {
+ this.toastService.resume(id);
+ }
+}
diff --git a/tedi/components/notifications/toast/toast.component.html b/tedi/components/notifications/toast/toast.component.html
new file mode 100644
index 000000000..403fb3ba5
--- /dev/null
+++ b/tedi/components/notifications/toast/toast.component.html
@@ -0,0 +1,24 @@
+
+
+
+
+ @if (showProgressBar() && duration() > 0) {
+
+ }
+
diff --git a/tedi/components/notifications/toast/toast.component.scss b/tedi/components/notifications/toast/toast.component.scss
new file mode 100644
index 000000000..34e6bff1a
--- /dev/null
+++ b/tedi/components/notifications/toast/toast.component.scss
@@ -0,0 +1,240 @@
+@use "@tedi-design-system/core/bootstrap-utility/breakpoints";
+
+tedi-toast {
+ display: block;
+ width: var(--toast-width);
+ max-width: 100%;
+}
+
+.tedi-toast__wrapper {
+ position: relative;
+ padding: var(--toast-outer-spacing, 4px);
+
+ tedi-alert {
+ box-shadow: 0 4px 10px 0 var(--tedi-alpha-14, rgba(0, 0, 0, 0.14));
+ }
+}
+
+.tedi-toast__progress {
+ position: absolute;
+ bottom: var(--toast-outer-spacing);
+ left: var(--toast-outer-spacing);
+ width: calc(100% - var(--toast-outer-spacing) * 2);
+ height: 4px;
+ background: var(--toast-progress-color);
+ transform-origin: left;
+ animation: toast-progress linear forwards;
+ border-radius: 0 var(--alert-radius) 0 var(--alert-radius);
+
+ &--success {
+ --toast-progress-color: var(--alert-default-border-success);
+ }
+
+ &--info {
+ --toast-progress-color: var(--alert-default-border-info);
+ }
+
+ &--danger {
+ --toast-progress-color: var(--alert-default-border-danger);
+ }
+
+ &--warning {
+ --toast-progress-color: var(--alert-default-border-warning);
+ }
+
+ &--paused {
+ animation-play-state: paused;
+ }
+}
+
+.tedi-toast-container {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: var(--z-index-toast, 1050);
+
+ &__position {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ gap: var(--dimensions-05);
+ max-height: calc(100vh - calc(var(--toast-margin-bottom) * 2));
+ overflow: visible;
+
+ >* {
+ pointer-events: auto;
+ }
+
+ &--top-left {
+ top: var(--toast-margin-bottom);
+ left: var(--toast-margin-right);
+ align-items: flex-start;
+
+ tedi-toast {
+ animation: toast-slide-in-left 0.3s ease-out forwards;
+ }
+
+ tedi-toast.tedi-toast--exiting {
+ animation: toast-slide-out-left 0.3s ease-out forwards;
+ }
+ }
+
+ &--top-right {
+ top: var(--toast-margin-bottom);
+ right: var(--toast-margin-right);
+ align-items: flex-end;
+
+ tedi-toast {
+ animation: toast-slide-in-right 0.3s ease-out forwards;
+ }
+
+ tedi-toast.tedi-toast--exiting {
+ animation: toast-slide-out-right 0.3s ease-out forwards;
+ }
+ }
+
+ &--bottom-left {
+ bottom: var(--toast-margin-bottom);
+ left: var(--toast-margin-right);
+ align-items: flex-start;
+ flex-direction: column-reverse;
+
+ tedi-toast {
+ animation: toast-slide-in-left 0.3s ease-out forwards;
+ }
+
+ tedi-toast.tedi-toast--exiting {
+ animation: toast-slide-out-left 0.3s ease-out forwards;
+ }
+ }
+
+ &--bottom-right {
+ bottom: var(--toast-margin-bottom);
+ right: var(--toast-margin-right);
+ align-items: flex-end;
+ flex-direction: column-reverse;
+
+ tedi-toast {
+ animation: toast-slide-in-right 0.3s ease-out forwards;
+ }
+
+ tedi-toast.tedi-toast--exiting {
+ animation: toast-slide-out-right 0.3s ease-out forwards;
+ }
+ }
+
+ // Mobile: center all positions and use full width with padding
+ @include breakpoints.media-breakpoint-down(sm) {
+
+ &--top-left,
+ &--top-right,
+ &--bottom-left,
+ &--bottom-right {
+ left: var(--toast-margin-left);
+ right: var(--toast-margin-right);
+ transform: none;
+ align-items: stretch;
+
+ tedi-toast {
+ width: 100%;
+ animation: toast-slide-in-right 0.3s ease-out forwards;
+ }
+
+ tedi-toast.tedi-toast--exiting {
+ animation: toast-slide-out-right 0.3s ease-out forwards;
+ }
+ }
+ }
+ }
+}
+
+
+@keyframes toast-progress {
+ from {
+ transform: scaleX(1);
+ }
+
+ to {
+ transform: scaleX(0);
+ }
+}
+
+@keyframes toast-slide-in-right {
+ from {
+ transform: translateX(100%);
+ }
+
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes toast-slide-out-right {
+ from {
+ transform: translateX(0);
+ }
+
+ to {
+ transform: translateX(100%);
+ }
+}
+
+@keyframes toast-slide-in-left {
+ from {
+ transform: translateX(-100%);
+ }
+
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes toast-slide-out-left {
+ from {
+ transform: translateX(0);
+ }
+
+ to {
+ transform: translateX(-100%);
+ }
+}
+
+@keyframes toast-slide-in-down {
+ from {
+ transform: translateY(-100%);
+ }
+
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes toast-slide-in-up {
+ from {
+ transform: translateY(100%);
+ }
+
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes toast-slide-out-up {
+ from {
+ transform: translateY(0);
+ }
+
+ to {
+ transform: translateY(-100%);
+ }
+}
+
+@keyframes toast-slide-out-down {
+ from {
+ transform: translateY(0);
+ }
+
+ to {
+ transform: translateY(100%);
+ }
+}
diff --git a/tedi/components/notifications/toast/toast.component.spec.ts b/tedi/components/notifications/toast/toast.component.spec.ts
new file mode 100644
index 000000000..4587e0684
--- /dev/null
+++ b/tedi/components/notifications/toast/toast.component.spec.ts
@@ -0,0 +1,167 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { ToastComponent, ToastType } from "./toast.component";
+import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+
+describe("ToastComponent", () => {
+ let component: ToastComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ToastComponent],
+ providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ToastComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput("title", "Test Title");
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should render alert component", () => {
+ const alertElement = fixture.nativeElement.querySelector("tedi-alert");
+ expect(alertElement).toBeTruthy();
+ });
+
+ it("should display title in the alert", () => {
+ const titleElement = fixture.nativeElement.querySelector(".tedi-alert__title");
+ expect(titleElement.textContent).toContain("Test Title");
+ });
+
+ it("should apply correct type class", () => {
+ const types: ToastType[] = ["info", "success", "warning", "danger"];
+
+ for (const type of types) {
+ fixture.componentRef.setInput("type", type);
+ fixture.detectChanges();
+
+ const alertElement = fixture.nativeElement.querySelector("tedi-alert");
+ expect(alertElement.classList.contains(`tedi-alert--${type}`)).toBe(true);
+ }
+ });
+
+ it("should pass icon to alert when provided", () => {
+ fixture.componentRef.setInput("icon", "info");
+ fixture.detectChanges();
+
+ // Use direct child selector to get the alert icon (not close button icon)
+ const iconElement = fixture.nativeElement.querySelector(".tedi-alert__head > tedi-icon");
+ expect(iconElement).toBeTruthy();
+ expect(iconElement.textContent).toBe("info");
+ });
+
+ it("should not show icon when not provided", () => {
+ fixture.componentRef.setInput("icon", undefined);
+ fixture.detectChanges();
+
+ // Use direct child selector to exclude the close button's icon
+ const iconElement = fixture.nativeElement.querySelector(".tedi-alert__head > tedi-icon");
+ expect(iconElement).toBeFalsy();
+ });
+
+ it("should always show close button", () => {
+ const closeButton = fixture.nativeElement.querySelector(".tedi-alert__close");
+ expect(closeButton).toBeTruthy();
+ });
+
+ it("should emit closed event when close button is clicked", () => {
+ const closedSpy = jest.fn();
+ component.closed.subscribe(closedSpy);
+
+ const closeButton = fixture.nativeElement.querySelector(".tedi-alert__close");
+ closeButton.click();
+ fixture.detectChanges();
+
+ expect(closedSpy).toHaveBeenCalled();
+ });
+
+ it("should have status role by default", () => {
+ const alertElement = fixture.nativeElement.querySelector("tedi-alert");
+ expect(alertElement.getAttribute("role")).toBe("status");
+ });
+
+ it("should apply custom role when provided", () => {
+ fixture.componentRef.setInput("role", "alert");
+ fixture.detectChanges();
+
+ const alertElement = fixture.nativeElement.querySelector("tedi-alert");
+ expect(alertElement.getAttribute("role")).toBe("alert");
+ });
+
+ it("should show progress bar when showProgressBar is true and duration > 0", () => {
+ fixture.componentRef.setInput("duration", 5000);
+ fixture.componentRef.setInput("showProgressBar", true);
+ fixture.detectChanges();
+
+ const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress");
+ expect(progressBar).toBeTruthy();
+ });
+
+ it("should not show progress bar when showProgressBar is false", () => {
+ fixture.componentRef.setInput("duration", 5000);
+ fixture.componentRef.setInput("showProgressBar", false);
+ fixture.detectChanges();
+
+ const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress");
+ expect(progressBar).toBeFalsy();
+ });
+
+ it("should not show progress bar when duration is 0", () => {
+ fixture.componentRef.setInput("duration", 0);
+ fixture.componentRef.setInput("showProgressBar", true);
+ fixture.detectChanges();
+
+ const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress");
+ expect(progressBar).toBeFalsy();
+ });
+
+ it("should apply correct type class to progress bar", () => {
+ const types: ToastType[] = ["info", "success", "warning", "danger"];
+
+ for (const type of types) {
+ fixture.componentRef.setInput("type", type);
+ fixture.componentRef.setInput("duration", 5000);
+ fixture.componentRef.setInput("showProgressBar", true);
+ fixture.detectChanges();
+
+ const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress");
+ expect(progressBar.classList.contains(`tedi-toast__progress--${type}`)).toBe(true);
+ }
+ });
+
+ it("should pause progress bar animation when paused is true", () => {
+ fixture.componentRef.setInput("duration", 5000);
+ fixture.componentRef.setInput("showProgressBar", true);
+ fixture.componentRef.setInput("paused", true);
+ fixture.detectChanges();
+
+ const progressBar = fixture.nativeElement.querySelector(".tedi-toast__progress");
+ expect(progressBar.classList.contains("tedi-toast__progress--paused")).toBe(true);
+ });
+
+ it("should emit mouseEnter event on mouse enter", () => {
+ const mouseEnterSpy = jest.fn();
+ component.mouseEnter.subscribe(mouseEnterSpy);
+
+ const wrapper = fixture.nativeElement.querySelector(".tedi-toast__wrapper");
+ wrapper.dispatchEvent(new MouseEvent("mouseenter"));
+ fixture.detectChanges();
+
+ expect(mouseEnterSpy).toHaveBeenCalled();
+ });
+
+ it("should emit mouseLeave event on mouse leave", () => {
+ const mouseLeaveSpy = jest.fn();
+ component.mouseLeave.subscribe(mouseLeaveSpy);
+
+ const wrapper = fixture.nativeElement.querySelector(".tedi-toast__wrapper");
+ wrapper.dispatchEvent(new MouseEvent("mouseleave"));
+ fixture.detectChanges();
+
+ expect(mouseLeaveSpy).toHaveBeenCalled();
+ });
+});
diff --git a/tedi/components/notifications/toast/toast.component.ts b/tedi/components/notifications/toast/toast.component.ts
new file mode 100644
index 000000000..66c9b5b7b
--- /dev/null
+++ b/tedi/components/notifications/toast/toast.component.ts
@@ -0,0 +1,148 @@
+import {
+ Component,
+ ChangeDetectionStrategy,
+ ViewEncapsulation,
+ input,
+ output,
+} from "@angular/core";
+import { AlertComponent, AlertType, AlertRole } from "../alert/alert.component";
+
+export const TOAST_DEFAULT_DURATION = 6000;
+
+export type ToastType = AlertType;
+export type ToastRole = AlertRole;
+export type ToastPosition =
+ | "top-left"
+ | "top-right"
+ | "bottom-left"
+ | "bottom-right";
+
+export interface ToastConfig {
+ /**
+ * Title of the toast notification.
+ */
+ title: string;
+ /**
+ * Toast text content.
+ */
+ content?: string;
+ /**
+ * Type of the toast notification determining its color scheme.
+ * @default info
+ */
+ type?: ToastType;
+ /**
+ * Specifies an optional icon to display in the toast notification, providing quick visual context.
+ */
+ icon?: string;
+ /**
+ * Toast duration in milliseconds. Set to 0 for persistent toast.
+ */
+ duration?: number;
+ /**
+ * Whether to show the progress bar for timed toasts.
+ * @default false
+ */
+ showProgressBar?: boolean;
+ /**
+ * Whether to pause the auto-close timer when hovering over the toast.
+ * @default true
+ */
+ pauseOnHover?: boolean;
+ /**
+ * The ARIA role of the toast, informing screen readers about the notification's priority.
+ * - 'status': For non-critical notifications.
+ * - 'alert': For critical errors.
+ * - 'none': Used when no ARIA role is needed.
+ * @default status
+ */
+ role?: ToastRole;
+ /**
+ * The position of toast container.
+ * Possible values:
+ * - 'top-left'
+ * - 'top-right'
+ * - 'bottom-left'
+ * - 'bottom-right'
+ * @default bottom-right
+ */
+ position?: ToastPosition;
+ /**
+ * Unique identifier of given toast. Id is automatically generated if not provided by client
+ */
+ id?: string;
+}
+
+@Component({
+ selector: "tedi-toast",
+ standalone: true,
+ imports: [AlertComponent],
+ templateUrl: "./toast.component.html",
+ styleUrl: "./toast.component.scss",
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ToastComponent {
+ /**
+ * Title of the toast notification.
+ */
+ readonly title = input();
+
+ /**
+ * Type of the toast notification determining its color scheme.
+ * @default info
+ */
+ readonly type = input("info");
+
+ /**
+ * Icon name. Only shown when provided.
+ */
+ readonly icon = input("");
+
+ /**
+ * The ARIA role of the toast, informing screen readers about the notification's priority.
+ * - 'status': For non-critical notifications (screen readers announce politely).
+ * - 'alert': For critical errors (screen readers announce immediately).
+ * - 'none': Used when no ARIA role is needed.
+ * @default status
+ */
+ readonly role = input("status");
+
+ /**
+ * Duration in milliseconds for auto-close.
+ * @default 6000
+ */
+ readonly duration = input(TOAST_DEFAULT_DURATION);
+
+ /**
+ * Whether to show the progress bar.
+ * @default false
+ */
+ readonly showProgressBar = input(false);
+
+ /**
+ * Whether the toast timer is currently paused.
+ * @default false
+ */
+ readonly paused = input(false);
+
+ /**
+ * Emits when the toast is closed by the user.
+ */
+ readonly closed = output();
+
+ readonly mouseEnter = output();
+ readonly mouseLeave = output();
+
+ handleClose(): void {
+ this.closed.emit();
+ }
+
+ onMouseEnter(): void {
+ this.mouseEnter.emit();
+ }
+
+ onMouseLeave(): void {
+ this.mouseLeave.emit();
+ }
+}
diff --git a/tedi/components/notifications/toast/toast.stories.ts b/tedi/components/notifications/toast/toast.stories.ts
new file mode 100644
index 000000000..45c6c7ef9
--- /dev/null
+++ b/tedi/components/notifications/toast/toast.stories.ts
@@ -0,0 +1,561 @@
+import {
+ type Meta,
+ type StoryObj,
+ moduleMetadata,
+ applicationConfig,
+} from "@storybook/angular";
+import { Component, inject } from "@angular/core";
+import { provideAnimations } from "@angular/platform-browser/animations";
+
+import { ToastComponent } from "./toast.component";
+import { ToastService } from "../../../services/toast/toast.service";
+import { RowComponent } from "../../helpers/grid/row/row.component";
+import { ColComponent } from "../../helpers/grid/col/col.component";
+import { ButtonComponent } from "../../buttons/button/button.component";
+import { VerticalSpacingDirective } from "../../../directives/vertical-spacing/vertical-spacing.directive";
+
+/**
+ * Figma ↗
+ * Zeroheight ↗
+ *
+ * ## Usage
+ *
+ * Inject `ToastService` and call one of the convenience methods:
+ *
+ * ```typescript
+ * import { ToastService } from '@tedi-design-system/angular/tedi';
+ *
+ * export class MyComponent {
+ * private toastService = inject(ToastService);
+ *
+ * showNotification() {
+ * this.toastService.success('Title', 'Content text', { icon: 'icon_name' });
+ * }
+ * }
+ * ```
+ *
+ * ## Accessibility
+ *
+ * Toasts use CDK live announcer for accessibility:
+ * - `role="status"` (default): For non-critical notifications. Screen readers announce politely.
+ * - `role="alert"` (default for danger): For critical errors. Screen readers announce immediately.
+ * - `role="none"`: When no screen reader announcement is needed.
+ */
+export default {
+ title: "TEDI-Ready/Components/Notifications/Toast",
+ component: ToastComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [
+ ToastComponent,
+ RowComponent,
+ ColComponent,
+ ButtonComponent,
+ VerticalSpacingDirective,
+ ],
+ }),
+ applicationConfig({
+ providers: [
+ provideAnimations(),
+ ],
+ }),
+ ],
+ argTypes: {
+ title: {
+ control: "text",
+ description:
+ "Title of the toast notification.",
+ },
+ content: {
+ control: "text",
+ description:
+ "Toast text content.",
+ },
+ type: {
+ control: "radio",
+ options: ["info", "success", "warning", "error"],
+ description:
+ "Type of the toast notification determining its color scheme.",
+ defaultValue: {
+ summary: "info",
+ },
+ },
+ icon: {
+ control: "text",
+ description:
+ "Specifies an optional icon to display in the toast notification. See the icon component for more details.",
+ },
+ duration: {
+ control: "number",
+ description: "Toast duration in milliseconds. Set to 0 for persistent toast.",
+ defaultValue: { summary: 6000 }
+ },
+ showProgressBar: {
+ control: "boolean",
+ description: "Whether to show the progress bar for timed toasts.",
+ defaultValue: { summary: false }
+ },
+ pauseOnHover: {
+ control: "boolean",
+ description: "Whether to pause the auto-close timer when hovering over the toast.",
+ defaultValue: { summary: true }
+ },
+ role: {
+ control: "select",
+ options: ["alert", "status", "none"],
+ description:
+ "The ARIA role of the toast, informing screen readers about the notification's priority. Options: \n - alert for high-priority messages that demand immediate attention. \n - status for less urgent messages providing feedback or updates.\n - none used when no ARIA role is needed.",
+ defaultValue: {
+ summary: "alert",
+ },
+ },
+ },
+} as Meta;
+
+type Story = StoryObj;
+
+@Component({
+ selector: "toast-default-demo",
+ standalone: true,
+ imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective],
+ template: `
+
+
+
+ Show success toast
+
+
+
+
+ Show warning toast
+
+
+
+
+ Show danger toast
+
+
+
+
+ Show info toast
+
+
+
+ `,
+})
+class ToastDefaultDemoComponent {
+ private readonly toastService = inject(ToastService);
+
+ showSuccess() {
+ this.toastService.success("Notice", "Something was successful!");
+ }
+
+ showWarning() {
+ this.toastService.warning("Notice", "Warning!");
+ }
+
+ showDanger() {
+ this.toastService.danger("Notice", "Something went wrong!");
+ }
+
+ showInfo() {
+ this.toastService.info("Notice", "Some info text that can usually be very long!");
+ }
+}
+
+/**
+ * Default toast notifications with different types.
+ */
+export const Default: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [ToastDefaultDemoComponent],
+ }),
+ ],
+ parameters: {
+ docs: {
+ source: {
+ code: `
+this.toastService.success("Notice", "Something was successful!");
+this.toastService.warning("Notice", "Warning!");
+this.toastService.danger("Notice", "Something went wrong!");
+this.toastService.info("Notice", "Some info text!");
+ `,
+ language: "typescript",
+ type: "code",
+ },
+ },
+ },
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+@Component({
+ selector: "toast-icon-demo",
+ standalone: true,
+ imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective],
+ template: `
+ With icon
+ `,
+})
+class ToastIconDemoComponent {
+ private readonly toastService = inject(ToastService);
+
+ showWithCustomIcon() {
+ this.toastService.info("With Icon", "Using a custom icon", { icon: "info" });
+ }
+}
+
+/**
+ * Toasts with or without icons. Icons are only shown when explicitly provided.
+ */
+export const WithIcon: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [ToastIconDemoComponent],
+ }),
+ ],
+ parameters: {
+ docs: {
+ source: {
+ code: `this.toastService.info("With Icon", "Using a custom icon", { icon: "info" });`,
+ language: "typescript",
+ type: "code",
+ },
+ },
+ },
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+@Component({
+ selector: "toast-timer-demo",
+ standalone: true,
+ imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective],
+ template: `
+
+
+ Auto close in 2s
+
+
+ Auto close in 10s
+
+
+ `,
+})
+class ToastTimerDemoComponent {
+ private readonly toastService = inject(ToastService);
+
+ show(delay: number) {
+ this.toastService.info(`${delay}s delay`, `Closes after ${delay} seconds`, {
+ duration: delay * 1000,
+ showProgressBar: true,
+ });
+ }
+}
+
+/**
+ * Toasts with custom auto-close durations and progress bar.
+ */
+export const CustomTimerForAutoclose: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [ToastTimerDemoComponent],
+ }),
+ ],
+ parameters: {
+ docs: {
+ source: {
+ code: `
+this.toastService.info("2s delay", "Closes after 2 seconds", {
+ duration: 2000,
+ showProgressBar: true
+});
+
+this.toastService.info("10s delay", "Closes after 10 seconds", {
+ duration: 10000,
+ showProgressBar: true
+});
+ `,
+ language: "typescript",
+ type: "code",
+ },
+ },
+ },
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+@Component({
+ selector: "toast-persistent-demo",
+ standalone: true,
+ imports: [ButtonComponent],
+ template: `
+ Show persistent toast
+ `,
+})
+class ToastPersistentDemoComponent {
+ private readonly toastService = inject(ToastService);
+
+ showPersistent() {
+ this.toastService.warning("Persistent", "Stays until closed", {
+ duration: 0,
+ });
+ }
+}
+
+/**
+ * Persistent toast that stays visible until manually closed.
+ */
+export const PersistentToast: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [ToastPersistentDemoComponent],
+ }),
+ ],
+ parameters: {
+ docs: {
+ source: {
+ code: `this.toastService.warning("Persistent", "Stays until closed", { duration: 0 });`,
+ language: "typescript",
+ type: "code",
+ },
+ },
+ },
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+@Component({
+ selector: "toast-position-demo",
+ standalone: true,
+ imports: [ButtonComponent, RowComponent, ColComponent, VerticalSpacingDirective],
+ template: `
+
+
+
+
+ Top Left
+
+
+
+
+ Top Right
+
+
+
+
+
+
+ Bottom Left
+
+
+
+
+ Bottom Right
+
+
+
+
+ `,
+})
+class ToastPositionDemoComponent {
+ private readonly toastService = inject(ToastService);
+
+ showTopLeft() {
+ this.toastService.info("Top Left", "Positioned at top-left corner.", {
+ position: "top-left",
+ });
+ }
+
+ showTopRight() {
+ this.toastService.info("Top Right", "Positioned at top-right corner.", {
+ position: "top-right",
+ });
+ }
+
+ showBottomLeft() {
+ this.toastService.info("Bottom Left", "Positioned at bottom-left corner.", {
+ position: "bottom-left",
+ });
+ }
+
+ showBottomRight() {
+ this.toastService.info("Bottom Right", "Positioned at bottom-right corner.", {
+ position: "bottom-right",
+ });
+ }
+}
+
+/**
+ * Toast notifications at different screen positions.
+ * Default and also recommended value is "bottom-right"
+ */
+export const Positions: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [ToastPositionDemoComponent],
+ }),
+ ],
+ parameters: {
+ docs: {
+ source: {
+ code: `
+this.toastService.info("Top Left", "Message", { position: "top-left" });
+this.toastService.info("Top Right", "Message", { position: "top-right" });
+this.toastService.info("Bottom Left", "Message", { position: "bottom-left" });
+this.toastService.info("Bottom Right", "Message", { position: "bottom-right" });
+ `,
+ language: "typescript",
+ type: "code",
+ },
+ },
+ },
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+@Component({
+ selector: "toast-hover-demo",
+ standalone: true,
+ imports: [ButtonComponent, RowComponent, ColComponent],
+ template: `
+
+
+
+ Pause on hover
+
+
+
+
+ No pause on hover
+
+
+
+ `,
+})
+class ToastHoverDemoComponent {
+ private readonly toastService = inject(ToastService);
+
+ showPauseOnHover() {
+ this.toastService.info("Pauses", "Timer stops when hovered", {
+ showProgressBar: true,
+ });
+ }
+
+ showNoPause() {
+ this.toastService.danger("No Pause", "Closes even if hovered", {
+ showProgressBar: true,
+ pauseOnHover: false,
+ });
+ }
+}
+
+/**
+ * Toasts with hover behavior control. By default, hovering pauses the auto-close timer.
+ */
+export const HoverBehavior: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [ToastHoverDemoComponent],
+ }),
+ ],
+ parameters: {
+ docs: {
+ source: {
+ code: `
+this.toastService.info("Pauses", "Timer stops when hovered", {
+ showProgressBar: true
+});
+
+this.toastService.danger("No Pause", "Closes even if hovered", {
+ showProgressBar: true,
+ pauseOnHover: false
+});
+ `,
+ language: "typescript",
+ type: "code",
+ },
+ },
+ },
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+@Component({
+ selector: "toast-wcag-demo",
+ standalone: true,
+ imports: [ButtonComponent, RowComponent, ColComponent],
+ template: `
+
+
+
+ Success (role=status)
+
+
+
+
+ Error (role=alert)
+
+
+
+
+ Info (role=none)
+
+
+
+ `,
+})
+class ToastWcagDemoComponent {
+ private readonly toastService = inject(ToastService);
+
+ showStatus() {
+ this.toastService.success("Success", "Screen reader announces politely");
+ }
+
+ showAlert() {
+ this.toastService.danger("Error", "Screen reader announces immediately");
+ }
+
+ showNone() {
+ this.toastService.info("Info", "No screen reader announcement", {
+ role: "none"
+ });
+ }
+}
+
+/**
+ * Toasts with different ARIA roles for screen reader accessibility.
+ */
+export const WCAGCompliance: Story = {
+ decorators: [
+ moduleMetadata({
+ imports: [ToastWcagDemoComponent],
+ }),
+ ],
+ parameters: {
+ docs: {
+ source: {
+ code: `
+// Polite announcement (default for success, info, warning)
+this.toastService.success("Success", "Screen reader announces politely");
+
+// Assertive announcement - danger() defaults to role="alert"
+this.toastService.danger("Error", "Screen reader announces immediately");
+this.toastService.info("Info", "No screen reader announcement", { role: "none" });
+ `,
+ language: "typescript",
+ type: "code",
+ },
+ },
+ },
+ render: () => ({
+ template: ` `,
+ }),
+};
diff --git a/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss
index abfd74efe..ed055ea56 100644
--- a/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss
+++ b/tedi/components/overlay/dropdown/dropdown-content/dropdown-content.component.scss
@@ -1,12 +1,12 @@
tedi-dropdown-content {
- width: max-content;
- min-width: var(--_tedi-dropdown-trigger-width);
display: flex;
flex-direction: column;
- border-radius: var(--form-select-area-radius);
- border: 1px solid var(--card-border-primary);
- box-shadow: 0 1px 5px 0 rgba(0, 0, 0, 0.2);
+ width: max-content;
+ min-width: var(--_tedi-dropdown-trigger-width);
overflow: auto;
+ border: 1px solid var(--card-border-primary);
+ border-radius: var(--form-select-area-radius);
+ box-shadow: 0 1px 5px 0 rgb(0 0 0 / 20%);
ul {
margin: 0;
diff --git a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss
index 5cdb2a5e2..946015a5e 100644
--- a/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss
+++ b/tedi/components/overlay/dropdown/dropdown-item/dropdown-item.component.scss
@@ -1,13 +1,13 @@
li[tedi-dropdown-item] {
- width: 100%;
- min-height: 40px;
display: flex;
- align-items: center;
gap: var(--dropdown-item-inner-spacing);
- color: var(--dropdown-item-default-text);
- background: var(--dropdown-item-default-background);
+ align-items: center;
+ width: 100%;
+ min-height: 40px;
padding: var(--dropdown-item-padding-y, 8px) var(--dropdown-item-padding-x);
+ color: var(--dropdown-item-default-text);
cursor: pointer;
+ background: var(--dropdown-item-default-background);
&:hover {
color: var(--dropdown-item-hover-text);
@@ -31,7 +31,7 @@ li[tedi-dropdown-item] {
&[aria-disabled="true"] {
color: var(--general-text-disabled);
- background: var(--dropdown-item-disabled-background);
cursor: not-allowed;
+ background: var(--dropdown-item-disabled-background);
}
}
diff --git a/tedi/components/overlay/dropdown/dropdown.component.scss b/tedi/components/overlay/dropdown/dropdown.component.scss
index a6b9e8908..4114f5a62 100644
--- a/tedi/components/overlay/dropdown/dropdown.component.scss
+++ b/tedi/components/overlay/dropdown/dropdown.component.scss
@@ -4,10 +4,10 @@ tedi-dropdown {
float-ui-content {
.float-ui-container-dropdown {
+ z-index: var(--z-index-dropdown);
padding: 0;
border: 0;
border-radius: var(--dropdown-item-radius);
- box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20, rgba(0, 0, 0, 0.2));
- z-index: var(--z-index-dropdown);
+ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%));
}
}
diff --git a/tedi/components/overlay/dropdown/dropdown.stories.ts b/tedi/components/overlay/dropdown/dropdown.stories.ts
index a0def56dc..6e5f6b117 100644
--- a/tedi/components/overlay/dropdown/dropdown.stories.ts
+++ b/tedi/components/overlay/dropdown/dropdown.stories.ts
@@ -150,7 +150,7 @@ export const Default: Story = {
props: args,
template: `
-
+
Trigger
diff --git a/tedi/components/overlay/modal/modal.component.scss b/tedi/components/overlay/modal/modal.component.scss
index 79d2c7a75..649ed9772 100644
--- a/tedi/components/overlay/modal/modal.component.scss
+++ b/tedi/components/overlay/modal/modal.component.scss
@@ -2,6 +2,7 @@
@mixin modal-heading($size) {
.tedi-modal-header__head {
+
h1,
h2,
h3,
@@ -13,13 +14,19 @@
}
}
-$modal-widths: (xs, sm, md, lg, xl);
+$modal-widths: (
+ xs,
+ sm,
+ md,
+ lg,
+ xl
+);
.tedi-modal {
position: fixed;
inset: 0;
+ z-index: var(--z-index-modal);
display: none;
- z-index: 1000;
&--open {
display: block;
@@ -57,8 +64,8 @@ $modal-widths: (xs, sm, md, lg, xl);
.tedi-modal__dialog {
top: 50%;
left: 50%;
- transform: translate(-50%, -50%);
max-height: 95dvh;
+ transform: translate(-50%, -50%);
}
}
@@ -80,9 +87,9 @@ $modal-widths: (xs, sm, md, lg, xl);
&__dialog {
position: fixed;
- width: 100%;
display: flex;
flex-direction: column;
+ width: 100%;
background-color: var(--modal-background);
border: var(--borders-01) solid var(--modal-border-outer);
border-radius: var(--modal-radius);
@@ -95,14 +102,13 @@ $modal-widths: (xs, sm, md, lg, xl);
}
tedi-modal-header {
+ padding: var(--_tedi-modal-heading-padding-y) var(--_tedi-modal-heading-padding-x);
border-bottom: var(--borders-01) solid var(--modal-border-inner);
- padding: var(--_tedi-modal-heading-padding-y)
- var(--_tedi-modal-heading-padding-x);
.tedi-modal-header__head {
display: flex;
- align-items: center;
gap: var(--layout-grid-gutters-08);
+ align-items: center;
button[tedi-closing-button] {
margin-left: auto;
@@ -121,9 +127,8 @@ $modal-widths: (xs, sm, md, lg, xl);
tedi-modal-footer {
display: flex;
gap: var(--layout-grid-gutters-16);
-
+ justify-content: flex-end;
+ padding: var(--_tedi-modal-footer-padding-y) var(--_tedi-modal-footer-padding-x);
border-top: var(--borders-01) solid var(--modal-border-inner);
- padding: var(--_tedi-modal-footer-padding-y)
- var(--_tedi-modal-footer-padding-x);
}
}
diff --git a/tedi/components/overlay/modal/modal.stories.ts b/tedi/components/overlay/modal/modal.stories.ts
index ef120ea63..f687aef03 100644
--- a/tedi/components/overlay/modal/modal.stories.ts
+++ b/tedi/components/overlay/modal/modal.stories.ts
@@ -5,8 +5,7 @@ import { ModalContentComponent } from "./modal-content/modal-content.component";
import { ModalFooterComponent } from "./modal-footer/modal-footer.component";
import { ButtonComponent } from "../../buttons/button/button.component";
import { LabelComponent } from "../../form/label/label.component";
-import { SelectComponent } from "community/components/form/select/select.component";
-import { SelectOptionComponent } from "community/components/form/select/select-option.component";
+import { SelectComponent, SelectOptionComponent } from "@tedi-design-system/angular/community";
import { IconComponent } from "../../base/icon/icon.component";
/**
@@ -175,7 +174,7 @@ export const Default: DefaultStory = {
-
+
Cancel
Continue
@@ -225,7 +224,7 @@ export const Size: StoryObj = {
-
+
Cancel
Continue
@@ -252,7 +251,7 @@ export const Size: StoryObj = {
-
+
Cancel
Continue
@@ -306,7 +305,7 @@ export const FooterVariants: StoryObj = {
-
+
Cancel
Continue
diff --git a/tedi/components/overlay/popover/popover.component.scss b/tedi/components/overlay/popover/popover.component.scss
index ce66d930e..5e9541d5a 100644
--- a/tedi/components/overlay/popover/popover.component.scss
+++ b/tedi/components/overlay/popover/popover.component.scss
@@ -20,20 +20,20 @@ tedi-popover {
float-ui-content {
.float-ui-container-popover {
+ z-index: var(--z-index-dropdown);
padding: 0;
border: var(--borders-01) solid var(--popover-border);
border-radius: var(--popover-radius-rounded);
- box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20, rgba(0, 0, 0, 0.2));
- z-index: var(--z-index-dropdown);
+ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%));
&--arrow {
.float-ui-arrow {
+ z-index: var(--z-index-dropdown);
width: 24px;
height: 24px;
background: var(--popover-background);
filter: drop-shadow(0 0 5px var(--tedi-alpha-20));
- clip-path: inset(0px -5px -5px 0px);
- z-index: var(--z-index-dropdown);
+ clip-path: inset(0 -5px -5px 0);
}
}
@@ -43,8 +43,8 @@ float-ui-content {
.float-ui-arrow {
width: 18px;
height: 18px;
- border-bottom: 4px solid var(--header-popover-border-top);
border-right: 4px solid var(--header-popover-border-top);
+ border-bottom: 4px solid var(--header-popover-border-top);
}
&[data-float-ui-placement="top"] {
@@ -117,14 +117,14 @@ float-ui-content {
.tedi-popover-content {
position: relative;
- max-width: calc(100dvw - 1rem);
display: flex;
flex-direction: column;
- background: var(--popover-background);
- color: var(--popover-text);
- border-radius: var(--popover-radius-rounded);
gap: var(--layout-grid-gutters-08);
+ max-width: calc(100dvw - 1rem);
padding: var(--popover-padding-sm);
+ color: var(--popover-text);
+ background: var(--popover-background);
+ border-radius: var(--popover-radius-rounded);
@each $name, $width in $popover-max-width {
&--#{$name} {
@@ -134,15 +134,15 @@ float-ui-content {
&__head {
display: flex;
- justify-content: space-between;
gap: var(--layout-grid-gutters-08);
+ justify-content: space-between;
> div {
display: flex;
- flex-direction: column;
flex: 1 1 0;
- min-width: 0;
+ flex-direction: column;
gap: var(--layout-grid-gutters-08);
+ min-width: 0;
}
}
diff --git a/tedi/components/overlay/tooltip/tooltip-content/tooltip-content.component.ts b/tedi/components/overlay/tooltip/tooltip-content/tooltip-content.component.ts
index fd3e1c493..2c666ca88 100644
--- a/tedi/components/overlay/tooltip/tooltip-content/tooltip-content.component.ts
+++ b/tedi/components/overlay/tooltip/tooltip-content/tooltip-content.component.ts
@@ -20,7 +20,7 @@ export type TooltipWidth = "none" | "small" | "medium" | "large";
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
"[class]": "classes()",
- role: "tooltip",
+ "aria-hidden": "true"
},
})
export class TooltipContentComponent {
diff --git a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts
index 8199f3ef0..608c9db6d 100644
--- a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts
+++ b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts
@@ -5,7 +5,8 @@ import { Component, viewChild } from "@angular/core";
import { Renderer2 } from "@angular/core";
class MockTooltipComponent {
- containerId = jest.fn(() => "mockContainer");
+ descriptionId = "mock-tooltip-id";
+ isOpen = jest.fn(() => false);
isContentHovered = jest.fn(() => false);
timeoutDelay = jest.fn(() => 100);
hideTimeout?: ReturnType;
@@ -175,6 +176,13 @@ describe("TooltipTriggerComponent", () => {
expect(tooltip.hideTooltip).not.toHaveBeenCalled();
});
});
+
+ describe("keydown.escape", () => {
+ it("should call hideTooltip when Escape key is pressed", () => {
+ hostEl.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ expect(tooltip.hideTooltip).toHaveBeenCalled();
+ });
+ });
});
describe("ngAfterContentChecked", () => {
@@ -218,5 +226,26 @@ describe("TooltipTriggerComponent", () => {
component.ngAfterContentChecked();
expect(btn.getAttribute("tabindex")).toBe("2");
});
+
+ it("should set ARIA attributes on interactive element when closed", () => {
+ hostEl.innerHTML = `Click me `;
+ const btn = hostEl.querySelector("button")!;
+ component.ngAfterContentChecked();
+ fixture.detectChanges();
+
+ expect(btn.getAttribute("aria-describedby")).toBe("mock-tooltip-id");
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("should set aria-expanded to true when tooltip is open", () => {
+ tooltip.isOpen = jest.fn(() => true);
+ hostEl.innerHTML = `Click me `;
+ const btn = hostEl.querySelector("button")!;
+ component.ngAfterContentChecked();
+ fixture.detectChanges();
+
+ expect(btn.getAttribute("aria-describedby")).toBe("mock-tooltip-id");
+ expect(btn.getAttribute("aria-expanded")).toBe("true");
+ });
});
});
diff --git a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts
index 195ce8af7..cce6165bd 100644
--- a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts
+++ b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts
@@ -2,10 +2,12 @@ import {
AfterContentChecked,
ChangeDetectionStrategy,
Component,
+ effect,
ElementRef,
HostListener,
inject,
Renderer2,
+ signal,
ViewEncapsulation,
} from "@angular/core";
import { TooltipComponent } from "../tooltip.component";
@@ -16,16 +18,26 @@ import { TooltipComponent } from "../tooltip.component";
template: " ",
styleUrl: "../tooltip.component.scss",
encapsulation: ViewEncapsulation.None,
- changeDetection: ChangeDetectionStrategy.OnPush,
- host: {
- "[id]": "tooltip.containerId() + '_trigger'",
- "[attr.aria-controls]": "tooltip.containerId()",
- },
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class TooltipTriggerComponent implements AfterContentChecked {
readonly host = inject>(ElementRef);
private renderer = inject(Renderer2);
readonly tooltip = inject(TooltipComponent);
+ private interactiveElement = signal(null);
+
+ constructor() {
+ effect(() => {
+ const element = this.interactiveElement();
+ if (!element) return;
+
+ const descriptionId = this.tooltip.descriptionId;
+ const isOpen = this.tooltip.isOpen();
+
+ element.setAttribute("aria-describedby", descriptionId);
+ element.setAttribute("aria-expanded", String(isOpen));
+ });
+ }
@HostListener("click")
onClick() {
@@ -85,6 +97,11 @@ export class TooltipTriggerComponent implements AfterContentChecked {
}
}
+ @HostListener("keydown.escape")
+ onEscape() {
+ this.tooltip.hideTooltip();
+ }
+
ngAfterContentChecked(): void {
const element = this.host.nativeElement as HTMLElement;
const firstChild = element.firstChild as HTMLElement | null;
@@ -103,6 +120,7 @@ export class TooltipTriggerComponent implements AfterContentChecked {
this.renderer.setAttribute(span, "tabindex", "0");
this.renderer.insertBefore(element, span, firstChild);
this.renderer.appendChild(span, firstChild);
+ this.interactiveElement.set(span);
return;
}
@@ -111,5 +129,7 @@ export class TooltipTriggerComponent implements AfterContentChecked {
if (!firstChild.getAttribute("tabindex")) {
this.renderer.setAttribute(firstChild, "tabindex", "0");
}
+
+ this.interactiveElement.set(firstChild);
}
}
diff --git a/tedi/components/overlay/tooltip/tooltip.component.html b/tedi/components/overlay/tooltip/tooltip.component.html
index 914c89b36..2723efa73 100644
--- a/tedi/components/overlay/tooltip/tooltip.component.html
+++ b/tedi/components/overlay/tooltip/tooltip.component.html
@@ -13,6 +13,9 @@
>
-
+@if (contentText()) {
+ {{ contentText() }}
+}
+
diff --git a/tedi/components/overlay/tooltip/tooltip.component.scss b/tedi/components/overlay/tooltip/tooltip.component.scss
index bab2a7347..fec2e1add 100644
--- a/tedi/components/overlay/tooltip/tooltip.component.scss
+++ b/tedi/components/overlay/tooltip/tooltip.component.scss
@@ -11,17 +11,17 @@ tedi-tooltip {
float-ui-content {
.float-ui-container-tooltip {
+ z-index: var(--z-index-tooltip);
padding: 0;
border: 0;
- box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20, rgba(0, 0, 0, 0.2));
- z-index: var(--z-index-tooltip);
border-radius: var(--popover-radius-rounded);
+ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%));
.float-ui-arrow {
width: 8px;
height: 8px;
background: var(--tooltip-background);
- clip-path: inset(0px -5px -5px 0px);
+ clip-path: inset(0 -5px -5px 0);
}
&[data-float-ui-placement="top"] {
@@ -77,12 +77,12 @@ tedi-tooltip-trigger {
.tedi-tooltip-content {
position: relative;
display: block;
- background: var(--tooltip-background);
- color: var(--tooltip-text);
- box-shadow: 0px 1px 5px 0px var(--tedi-alpha-20, rgba(0, 0, 0, 0.2));
max-width: var(--tooltip-max-width);
padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
+ color: var(--tooltip-text);
+ background: var(--tooltip-background);
border-radius: var(--tooltip-radius);
+ box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%));
@each $name, $width in $tooltip-max-width {
&--#{$name} {
diff --git a/tedi/components/overlay/tooltip/tooltip.component.ts b/tedi/components/overlay/tooltip/tooltip.component.ts
index 32f761970..33a13e890 100644
--- a/tedi/components/overlay/tooltip/tooltip.component.ts
+++ b/tedi/components/overlay/tooltip/tooltip.component.ts
@@ -1,4 +1,5 @@
import {
+ AfterContentChecked,
Component,
input,
ViewEncapsulation,
@@ -6,7 +7,7 @@ import {
viewChild,
contentChild,
signal,
- AfterContentChecked,
+ ElementRef,
} from "@angular/core";
import {
NgxFloatUiContentComponent,
@@ -14,10 +15,13 @@ import {
NgxFloatUiPlacements,
} from "ngx-float-ui";
import { TooltipTriggerComponent } from "./tooltip-trigger/tooltip-trigger.component";
+import { TooltipContentComponent } from "./tooltip-content/tooltip-content.component";
export type TooltipPosition = `${NgxFloatUiPlacements}`;
export type TooltipOpenWith = "hover" | "click" | "both";
+let tooltipIdCounter = 0;
+
@Component({
standalone: true,
selector: "tedi-tooltip",
@@ -61,7 +65,14 @@ export class TooltipComponent implements AfterContentChecked {
/** Dropdown trigger button */
readonly tooltipTrigger = contentChild.required(TooltipTriggerComponent);
- readonly containerId = signal("");
+ /** Tooltip content component */
+ readonly tooltipContent = contentChild.required(TooltipContentComponent, {
+ read: ElementRef,
+ });
+
+ readonly descriptionId = `tedi-tooltip-${++tooltipIdCounter}`;
+ readonly contentText = signal("");
+ readonly isOpen = signal(false);
isContentHovered = signal(false);
floatUiDisplay = signal<"inline" | "block">("inline");
@@ -73,6 +84,7 @@ export class TooltipComponent implements AfterContentChecked {
clearTimeout(this.hideTimeout);
this.floatUiComponent().show();
this.floatUiDisplay.set("block");
+ this.isOpen.set(true);
}
}
@@ -80,6 +92,7 @@ export class TooltipComponent implements AfterContentChecked {
if (this.floatUiComponent().state) {
this.floatUiComponent().hide();
this.floatUiDisplay.set("inline");
+ this.isOpen.set(false);
}
}
@@ -92,15 +105,12 @@ export class TooltipComponent implements AfterContentChecked {
}
ngAfterContentChecked(): void {
- const floatUiEl = this.floatUiComponent().elRef
- .nativeElement as HTMLElement;
- const container = floatUiEl.querySelector(
- ".float-ui-container",
- );
-
- if (container) {
- container.setAttribute("aria-labelledby", container.id + "_trigger");
- this.containerId.set(container.id);
+ const contentEl = this.tooltipContent()?.nativeElement as HTMLElement;
+ if (contentEl) {
+ const text = contentEl.textContent?.trim() ?? "";
+ if (text !== this.contentText()) {
+ this.contentText.set(text);
+ }
}
}
}
diff --git a/tedi/services/index.ts b/tedi/services/index.ts
index 549e7a9f5..80b532e2b 100644
--- a/tedi/services/index.ts
+++ b/tedi/services/index.ts
@@ -2,3 +2,4 @@ export * from "./breakpoint/breakpoint.service";
export * from "./translation/translation.service";
export * from "./theme/theme.service";
export * from "./translation/translation.pipe";
+export * from "./toast/toast.service";
diff --git a/tedi/services/sidenav/sidenav.service.spec.ts b/tedi/services/sidenav/sidenav.service.spec.ts
new file mode 100644
index 000000000..5425fb685
--- /dev/null
+++ b/tedi/services/sidenav/sidenav.service.spec.ts
@@ -0,0 +1,161 @@
+import { TestBed } from "@angular/core/testing";
+import { signal } from "@angular/core";
+import { SideNavService } from "./sidenav.service";
+import { BreakpointService } from "../breakpoint/breakpoint.service";
+import { SideNavItemComponent } from "../../components/layout/sidenav/sidenav-item/sidenav-item.component";
+
+describe("SideNavService", () => {
+ let service: SideNavService;
+ let isBelowBreakpointSignal: ReturnType>;
+
+ beforeEach(() => {
+ isBelowBreakpointSignal = signal(false);
+
+ const breakpointServiceMock = {
+ isBelowBreakpoint: jest.fn().mockReturnValue(isBelowBreakpointSignal),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ SideNavService,
+ { provide: BreakpointService, useValue: breakpointServiceMock },
+ ],
+ });
+
+ service = TestBed.inject(SideNavService);
+ });
+
+ it("should be created", () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe("registerItem", () => {
+ it("should add item to items array", () => {
+ const item = {} as SideNavItemComponent;
+ expect(service.items().length).toBe(0);
+
+ service.registerItem(item);
+
+ expect(service.items().length).toBe(1);
+ expect(service.items()[0]).toBe(item);
+ });
+ });
+
+ describe("unregisterItem", () => {
+ it("should remove item from items array", () => {
+ const item1 = { id: 1 } as unknown as SideNavItemComponent;
+ const item2 = { id: 2 } as unknown as SideNavItemComponent;
+
+ service.registerItem(item1);
+ service.registerItem(item2);
+ expect(service.items().length).toBe(2);
+
+ service.unregisterItem(item1);
+
+ expect(service.items().length).toBe(1);
+ expect(service.items()[0]).toBe(item2);
+ });
+ });
+
+ describe("handleGoToMainMenu", () => {
+ it("should close all open dropdowns", () => {
+ const openSignal1 = signal(true);
+ const openSignal2 = signal(true);
+ const item1 = { dropdown: { open: openSignal1 } } as unknown as SideNavItemComponent;
+ const item2 = { dropdown: { open: openSignal2 } } as unknown as SideNavItemComponent;
+
+ service.registerItem(item1);
+ service.registerItem(item2);
+
+ service.handleGoToMainMenu();
+
+ expect(openSignal1()).toBe(false);
+ expect(openSignal2()).toBe(false);
+ });
+
+ it("should handle items without dropdowns", () => {
+ const item = { dropdown: undefined } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(() => service.handleGoToMainMenu()).not.toThrow();
+ });
+ });
+
+ describe("handleCollapse", () => {
+ it("should toggle isCollapsed state", () => {
+ expect(service.isCollapsed()).toBe(false);
+
+ service.handleCollapse();
+ expect(service.isCollapsed()).toBe(true);
+
+ service.handleCollapse();
+ expect(service.isCollapsed()).toBe(false);
+ });
+ });
+
+ describe("isMobileItemOpen", () => {
+ it("should return false when not mobile", () => {
+ isBelowBreakpointSignal.set(false);
+ const openSignal = signal(true);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.isMobileItemOpen()).toBe(false);
+ });
+
+ it("should return false when mobile but no dropdown open", () => {
+ isBelowBreakpointSignal.set(true);
+ const openSignal = signal(false);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.isMobileItemOpen()).toBe(false);
+ });
+
+ it("should return true when mobile and dropdown is open", () => {
+ isBelowBreakpointSignal.set(true);
+ const openSignal = signal(true);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.isMobileItemOpen()).toBe(true);
+ });
+ });
+
+ describe("tooltipEnabled", () => {
+ it("should return false when not collapsed", () => {
+ service.isCollapsed.set(false);
+ expect(service.tooltipEnabled()).toBe(false);
+ });
+
+ it("should return true when collapsed and no dropdown open", () => {
+ service.isCollapsed.set(true);
+ const openSignal = signal(false);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.tooltipEnabled()).toBe(true);
+ });
+
+ it("should return false when collapsed but dropdown is open", () => {
+ service.isCollapsed.set(true);
+ const openSignal = signal(true);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.tooltipEnabled()).toBe(false);
+ });
+ });
+
+ describe("effect: reset collapsed on mobile", () => {
+ it("should reset isCollapsed to false when switching to mobile while collapsed", () => {
+ service.isCollapsed.set(true);
+ expect(service.isCollapsed()).toBe(true);
+
+ isBelowBreakpointSignal.set(true);
+ TestBed.flushEffects();
+
+ expect(service.isCollapsed()).toBe(false);
+ });
+ });
+});
diff --git a/tedi/services/toast/toast-announcer.service.spec.ts b/tedi/services/toast/toast-announcer.service.spec.ts
new file mode 100644
index 000000000..d8b82406d
--- /dev/null
+++ b/tedi/services/toast/toast-announcer.service.spec.ts
@@ -0,0 +1,251 @@
+import { TestBed, fakeAsync, tick } from "@angular/core/testing";
+import { DOCUMENT } from "@angular/common";
+import { ToastAnnouncerService } from "./toast-announcer.service";
+
+describe("ToastAnnouncerService", () => {
+ let service: ToastAnnouncerService;
+ let document: Document;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [ToastAnnouncerService],
+ });
+
+ service = TestBed.inject(ToastAnnouncerService);
+ document = TestBed.inject(DOCUMENT);
+ });
+
+ afterEach(() => {
+ service.destroy();
+ });
+
+ describe("announce", () => {
+ it("should create polite announcer element", fakeAsync(() => {
+ service.announce("Test message", "polite");
+
+ tick(100);
+
+ const element = document.getElementById("tedi-toast-announcer-polite");
+ expect(element).toBeTruthy();
+ expect(element?.getAttribute("aria-live")).toBe("polite");
+ expect(element?.getAttribute("aria-atomic")).toBe("true");
+ expect(element?.getAttribute("role")).toBe("status");
+ expect(element?.classList.contains("sr-only")).toBe(true);
+ }));
+
+ it("should create assertive announcer element", fakeAsync(() => {
+ service.announce("Test message", "assertive");
+
+ tick(100);
+
+ const element = document.getElementById("tedi-toast-announcer-assertive");
+ expect(element).toBeTruthy();
+ expect(element?.getAttribute("aria-live")).toBe("assertive");
+ expect(element?.getAttribute("aria-atomic")).toBe("true");
+ expect(element?.getAttribute("role")).toBe("alert");
+ expect(element?.classList.contains("sr-only")).toBe(true);
+ }));
+
+ it("should set message content after delay", fakeAsync(() => {
+ service.announce("Test message", "polite");
+
+ const element = document.getElementById("tedi-toast-announcer-polite");
+ expect(element?.textContent).toBe("");
+
+ tick(100);
+
+ expect(element?.textContent).toBe("Test message");
+ }));
+
+ it("should clear message after clearAfterMs", fakeAsync(() => {
+ service.announce("Test message", "polite", 500);
+
+ tick(100);
+ expect(
+ document.getElementById("tedi-toast-announcer-polite")?.textContent
+ ).toBe("Test message");
+
+ tick(500);
+ expect(
+ document.getElementById("tedi-toast-announcer-polite")?.textContent
+ ).toBe("");
+ }));
+
+ it("should use default polite politeness", fakeAsync(() => {
+ service.announce("Test message");
+
+ tick(100);
+
+ const politeElement = document.getElementById(
+ "tedi-toast-announcer-polite"
+ );
+ expect(politeElement?.textContent).toBe("Test message");
+ }));
+
+ it("should reuse existing element for same politeness", fakeAsync(() => {
+ service.announce("First message", "polite");
+ tick(100);
+
+ const firstElement = document.getElementById(
+ "tedi-toast-announcer-polite"
+ );
+
+ service.announce("Second message", "polite");
+ tick(100);
+
+ const secondElement = document.getElementById(
+ "tedi-toast-announcer-polite"
+ );
+ expect(firstElement).toBe(secondElement);
+ expect(secondElement?.textContent).toBe("Second message");
+ }));
+
+ it("should create separate elements for different politeness levels", fakeAsync(() => {
+ service.announce("Polite message", "polite");
+ service.announce("Assertive message", "assertive");
+
+ tick(100);
+
+ const politeElement = document.getElementById(
+ "tedi-toast-announcer-polite"
+ );
+ const assertiveElement = document.getElementById(
+ "tedi-toast-announcer-assertive"
+ );
+
+ expect(politeElement).toBeTruthy();
+ expect(assertiveElement).toBeTruthy();
+ expect(politeElement).not.toBe(assertiveElement);
+ }));
+
+ it("should clear content before setting new message for re-announcement", fakeAsync(() => {
+ service.announce("First message", "polite");
+ tick(100);
+
+ const element = document.getElementById("tedi-toast-announcer-polite");
+ expect(element?.textContent).toBe("First message");
+
+ // Announce same message again
+ service.announce("First message", "polite");
+
+ expect(element?.textContent).toBe("");
+
+ tick(100);
+ expect(element?.textContent).toBe("First message");
+ }));
+ });
+
+ describe("clear", () => {
+ it("should clear polite element content", fakeAsync(() => {
+ service.announce("Test message", "polite");
+ tick(100);
+
+ service.clear();
+
+ const element = document.getElementById("tedi-toast-announcer-polite");
+ expect(element?.textContent).toBe("");
+ }));
+
+ it("should clear assertive element content", fakeAsync(() => {
+ service.announce("Test message", "assertive");
+ tick(100);
+
+ service.clear();
+
+ const element = document.getElementById("tedi-toast-announcer-assertive");
+ expect(element?.textContent).toBe("");
+ }));
+
+ it("should clear both elements", fakeAsync(() => {
+ service.announce("Polite message", "polite");
+ service.announce("Assertive message", "assertive");
+ tick(100);
+
+ service.clear();
+
+ expect(
+ document.getElementById("tedi-toast-announcer-polite")?.textContent
+ ).toBe("");
+ expect(
+ document.getElementById("tedi-toast-announcer-assertive")?.textContent
+ ).toBe("");
+ }));
+
+ it("should not throw when no elements exist", () => {
+ expect(() => service.clear()).not.toThrow();
+ });
+ });
+
+ describe("destroy", () => {
+ it("should remove polite element from DOM", fakeAsync(() => {
+ service.announce("Test message", "polite");
+ tick(100);
+
+ expect(
+ document.getElementById("tedi-toast-announcer-polite")
+ ).toBeTruthy();
+
+ service.destroy();
+
+ expect(document.getElementById("tedi-toast-announcer-polite")).toBeNull();
+ }));
+
+ it("should remove assertive element from DOM", fakeAsync(() => {
+ service.announce("Test message", "assertive");
+ tick(100);
+
+ expect(
+ document.getElementById("tedi-toast-announcer-assertive")
+ ).toBeTruthy();
+
+ service.destroy();
+
+ expect(
+ document.getElementById("tedi-toast-announcer-assertive")
+ ).toBeNull();
+ }));
+
+ it("should remove both elements", fakeAsync(() => {
+ service.announce("Polite", "polite");
+ service.announce("Assertive", "assertive");
+ tick(100);
+
+ service.destroy();
+
+ expect(document.getElementById("tedi-toast-announcer-polite")).toBeNull();
+ expect(
+ document.getElementById("tedi-toast-announcer-assertive")
+ ).toBeNull();
+ }));
+
+ it("should not throw when no elements exist", () => {
+ expect(() => service.destroy()).not.toThrow();
+ });
+
+ it("should allow creating new elements after destroy", fakeAsync(() => {
+ service.announce("First", "polite");
+ tick(100);
+ service.destroy();
+
+ service.announce("Second", "polite");
+ tick(100);
+
+ const element = document.getElementById("tedi-toast-announcer-polite");
+ expect(element).toBeTruthy();
+ expect(element?.textContent).toBe("Second");
+ }));
+ });
+
+ describe("ngOnDestroy", () => {
+ it("should call destroy on ngOnDestroy", fakeAsync(() => {
+ const destroySpy = jest.spyOn(service, "destroy");
+
+ service.announce("Test", "polite");
+ tick(100);
+
+ service.ngOnDestroy();
+
+ expect(destroySpy).toHaveBeenCalled();
+ }));
+ });
+});
diff --git a/tedi/services/toast/toast-announcer.service.ts b/tedi/services/toast/toast-announcer.service.ts
new file mode 100644
index 000000000..5f155319a
--- /dev/null
+++ b/tedi/services/toast/toast-announcer.service.ts
@@ -0,0 +1,86 @@
+import { Injectable, inject, OnDestroy } from "@angular/core";
+import { DOCUMENT } from "@angular/common";
+
+/**
+ * Custom announcer service for toast notifications that uses the `sr-only` class
+ * instead of CDK's LiveAnnouncer which requires CDK styles.
+ *
+ * Creates a visually hidden element that screen readers can access to announce
+ * toast messages with appropriate politeness levels.
+ *
+ * @internal
+ */
+@Injectable({ providedIn: "root" })
+export class ToastAnnouncerService implements OnDestroy {
+ private readonly document = inject(DOCUMENT);
+
+ private politeElement: HTMLElement | null = null;
+ private assertiveElement: HTMLElement | null = null;
+
+ /**
+ * Announce a message to screen readers.
+ * @param message The message to announce
+ * @param politeness The politeness level: 'polite' (default) or 'assertive'
+ * @param clearAfterMs Time in ms after which to clear the message (default: 1000ms)
+ */
+ announce(message: string, politeness: "polite" | "assertive" = "polite", clearAfterMs: number = 1000): void {
+ const element = this.getOrCreateElement(politeness);
+ element.textContent = "";
+
+ // Use a small timeout to ensure screen readers detect the change
+ setTimeout(() => {
+ element.textContent = message;
+ setTimeout(() => {
+ element.textContent = "";
+ }, clearAfterMs);
+ }, 100);
+ }
+
+ /**
+ * Clear all announcements text content.
+ */
+ clear(): void {
+ if (this.politeElement) {
+ this.politeElement.textContent = "";
+ }
+ if (this.assertiveElement) {
+ this.assertiveElement.textContent = "";
+ }
+ }
+
+ destroy(): void {
+ this.politeElement?.remove();
+ this.assertiveElement?.remove();
+ this.politeElement = null;
+ this.assertiveElement = null;
+ }
+
+ ngOnDestroy(): void {
+ this.destroy();
+ }
+
+ private getOrCreateElement(politeness: "polite" | "assertive"): HTMLElement {
+ if (politeness === "assertive") {
+ if (!this.assertiveElement) {
+ this.assertiveElement = this.createAnnouncerElement("assertive");
+ }
+ return this.assertiveElement;
+ } else {
+ if (!this.politeElement) {
+ this.politeElement = this.createAnnouncerElement("polite");
+ }
+ return this.politeElement;
+ }
+ }
+
+ private createAnnouncerElement(politeness: "polite" | "assertive"): HTMLElement {
+ const element = this.document.createElement("span");
+ element.setAttribute("aria-live", politeness);
+ element.setAttribute("aria-atomic", "true");
+ element.setAttribute("role", politeness === "assertive" ? "alert" : "status");
+ element.classList.add("sr-only");
+ element.id = `tedi-toast-announcer-${politeness}`;
+ this.document.body.appendChild(element);
+ return element;
+ }
+}
diff --git a/tedi/services/toast/toast.service.spec.ts b/tedi/services/toast/toast.service.spec.ts
new file mode 100644
index 000000000..55e9682b0
--- /dev/null
+++ b/tedi/services/toast/toast.service.spec.ts
@@ -0,0 +1,418 @@
+import { TestBed, fakeAsync, tick } from "@angular/core/testing";
+import { ToastService } from "./toast.service";
+import { ToastAnnouncerService } from "./toast-announcer.service";
+import { Overlay, OverlayRef } from "@angular/cdk/overlay";
+
+describe("ToastService", () => {
+ let service: ToastService;
+ let announcerSpy: jest.SpyInstance;
+ let mockOverlayRef: Partial;
+ let mockOverlay: Partial;
+
+ beforeEach(() => {
+ // Reset static state before each test
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (ToastService as any).sharedToasts.set([]);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (ToastService as any).sharedTimerMap.clear();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (ToastService as any).sharedOverlayRef = null;
+
+ mockOverlayRef = {
+ attach: jest.fn(),
+ dispose: jest.fn(),
+ hasAttached: jest.fn().mockReturnValue(true),
+ overlayElement: { isConnected: true } as HTMLElement,
+ };
+
+ mockOverlay = {
+ create: jest.fn().mockReturnValue(mockOverlayRef),
+ scrollStrategies: {
+ noop: jest.fn().mockReturnValue({}),
+ } as unknown as Overlay["scrollStrategies"],
+ position: jest.fn().mockReturnValue({
+ global: jest.fn().mockReturnValue({}),
+ }),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ ToastService,
+ ToastAnnouncerService,
+ { provide: Overlay, useValue: mockOverlay },
+ ],
+ });
+
+ service = TestBed.inject(ToastService);
+ const announcer = TestBed.inject(ToastAnnouncerService);
+ announcerSpy = jest.spyOn(announcer, "announce");
+ });
+
+ afterEach(fakeAsync(() => {
+ const toasts = service.getToasts();
+ toasts.forEach((toast) => {
+ service.close(toast.id);
+ });
+ tick(300);
+ }));
+
+ describe("show methods", () => {
+ it("should show info toast", fakeAsync(() => {
+ const id = service.info("Info Title", "Info content");
+
+ expect(id).toBeDefined();
+ expect(service.getToasts().length).toBe(1);
+ expect(service.getToasts()[0].type).toBe("info");
+ expect(service.getToasts()[0].title).toBe("Info Title");
+ expect(service.getToasts()[0].content).toBe("Info content");
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should show success toast", fakeAsync(() => {
+ const id = service.success("Success Title", "Success content");
+
+ expect(service.getToasts()[0].type).toBe("success");
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should show warning toast", fakeAsync(() => {
+ const id = service.warning("Warning Title", "Warning content");
+
+ expect(service.getToasts()[0].type).toBe("warning");
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should show danger toast with alert role by default", fakeAsync(() => {
+ const id = service.danger("Danger Title", "Danger content");
+
+ expect(service.getToasts()[0].type).toBe("danger");
+ expect(service.getToasts()[0].role).toBe("alert");
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should show toast with custom options", fakeAsync(() => {
+ const id = service.show({
+ title: "Custom Toast",
+ content: "Custom content",
+ type: "success",
+ icon: "check",
+ position: "top-left",
+ duration: 5000,
+ showProgressBar: true,
+ pauseOnHover: false,
+ role: "alert",
+ });
+
+ const toast = service.getToasts()[0];
+ expect(toast.title).toBe("Custom Toast");
+ expect(toast.content).toBe("Custom content");
+ expect(toast.type).toBe("success");
+ expect(toast.icon).toBe("check");
+ expect(toast.position).toBe("top-left");
+ expect(toast.duration).toBe(5000);
+ expect(toast.showProgressBar).toBe(true);
+ expect(toast.pauseOnHover).toBe(false);
+ expect(toast.role).toBe("alert");
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should use custom id when provided", fakeAsync(() => {
+ const customId = "my-custom-toast-id";
+ const id = service.info("Title", "Content", { id: customId });
+
+ expect(id).toBe(customId);
+ expect(service.getToasts()[0].id).toBe(customId);
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should use default values when not provided", fakeAsync(() => {
+ const id = service.show({ title: "Minimal Toast" });
+
+ const toast = service.getToasts()[0];
+ expect(toast.type).toBe("info");
+ expect(toast.position).toBe("bottom-right");
+ expect(toast.role).toBe("status");
+ expect(toast.showProgressBar).toBe(false);
+ expect(toast.pauseOnHover).toBe(true);
+
+ service.close(id);
+ tick(300);
+ }));
+ });
+
+ describe("auto-close", () => {
+ it("should auto-close toast after duration", fakeAsync(() => {
+ service.info("Auto close", "Content", { duration: 1000 });
+
+ expect(service.getToasts().length).toBe(1);
+
+ tick(1000); // Duration
+ tick(300); // Animation
+
+ expect(service.getToasts().length).toBe(0);
+ }));
+
+ it("should not auto-close when duration is 0", fakeAsync(() => {
+ const id = service.info("Persistent", "Content", { duration: 0 });
+
+ expect(service.getToasts().length).toBe(1);
+
+ tick(10000);
+
+ expect(service.getToasts().length).toBe(1);
+
+ service.close(id);
+ tick(300);
+ }));
+ });
+
+ describe("close", () => {
+ it("should close toast by id", fakeAsync(() => {
+ const id = service.info("Title", "Content");
+
+ expect(service.getToasts().length).toBe(1);
+
+ service.close(id);
+
+ expect(service.getToasts()[0].exiting).toBe(true);
+ tick(300);
+
+ expect(service.getToasts().length).toBe(0);
+ }));
+
+ it("should not throw when closing non-existent toast", fakeAsync(() => {
+ expect(() => service.close("non-existent-id")).not.toThrow();
+ }));
+
+ it("should not close already exiting toast", fakeAsync(() => {
+ const id = service.info("Title", "Content");
+ service.close(id);
+
+ // Try to close again while exiting
+ service.close(id);
+
+ tick(300);
+ expect(service.getToasts().length).toBe(0);
+ }));
+ });
+
+ describe("pause and resume", () => {
+ it("should pause toast timer", fakeAsync(() => {
+ const id = service.info("Title", "Content", {
+ duration: 2000,
+ pauseOnHover: true,
+ });
+
+ tick(500);
+ service.pause(id);
+
+ expect(service.getToasts()[0].paused).toBe(true);
+
+ tick(5000); // Wait longer than original duration
+
+ expect(service.getToasts().length).toBe(1);
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should resume toast timer", fakeAsync(() => {
+ const id = service.info("Title", "Content", {
+ duration: 2000,
+ pauseOnHover: true,
+ });
+
+ tick(500);
+ service.pause(id);
+
+ tick(1000);
+ service.resume(id);
+
+ expect(service.getToasts()[0].paused).toBe(false);
+
+ tick(1500);
+ tick(300); // Animation
+
+ expect(service.getToasts().length).toBe(0);
+ }));
+
+ it("should not pause when pauseOnHover is false", fakeAsync(() => {
+ const id = service.info("Title", "Content", {
+ duration: 2000,
+ pauseOnHover: false,
+ });
+
+ service.pause(id);
+
+ expect(service.getToasts()[0].paused).toBeFalsy();
+
+ tick(2000);
+ tick(300);
+ }));
+
+ it("should not pause exiting toast", fakeAsync(() => {
+ const id = service.info("Title", "Content", {
+ duration: 2000,
+ pauseOnHover: true,
+ });
+
+ service.close(id);
+ service.pause(id);
+
+ tick(300);
+ }));
+
+ it("should not resume when not paused", fakeAsync(() => {
+ const id = service.info("Title", "Content", {
+ duration: 2000,
+ pauseOnHover: true,
+ });
+
+ service.resume(id);
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should not resume exiting toast", fakeAsync(() => {
+ const id = service.info("Title", "Content", {
+ duration: 2000,
+ pauseOnHover: true,
+ });
+
+ service.pause(id);
+ service.close(id);
+ service.resume(id);
+
+ tick(300);
+ }));
+
+ it("should not pause non-existent toast", fakeAsync(() => {
+ expect(() => service.pause("non-existent")).not.toThrow();
+ }));
+
+ it("should not resume non-existent toast", fakeAsync(() => {
+ expect(() => service.resume("non-existent")).not.toThrow();
+ }));
+ });
+
+ describe("screen reader announcements", () => {
+ it("should announce with polite politeness for status role", fakeAsync(() => {
+ const id = service.info("Title", "Content", { role: "status" });
+
+ expect(announcerSpy).toHaveBeenCalledWith("Title: Content", "polite");
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should announce with assertive politeness for alert role", fakeAsync(() => {
+ const id = service.danger("Error", "Something went wrong");
+
+ expect(announcerSpy).toHaveBeenCalledWith(
+ "Error: Something went wrong",
+ "assertive"
+ );
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should not announce when role is none", fakeAsync(() => {
+ const id = service.info("Title", "Content", { role: "none" });
+
+ expect(announcerSpy).not.toHaveBeenCalled();
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should announce only title when no content", fakeAsync(() => {
+ const id = service.info("Title Only");
+
+ expect(announcerSpy).toHaveBeenCalledWith("Title Only", "polite");
+
+ service.close(id);
+ tick(300);
+ }));
+ });
+
+ describe("multiple toasts", () => {
+ it("should manage multiple toasts", fakeAsync(() => {
+ const id1 = service.info("Toast 1");
+ const id2 = service.success("Toast 2");
+ const id3 = service.warning("Toast 3");
+
+ expect(service.getToasts().length).toBe(3);
+
+ service.close(id2);
+ tick(300);
+
+ expect(service.getToasts().length).toBe(2);
+ expect(service.getToasts().find((t) => t.id === id2)).toBeUndefined();
+
+ service.close(id1);
+ service.close(id3);
+ tick(300);
+ }));
+
+ it("should handle toasts in different positions", fakeAsync(() => {
+ const id1 = service.info("Top Left", undefined, { position: "top-left" });
+ const id2 = service.info("Bottom Right", undefined, {
+ position: "bottom-right",
+ });
+
+ const toasts = service.getToasts();
+ expect(toasts.find((t) => t.id === id1)?.position).toBe("top-left");
+ expect(toasts.find((t) => t.id === id2)?.position).toBe("bottom-right");
+
+ service.close(id1);
+ service.close(id2);
+ tick(300);
+ }));
+ });
+
+ describe("overlay management", () => {
+ it("should create overlay on first toast", fakeAsync(() => {
+ const id = service.info("Title");
+
+ expect(mockOverlay.create).toHaveBeenCalled();
+ expect(mockOverlayRef.attach).toHaveBeenCalled();
+
+ service.close(id);
+ tick(300);
+ }));
+
+ it("should reuse existing overlay for subsequent toasts", fakeAsync(() => {
+ const id1 = service.info("Toast 1");
+ const id2 = service.info("Toast 2");
+
+ expect(mockOverlay.create).toHaveBeenCalledTimes(1);
+
+ service.close(id1);
+ service.close(id2);
+ tick(300);
+ }));
+
+ it("should dispose overlay when all toasts are closed", fakeAsync(() => {
+ const id = service.info("Title");
+
+ service.close(id);
+ tick(300);
+
+ expect(mockOverlayRef.dispose).toHaveBeenCalled();
+ }));
+ });
+});
diff --git a/tedi/services/toast/toast.service.ts b/tedi/services/toast/toast.service.ts
new file mode 100644
index 000000000..cbebea318
--- /dev/null
+++ b/tedi/services/toast/toast.service.ts
@@ -0,0 +1,288 @@
+import {
+ Injectable,
+ Injector,
+ inject,
+ signal,
+} from "@angular/core";
+import { Overlay, OverlayRef, OverlayConfig } from "@angular/cdk/overlay";
+import { ComponentPortal } from "@angular/cdk/portal";
+import { ToastContainerComponent, ToastItem } from "../../components/notifications/toast/toast-container.component";
+import { ToastConfig, ToastRole, TOAST_DEFAULT_DURATION } from "../../components/notifications/toast/toast.component";
+import { ToastAnnouncerService } from "./toast-announcer.service";
+
+type ToastMethodOptions = Partial>;
+
+const ANIMATION_DURATION = 300;
+
+let toastId = 0;
+
+interface ToastTimerState {
+ timeout: ReturnType | null;
+ startTime: number;
+ remainingTime: number;
+}
+
+@Injectable({ providedIn: "root" })
+export class ToastService {
+ private readonly overlay = inject(Overlay);
+ private readonly injector = inject(Injector);
+ private readonly announcer = inject(ToastAnnouncerService);
+
+ // Static shared state across all service instances
+ private static readonly sharedToasts = signal([]);
+ private static readonly sharedTimerMap = new Map();
+ private static sharedOverlayRef: OverlayRef | null = null;
+
+ // Instance accessors for static state
+ private get toasts() {
+ return ToastService.sharedToasts;
+ }
+
+ private get timerMap() {
+ return ToastService.sharedTimerMap;
+ }
+
+ private get overlayRef() {
+ return ToastService.sharedOverlayRef;
+ }
+
+ private set overlayRef(value: OverlayRef | null) {
+ ToastService.sharedOverlayRef = value;
+ }
+
+ /**
+ * Readonly signal of all current toasts.
+ * @internal
+ */
+ readonly toasts$ = ToastService.sharedToasts.asReadonly();
+
+ /**
+ * Get all current toasts
+ * @internal
+ */
+ getToasts(): ToastItem[] {
+ return this.toasts();
+ }
+
+ /**
+ * Show an info toast notification.
+ * @param title The toast title
+ * @param content Toast content
+ * @param options Additional toast options
+ */
+ info(title: string, content?: string, options?: ToastMethodOptions): string {
+ return this.show({ ...options, title, content, type: "info" });
+ }
+
+ /**
+ * Show a success toast notification.
+ * @param title The toast title
+ * @param content Toast content
+ * @param options Additional toast options
+ */
+ success(title: string, content?: string, options?: ToastMethodOptions): string {
+ return this.show({ ...options, title, content, type: "success" });
+ }
+
+ /**
+ * Show a warning toast notification.
+ * @param title The toast title
+ * @param content Toast content
+ * @param options Additional toast options
+ */
+ warning(title: string, content?: string, options?: ToastMethodOptions): string {
+ return this.show({ ...options, title, content, type: "warning" });
+ }
+
+ /**
+ * Show a danger toast notification.
+ * Defaults to role="alert" for immediate screen reader announcement.
+ * @param title The toast title
+ * @param content Toast content
+ * @param options Additional toast options
+ */
+ danger(title: string, content?: string, options?: ToastMethodOptions): string {
+ return this.show({ role: "alert", ...options, title, content, type: "danger" });
+ }
+
+ /**
+ * Show a toast notification with full configuration.
+ */
+ show(options: ToastConfig): string {
+ this.assertContainerExists();
+
+ const id = options.id || this.generateId();
+ const position = options.position || "bottom-right";
+ const duration = options.duration ?? TOAST_DEFAULT_DURATION;
+ const role = options.role ?? "status";
+ const showProgressBar = options.showProgressBar ?? false;
+ const pauseOnHover = options.pauseOnHover ?? true;
+
+ const toast: ToastItem = {
+ id,
+ title: options.title,
+ content: options.content,
+ type: options.type ?? "info",
+ icon: options.icon ?? "",
+ role,
+ duration,
+ showProgressBar,
+ pauseOnHover,
+ position,
+ };
+
+ this.toasts.update((toasts) => [...toasts, toast]);
+
+ this.announceToScreenReader(toast, role);
+
+ if (duration > 0) {
+ this.startTimer(id, duration);
+ }
+
+ return id;
+ }
+
+ /**
+ * Close a specific toast by ID.
+ */
+ close(id: string): void {
+ this.clearTimer(id);
+
+ const toast = this.toasts().find((t) => t.id === id);
+ if (!toast || toast.exiting) return;
+
+ this.toasts.update((toasts) =>
+ toasts.map((t) => (t.id === id ? { ...t, exiting: true } : t))
+ );
+
+ setTimeout(() => {
+ this.toasts.update((toasts) => toasts.filter((t) => t.id !== id));
+ this.cleanupIfEmpty();
+ }, ANIMATION_DURATION);
+ }
+
+ /**
+ * Pause a toast's auto-close timer.
+ * @internal
+ */
+ pause(id: string): void {
+ const toast = this.toasts().find((t) => t.id === id);
+ if (!toast || !toast.pauseOnHover || toast.exiting) return;
+
+ const timerState = this.timerMap.get(id);
+ if (!timerState || !timerState.timeout) return;
+
+ const elapsed = Date.now() - timerState.startTime;
+ const remainingTime = Math.max(0, timerState.remainingTime - elapsed);
+
+ clearTimeout(timerState.timeout);
+ timerState.timeout = null;
+ timerState.remainingTime = remainingTime;
+
+ this.toasts.update((toasts) =>
+ toasts.map((t) => (t.id === id ? { ...t, paused: true } : t))
+ );
+ }
+
+ /**
+ * Resume a toast's auto-close timer.
+ * @internal
+ */
+ resume(id: string): void {
+ const toast = this.toasts().find((t) => t.id === id);
+ if (!toast || !toast.pauseOnHover || toast.exiting) return;
+
+ const timerState = this.timerMap.get(id);
+ if (!timerState || timerState.timeout) return;
+
+ this.toasts.update((toasts) =>
+ toasts.map((t) => (t.id === id ? { ...t, paused: false } : t))
+ );
+
+ if (timerState.remainingTime > 0) {
+ timerState.startTime = Date.now();
+ timerState.timeout = setTimeout(() => this.close(id), timerState.remainingTime);
+ }
+ }
+
+ private startTimer(id: string, duration: number): void {
+ const timeout = setTimeout(() => this.close(id), duration);
+ this.timerMap.set(id, {
+ timeout,
+ startTime: Date.now(),
+ remainingTime: duration,
+ });
+ }
+
+ private clearTimer(id: string): void {
+ const timerState = this.timerMap.get(id);
+ if (timerState?.timeout) {
+ clearTimeout(timerState.timeout);
+ }
+ this.timerMap.delete(id);
+ }
+
+ private assertContainerExists(): void {
+ if (this.overlayRef) {
+ // Check if the portal is attached and the overlay element is in the DOM
+ const isAttached = this.overlayRef.hasAttached();
+ const isInDom = this.overlayRef.overlayElement?.isConnected ?? false;
+
+ if (isAttached && isInDom) {
+ return;
+ }
+
+ // Portal detached or element removed from DOM, clean up stale overlay
+ try {
+ this.overlayRef.dispose();
+ } catch {
+ // Ignore disposal errors
+ }
+ this.overlayRef = null;
+ this.toasts.set([]);
+ this.timerMap.forEach((state) => {
+ if (state.timeout) clearTimeout(state.timeout);
+ });
+ this.timerMap.clear();
+ }
+
+ const overlayConfig = new OverlayConfig({
+ hasBackdrop: false,
+ scrollStrategy: this.overlay.scrollStrategies.noop(),
+ positionStrategy: this.overlay.position().global(),
+ });
+
+ this.overlayRef = this.overlay.create(overlayConfig);
+
+ const portal = new ComponentPortal(
+ ToastContainerComponent,
+ null,
+ this.injector
+ );
+
+ this.overlayRef.attach(portal);
+ }
+
+ private cleanupIfEmpty(): void {
+ if (!this.toasts().length && this.overlayRef) {
+ this.overlayRef.dispose();
+ this.overlayRef = null;
+ this.announcer.destroy();
+ }
+ }
+
+ private announceToScreenReader(toast: ToastItem, role: ToastRole): void {
+ if (role === "none") return;
+
+ const message = toast.content
+ ? `${toast.title}: ${toast.content}`
+ : toast.title;
+
+ const politeness = role === "alert" ? "assertive" : "polite";
+ this.announcer.announce(message, politeness);
+ }
+
+ private generateId(): string {
+ return `toast-${++toastId}`;
+ }
+}
diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts
index fd9800fab..f4e0b44fd 100644
--- a/tedi/services/translation/translations.ts
+++ b/tedi/services/translation/translations.ts
@@ -450,6 +450,13 @@ export const translationsMap = {
en: "Show tooltip",
ru: "Показать подсказку",
},
+ "info-button.label": {
+ description: "Info button default label",
+ components: ["InfoButton"],
+ et: "Lisainfo",
+ en: "More information",
+ ru: "Дополнительная информация",
+ },
"pagination.title": {
description: "Label of the pagination",
components: ["Table", "Pagination"],
@@ -575,6 +582,13 @@ export const translationsMap = {
en: (isOpen: boolean) => (isOpen ? "Close menu" : "Open menu"),
ru: (isOpen: boolean) => (isOpen ? "Закрыть меню" : "Открыть меню"),
},
+ "sidenav.toggleSubmenu": {
+ description: "Label for sidenav submenu toggle",
+ components: ["Sidenav"],
+ et: (value: string, isOpen: boolean) => (`${isOpen ? 'Sulge' : 'Ava'} ${value} alammenüü`),
+ en: (value: string, isOpen: boolean) => (`${isOpen ? 'Close' : 'Open'} ${value} submenu`),
+ ru: (value: string, isOpen: boolean) => (`${isOpen ? 'Закрыть' : 'Открыть'} ${value} подменю`),
+ },
carousel: {
description: "Label for carousel",
components: ["CarouselContent"],
@@ -936,6 +950,22 @@ export const translationsMap = {
en: "Next years",
ru: "Следующие годы",
},
+ "vertical-stepper.completed": {
+ description:
+ "Label for screen-reader that this step is completed (visually hidden)",
+ components: ["VerticalStepper"],
+ et: "Lõpetatud",
+ en: "Completed",
+ ru: "Завершено",
+ },
+ "vertical-stepper.error": {
+ description:
+ "Label for screen-reader that this step has error (visually hidden)",
+ components: ["VerticalStepper"],
+ et: "Puudulik",
+ en: "Error",
+ ru: "Oшибка",
+ },
};
export type TediTranslationsMap = {
diff --git a/tedi/utils/date.util.spec.ts b/tedi/utils/date.util.spec.ts
new file mode 100644
index 000000000..d488a5e88
--- /dev/null
+++ b/tedi/utils/date.util.spec.ts
@@ -0,0 +1,151 @@
+import {
+ formatDate,
+ parseDate,
+ isSameDay,
+ isBeforeDay,
+ isAfterDay,
+ getISOWeek,
+} from "./date.util";
+
+describe("date.util", () => {
+ describe("formatDate", () => {
+ it("should format date as dd.MM.yyyy", () => {
+ expect(formatDate(new Date(2026, 2, 10))).toBe("10.03.2026");
+ });
+
+ it("should pad single digit day and month with zero", () => {
+ expect(formatDate(new Date(2026, 0, 5))).toBe("05.01.2026");
+ });
+ });
+
+ describe("parseDate", () => {
+ it("should return null for formats not matching dd.MM.yyyy", () => {
+ expect(parseDate("")).toBeNull();
+ expect(parseDate("12.05")).toBeNull();
+ expect(parseDate("12-05-2026")).toBeNull();
+ expect(parseDate("12/05/2026")).toBeNull();
+ });
+
+ it("should return null when day, month, or year are not valid numbers", () => {
+ expect(parseDate("aa.bb.cccc")).toBeNull();
+ expect(parseDate("1..2026")).toBeNull();
+ expect(parseDate(".02.2026")).toBeNull();
+ expect(parseDate("15.NaN.2026")).toBeNull();
+ });
+
+ it("should return null for impossible dates", () => {
+ expect(parseDate("31.02.2026")).toBeNull();
+ expect(parseDate("10.13.2026")).toBeNull();
+ expect(parseDate("00.12.2026")).toBeNull();
+ expect(parseDate("10.00.2026")).toBeNull();
+ });
+
+ it("should return a valid Date for correct input", () => {
+ const result = parseDate("10.03.2026");
+ expect(result).toEqual(new Date(2026, 2, 10));
+ });
+
+ it("should handle whitespace in input", () => {
+ const result = parseDate(" 10.03.2026 ");
+ expect(result).toEqual(new Date(2026, 2, 10));
+ });
+ });
+
+ describe("isSameDay", () => {
+ it("should return true for same calendar day", () => {
+ const a = new Date(2026, 4, 15, 10, 30);
+ const b = new Date(2026, 4, 15, 22, 45);
+ expect(isSameDay(a, b)).toBe(true);
+ });
+
+ it("should return false for different days", () => {
+ const a = new Date(2026, 4, 15);
+ const b = new Date(2026, 4, 16);
+ expect(isSameDay(a, b)).toBe(false);
+ });
+
+ it("should return false for different months", () => {
+ const a = new Date(2026, 4, 15);
+ const b = new Date(2026, 5, 15);
+ expect(isSameDay(a, b)).toBe(false);
+ });
+
+ it("should return false for different years", () => {
+ const a = new Date(2026, 4, 15);
+ const b = new Date(2025, 4, 15);
+ expect(isSameDay(a, b)).toBe(false);
+ });
+ });
+
+ describe("isBeforeDay", () => {
+ it("should return true when a is before b", () => {
+ const a = new Date(2026, 4, 14);
+ const b = new Date(2026, 4, 15);
+ expect(isBeforeDay(a, b)).toBe(true);
+ });
+
+ it("should return false when a equals b ignoring time", () => {
+ const a = new Date(2026, 4, 15, 0, 0, 0);
+ const b = new Date(2026, 4, 15, 23, 59, 59);
+ expect(isBeforeDay(a, b)).toBe(false);
+ });
+
+ it("should return false when a is after b", () => {
+ const a = new Date(2026, 4, 16);
+ const b = new Date(2026, 4, 15);
+ expect(isBeforeDay(a, b)).toBe(false);
+ });
+
+ it("should compare by year first", () => {
+ const a = new Date(2023, 11, 31);
+ const b = new Date(2026, 0, 1);
+ expect(isBeforeDay(a, b)).toBe(true);
+ });
+
+ it("should compare by month when years are equal", () => {
+ const a = new Date(2026, 3, 30);
+ const b = new Date(2026, 4, 1);
+ expect(isBeforeDay(a, b)).toBe(true);
+ });
+
+ it("should ignore time components completely", () => {
+ const a = new Date(2026, 4, 15, 23, 59, 59);
+ const b = new Date(2026, 4, 15, 0, 0, 0);
+ expect(isBeforeDay(a, b)).toBe(false);
+ });
+ });
+
+ describe("isAfterDay", () => {
+ it("should return true when a is after b", () => {
+ const a = new Date(2026, 4, 16);
+ const b = new Date(2026, 4, 15);
+ expect(isAfterDay(a, b)).toBe(true);
+ });
+
+ it("should return false when a equals b ignoring time", () => {
+ const a = new Date(2026, 4, 15, 23, 59, 59);
+ const b = new Date(2026, 4, 15, 0, 0, 0);
+ expect(isAfterDay(a, b)).toBe(false);
+ });
+
+ it("should return false when a is before b", () => {
+ const a = new Date(2026, 4, 14);
+ const b = new Date(2026, 4, 15);
+ expect(isAfterDay(a, b)).toBe(false);
+ });
+ });
+
+ describe("getISOWeek", () => {
+ it("should return week 1 for Jan 1, 2026", () => {
+ expect(getISOWeek(new Date(2026, 0, 1))).toBe(1);
+ });
+
+ it("should return week 53 for Dec 31, 2026", () => {
+ expect(getISOWeek(new Date(2026, 11, 31))).toBe(53);
+ });
+
+ it("should return week 1 for first week of year", () => {
+ expect(getISOWeek(new Date(2026, 0, 4))).toBe(1);
+ });
+ });
+});
diff --git a/tedi/utils/date.util.ts b/tedi/utils/date.util.ts
new file mode 100644
index 000000000..659c63ec7
--- /dev/null
+++ b/tedi/utils/date.util.ts
@@ -0,0 +1,89 @@
+/**
+ * Formats a Date object to dd.MM.yyyy string format.
+ */
+export function formatDate(date: Date): string {
+ const d = String(date.getDate()).padStart(2, "0");
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const y = date.getFullYear();
+ return `${d}.${m}.${y}`;
+}
+
+/**
+ * Parses a dd.MM.yyyy string to a Date object.
+ * Returns null if the string is invalid.
+ */
+export function parseDate(str: string): Date | null {
+ const parts = str.trim().split(".");
+ if (parts.length !== 3) return null;
+
+ const [dd, mm, yyyy] = parts.map(Number);
+ if (!dd || !mm || !yyyy) return null;
+
+ const date = new Date(yyyy, mm - 1, dd);
+
+ if (
+ date.getFullYear() !== yyyy ||
+ date.getMonth() !== mm - 1 ||
+ date.getDate() !== dd
+ ) {
+ return null;
+ }
+
+ return date;
+}
+
+/**
+ * Checks if two dates represent the same calendar day.
+ */
+export function isSameDay(a: Date, b: Date): boolean {
+ return (
+ a.getDate() === b.getDate() &&
+ a.getMonth() === b.getMonth() &&
+ a.getFullYear() === b.getFullYear()
+ );
+}
+
+/**
+ * Checks if date a is before date b.
+ */
+export function isBeforeDay(a: Date, b: Date): boolean {
+ if (a.getFullYear() !== b.getFullYear()) {
+ return a.getFullYear() < b.getFullYear();
+ }
+ if (a.getMonth() !== b.getMonth()) {
+ return a.getMonth() < b.getMonth();
+ }
+ return a.getDate() < b.getDate();
+}
+
+/**
+ * Checks if date a is after date b.
+ */
+export function isAfterDay(a: Date, b: Date): boolean {
+ if (a.getFullYear() !== b.getFullYear()) {
+ return a.getFullYear() > b.getFullYear();
+ }
+ if (a.getMonth() !== b.getMonth()) {
+ return a.getMonth() > b.getMonth();
+ }
+ return a.getDate() > b.getDate();
+}
+
+/**
+ * Returns the ISO week number for a given date.
+ */
+export function getISOWeek(date: Date): number {
+ const target = new Date(date);
+ target.setHours(0, 0, 0, 0);
+
+ const day = target.getDay();
+ const isoDay = day === 0 ? 7 : day;
+
+ target.setDate(target.getDate() + (4 - isoDay));
+ const yearStart = new Date(target.getFullYear(), 0, 1);
+
+ const diffInDays = Math.floor(
+ (target.getTime() - yearStart.getTime()) / 86400000,
+ );
+ return Math.floor(diffInDays / 7) + 1;
+}
diff --git a/tsconfig.json b/tsconfig.json
index d0d808586..1100c1611 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -13,7 +13,8 @@
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
- "@tedi-design-system/angular/tedi": ["./tedi/index.ts"]
+ "@tedi-design-system/angular/tedi": ["./tedi/index.ts"],
+ "@tedi-design-system/angular/community": ["./community/index.ts"]
}
},
"include": ["src/**/*.ts", "tedi/**/*.ts", "community/**/*.ts"],