From 83570693315923d8beb3b69f44f15f5899a905b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A4rt=20Sessman?= Date: Tue, 13 Jan 2026 14:14:37 +0200 Subject: [PATCH 01/48] fix(toggle): fixed handle position of active state for default size #256 (#257) Co-authored-by: m2rt --- .../form/toggle/toggle.component.scss | 96 +++++++------------ 1 file changed, 34 insertions(+), 62 deletions(-) diff --git a/tedi/components/form/toggle/toggle.component.scss b/tedi/components/form/toggle/toggle.component.scss index 5bfc7bd0..8e296419 100644 --- a/tedi/components/form/toggle/toggle.component.scss +++ b/tedi/components/form/toggle/toggle.component.scss @@ -1,5 +1,5 @@ @mixin toggle-style($variant, $type) { - @if $type == "filled" { + @if $type =="filled" { background-color: var(--form-toggl-#{$variant}-inactive-default); &:hover { @@ -12,9 +12,7 @@ &:focus-visible { background-color: var(--form-toggl-#{$variant}-inactive-default); - outline: calc(2 * var(--borders-01)) - solid - var(--form-toggl-primary-active-default); + outline: calc(2 * var(--borders-01)) solid var(--form-toggl-primary-active-default); outline-offset: var(--borders-01); } @@ -23,12 +21,12 @@ background-color: var(--form-toggl-#{$variant}-inactive-default); cursor: not-allowed; - + .tedi-toggle__slider .tedi-toggle__icon { + +.tedi-toggle__slider .tedi-toggle__icon { opacity: 0.5; } } - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-inactive-indicator); } @@ -45,9 +43,7 @@ &:focus-visible { background-color: var(--form-toggl-#{$variant}-active-default); - outline: calc(2 * var(--borders-01)) - solid - var(--form-toggl-primary-active-default); + outline: calc(2 * var(--borders-01)) solid var(--form-toggl-primary-active-default); outline-offset: var(--borders-01); } @@ -56,56 +52,46 @@ background-color: var(--form-toggl-#{$variant}-active-default); cursor: not-allowed; - + .tedi-toggle__slider .tedi-toggle__icon { + +.tedi-toggle__slider .tedi-toggle__icon { opacity: 0.5; } } - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-active-indicator); } } } - @if $type == "outlined" { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-inactive-default); + @if $type =="outlined" { + border: var(--borders-01) solid var(--form-toggl-#{$variant}-inactive-default); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-inactive-default); } &:hover { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-inactive-hover); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-inactive-hover); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-inactive-hover); } } &:active { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-inactive-active); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-inactive-active); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-inactive-active); } } &:focus-visible { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-inactive-default); - outline: calc(2 * var(--borders-01)) - solid - var(--form-toggl-primary-active-default); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-inactive-default); + outline: calc(2 * var(--borders-01)) solid var(--form-toggl-primary-active-default); outline-offset: var(--borders-01); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-inactive-default); } } @@ -113,58 +99,46 @@ &:disabled { opacity: 0.5; cursor: not-allowed; - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-inactive-default); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-inactive-default); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-inactive-default); } - + .tedi-toggle__slider { + +.tedi-toggle__slider { opacity: 0.5; } } &:checked { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-active-default); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-active-default); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-active-default); } &:hover { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-active-hover); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-active-hover); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-active-hover); } } &:active { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-active-active); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-active-active); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-active-active); } } &:focus-visible { - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-active-default); - outline: calc(2 * var(--borders-01)) - solid - var(--form-toggl-primary-active-default); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-active-default); + outline: calc(2 * var(--borders-01)) solid var(--form-toggl-primary-active-default); outline-offset: var(--borders-01); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-active-default); } } @@ -172,15 +146,13 @@ &:disabled { opacity: 0.5; cursor: not-allowed; - border: var(--borders-01) - solid - var(--form-toggl-#{$variant}-active-default); + border: var(--borders-01) solid var(--form-toggl-#{$variant}-active-default); - + .tedi-toggle__slider { + +.tedi-toggle__slider { background-color: var(--form-toggl-#{$variant}-active-default); } - + .tedi-toggle__slider { + +.tedi-toggle__slider { opacity: 0.5; } } @@ -193,9 +165,9 @@ display: block; &--size { - --_toggle-indicator: var(--form-toggl-default-indicator); &-default { + --_toggle-indicator: var(--form-toggl-default-indicator); width: var(--form-toggl-default-width); height: var(--form-toggl-default-height); @@ -253,7 +225,7 @@ margin: 0; border-radius: var(--form-toggl-radius); - &:checked + .tedi-toggle__slider { + &:checked+.tedi-toggle__slider { transform: translateY(-50%); left: calc(100% - var(--_toggle-indicator) - var(--form-toggl-padding)); } From 345ba37ba3dcee6de1c0a8743efa5d10c6a92c22 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 13 Jan 2026 12:16:52 +0000 Subject: [PATCH 02/48] chore(release): 5.0.1-rc.1 ## [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)) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446255dd..74071055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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) From 2b95a3f68d8def6ebb04c4072e6112711361df7b Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Fri, 16 Jan 2026 07:37:12 +0200 Subject: [PATCH 03/48] fix(modal, table-of-contents): fix import paths #243 (#264) --- .../table-of-contents/table-of-contents.component.ts | 2 +- tedi/components/overlay/modal/modal.stories.ts | 3 +-- tsconfig.json | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) 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 f4eb7931..636d49c6 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/tedi/components/overlay/modal/modal.stories.ts b/tedi/components/overlay/modal/modal.stories.ts index ef120ea6..98d07a6a 100644 --- a/tedi/components/overlay/modal/modal.stories.ts +++ b/tedi/components/overlay/modal/modal.stories.ts @@ -5,8 +5,7 @@ import { ModalContentComponent } from "./modal-content/modal-content.component"; import { ModalFooterComponent } from "./modal-footer/modal-footer.component"; import { ButtonComponent } from "../../buttons/button/button.component"; import { LabelComponent } from "../../form/label/label.component"; -import { SelectComponent } from "community/components/form/select/select.component"; -import { SelectOptionComponent } from "community/components/form/select/select-option.component"; +import { SelectComponent, SelectOptionComponent } from "@tedi-design-system/angular/community"; import { IconComponent } from "../../base/icon/icon.component"; /** diff --git a/tsconfig.json b/tsconfig.json index d0d80858..1100c161 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"], From 0150330d0da9caa597d9e22e595fc50500b5fe55 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 16 Jan 2026 05:39:36 +0000 Subject: [PATCH 04/48] chore(release): 5.0.1-rc.2 ## [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)) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74071055..883389b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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) From 86ebc6ffe7c674f8859410f9dccca92db270641b Mon Sep 17 00:00:00 2001 From: artur-langl <209750178+artur-langl@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:12:04 +0200 Subject: [PATCH 05/48] fix(file-dropzone): make file dropzone update forms on deletion, and use form validation instead of it's own internal implementation (#239) * fix(file-dropzone): refactor for validators #66 * fix(file-dropzone): move more validator logic into component #66 * fix(file-dropzone): refactor file validation to use forms #66 * fix(file-dropzone): better accept description in the story #66 * fix(file-dropzone): address reviews & remove non-functional ngOnChanges #66 --- .../file-dropzone.component.html | 2 +- .../file-dropzone/file-dropzone.component.ts | 148 +++++++++++++----- .../file-dropzone/file-dropzone.stories.ts | 8 +- .../form/file-dropzone/file.service.ts | 82 +--------- .../components/form/file-dropzone/types.ts | 15 +- .../components/form/file-dropzone/utils.ts | 41 +++-- 6 files changed, 163 insertions(+), 133 deletions(-) diff --git a/community/components/form/file-dropzone/file-dropzone.component.html b/community/components/form/file-dropzone/file-dropzone.component.html index c4406401..15f84538 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"); @@ -221,13 +222,71 @@ export class FileDropzoneComponent implements ControlValueAccessor, OnInit { 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; + console.log("running validators"); + + 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 +360,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 cea65add..d88c94ad 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 4106ab7e..673e96d5 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 092e7239..9df7d878 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 916d6e4f..35c1b989 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; From f0fa86527e307acdae629e0e6f04ace26618bf52 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 16 Jan 2026 09:14:19 +0000 Subject: [PATCH 06/48] chore(release): 5.0.1-rc.3 ## [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) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 883389b7..5e4d54a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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) From a76084f91704075637b6e0e19508c1c0851f94d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A4rt=20Sessman?= Date: Mon, 19 Jan 2026 09:51:10 +0200 Subject: [PATCH 07/48] fix(info-button): added default aria-label #46 (#265) --- .../info-button/info-button.component.spec.ts | 24 +++++++++++++++++++ .../info-button/info-button.component.ts | 15 ++++++++++-- .../info-button/info-button.stories.ts | 9 +++++++ tedi/services/translation/translations.ts | 7 ++++++ 4 files changed, 53 insertions(+), 2 deletions(-) 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 3936b5e7..f7474a44 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 f822a512..98317afe 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 "@tedi-design-system/angular/tedi"; @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 6797e6ac..e2ebb3e1 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/services/translation/translations.ts b/tedi/services/translation/translations.ts index fd9800fa..fcbdd36c 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"], From a860c06e60d3d2df6cf2e6441a3e63d1ca7f5044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A4rt=20Sessman?= Date: Mon, 19 Jan 2026 09:52:51 +0200 Subject: [PATCH 08/48] fix(tooltip): wcag improvements #45 (#267) added esc handling, SR now reads tooltip content without needing focus --- .../tooltip-content.component.ts | 2 +- .../tooltip-trigger.component.spec.ts | 31 +++++++++++++++++- .../tooltip-trigger.component.ts | 30 ++++++++++++++--- .../overlay/tooltip/tooltip.component.html | 5 ++- .../overlay/tooltip/tooltip.component.ts | 32 ++++++++++++------- 5 files changed, 81 insertions(+), 19 deletions(-) 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 fd3e1c49..2c666ca8 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 8199f3ef..608c9db6 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 195ce8af..cce6165b 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 914c89b3..2723efa7 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.ts b/tedi/components/overlay/tooltip/tooltip.component.ts index 32f76197..33a13e89 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); + } } } } From 91b69136e3267524b718e8c7e8f709a71765ed54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A4rt=20Sessman?= Date: Mon, 19 Jan 2026 09:53:36 +0200 Subject: [PATCH 09/48] fix(carousel): carousel wcag fixes #241 (#263) --- .../carousel-content.component.html | 5 +-- .../carousel-content.component.ts | 9 ++++-- .../carousel-indicators.component.scss | 31 +++++++++++++------ .../carousel/carousel.component.spec.ts | 16 ++++++++-- 4 files changed, 45 insertions(+), 16 deletions(-) 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 a784470c..76ed5c4b 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) {