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)} `, 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 @@ - +} 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()) { + + } @else { + + @if (route()) { + {{ title() }} + } @else { + + } + + + } +
+
+ +
+@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

- - + -
`, 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 @@ -
+
-
-
+
+
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/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) { + + } +
+ } +
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 @@ +
+ @if (currentView() === "calendar-grid") { + @if (showNavigation()) { + + } + +
+ @if (monthMode() === "dropdown") { + + + + @for (monthName of monthNames; track i; let i = $index) { +
  • + {{ monthName() }} +
  • + } +
    +
    + } @else if (monthMode() === "grid") { + + } @else if (monthMode() === "label") { +
    + {{ monthNames[month().getMonth()]() }} +
    + } + + @if (yearMode() === "dropdown") { + + + + @for (year of years(); track year) { +
  • + {{ year }} +
  • + } +
    +
    + } @else if (yearMode() === "grid") { + + } @else if (yearMode() === "label") { +
    + {{ selectedYear() }} +
    + } +
    + + @if (showNavigation()) { + + } + } @else if (currentView() === "month-grid") { +
    + {{ monthNames[month().getMonth()]() }} +
    + } @else if (currentView() === "year-grid") { + +
    {{ selectedYear() }}
    + + } +
    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) { + + } +
    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) { + + } +
    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 (showNavigation()) { - - } - -
    - @if (monthMode() === "dropdown") { - - - - @for (month of monthNames; track i; let i = $index) { -
  • - {{ month() }} -
  • - } -
    -
    - } @else if (monthMode() === "grid") { - - } @else if (monthMode() === "label") { -
    - {{ monthNames[month().getMonth()]() }} -
    - } - - @if (yearMode() === "dropdown") { - - - - @for (year of years(); track year) { -
  • - {{ year }} -
  • - } -
    -
    - } @else if (yearMode() === "grid") { - - } @else if (yearMode() === "label") { -
    - {{ selectedYear() }} -
    - } -
    - - @if (showNavigation()) { - - } - } @else if (currentView() === "month-grid") { -
    - {{ monthNames[month().getMonth()]() }} -
    - } @else if (currentView() === "year-grid") { - -
    {{ selectedYear() }}
    - - } -
    + @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) { - - } -
    - } -
    + [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) { - - } -
    + } @else if (currentView() === "year-grid") { -
    - @for (year of pagedYears(); track 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()) { -
    - + @@ -252,7 +251,7 @@ export const Size: StoryObj = {
    - + @@ -306,7 +305,7 @@ export const FooterVariants: StoryObj = {
    - + 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 = ``; + 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 = ``; + 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"],