Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
},
"dependencies": {
"@tanstack/angular-table": "^8.21.4",
"@tedi-design-system/core": "^6.9.0"
"@tedi-design-system/core": "^6.11.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "20.3.27",
Expand Down
58 changes: 56 additions & 2 deletions skills/tedi-angular/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,11 @@ the part of this document worth maintaining by hand.

### The `/tedi` and `/community` entry points collide

This is the highest-value trap in the Angular library and it has no React equivalent. **25 selectors
This is the highest-value trap in the Angular library and it has no React equivalent. **27 selectors
are declared in both entry points, under identical class names**, including `tedi-card`,
`tedi-modal`, `tedi-accordion`, `tedi-tabs`, `tedi-dropdown`, `tedi-form-field`, `tedi-pagination`,
`tedi-search`, `tedi-tag`, `[tedi-floating-button]`, and the checkbox/radio group family.
`tedi-search`, `tedi-tag`, `[tedi-floating-button]`, `tedi-file-dropzone`, and the checkbox/radio
group family.

`CardComponent` from `/community` and `CardComponent` from `/tedi` are different components with
different input APIs behind the same `<tedi-card>` tag. Consequences:
Expand Down Expand Up @@ -200,6 +201,59 @@ Both entry points declare `[tedi-floating-button]`. The Community component is
`icon` input, not from projection, so you cannot swap in your own `tedi-icon` or add trailing
content. It is decorative and `aria-hidden`, which means the projected text is the whole accessible
name — if the icon carries meaning the text doesn't, compose a heading and an icon yourself.
- **`tedi-file-dropzone`'s `files` has no separate "default" input** — unlike the React version, which
needs `defaultFiles` alongside `files` to distinguish uncontrolled from controlled. The binding
picks the mode: `[files]` seeds the list and leaves the dropzone owning it, `[(files)]` keeps the
parent in sync, `[formControl]` hands it to the form. Bind a stable reference, though: an inline
literal (`[files]="[{ name: 'a.pdf' }]"`) is a new array on every check, so it is written back each
time and discards whatever the user added.
- **`tedi-file-dropzone` validates itself.** It registers on `NG_VALIDATORS`, so a `formControl` bound
to it fails with a `rejectedFiles` error (carrying the offending files) while any rejected file is
still listed — you don't add a validator for that. `Validators.required` alone would pass on a list
of visibly broken files whenever `keepRejectedFiles` is on and keeps them in the value. Same shape as Angular
Material's datepicker: the constraint comes from the component's own inputs (`accept`, `maxSize`), so
the component owns the rule. No `registerOnValidatorChange` is needed — `validate()` is a pure
function of the control value, which Angular already revalidates on every change.
- **`tedi-file-dropzone` emits `fileRemove` alongside `filesChange`.** `filesChange` gives the new list;
`fileRemove` gives the single entry the user deleted, so an in-flight upload can be aborted or a blob
URL revoked without diffing the two arrays.
- **`tedi-file-dropzone` selects files, it does not upload them.** It validates against `accept` and
`maxSize` and hands you the list; the upload, and setting each file's `isLoading` / `isValid` as it
progresses, is yours. A `validator` input adds a rule of your own on top, run on every selected file;
the reason it returns lands on that file's `error` after the built-in ones and joins the summary,
with the files it rejected named after it. Describe the rule in `hint`, which follows the allowed
types and size in the one hint. `keepRejectedFiles` decides what happens to a file that
fails `accept`, `maxSize` or `validator`: off (the default) discards it and summarises every rejection in one message under the
dropzone, which paints the border red. On keeps it in the list with its reason in that file's
`error`, leaving the border neutral — so the form value holds it with `isValid: false` and you must
filter on `isValid` before uploading. `error` is writable, so a failure the upload
came back with lands on the file it belongs to.
- **`tedi-file-dropzone` shows at most one hint and one validation message, error first.** Pass your
own text through `hint`, `error` and `success`, not projected `tedi-feedback-text`. `error` joins
the dropzone's own rejection summary in one error, and `success` takes the error's place while
there is none.
- **Selecting a file that is already listed is skipped**, matched on name, size and mtime — so two
different files sharing a name still both land. Project an `*tediFileDropzoneFile` template to
replace the built-in `tedi-attachment` row when a file needs a progress bar, an icon or its own
feedback.
- **`tedi-file-dropzone`'s `label` is the text inside the drop zone**, not a field label above it. It is
the accessible name of the zone, so the control is never nameless. Don't add a `<label tedi-label>`
for `inputId` and don't wrap it in `tedi-form-field`: the file input is `display: none` and cannot be
labelled, and `tedi-form-field` would also place its projected feedback after the file list rather
than under the dropzone. Fold the field name into `label` instead.
- **The drop zone is a `div[role="button"]`, not a `<label>` or a native `<button>`** — the same shape
React and react-dropzone use, and the reason is screen readers, not styling. The file input is
`display: none` and out of the tab order: while it was the focus target, closing the file dialog
handed focus back to it and VoiceOver discarded the status message every time. A native `<button>`
behaves differently again and was also unreliable there. Because a `role="button"` div has no native
activation, Enter and Space are wired by hand in `handleKeydown`, and `disabled` is `aria-disabled`
plus `tabindex="-1"` with both the click and key paths guarded. Carbon, USWDS, GOV.UK and Polaris all
avoid making the file input the focus target for the same reason.
- **Selections are announced assertively and on a delay; removals are polite and immediate.** Closing
the file dialog leaves the screen reader busy for a moment, and a message inserted during it is
dropped rather than queued. Removal involves no dialog, so it needs neither. Announcements go through
`ToastAnnouncerService`, whose regions live on `<body>`: a live region inside the component is
silently dropped when the rows around it re-render, which is what happens on every selection.

### Clear buttons

Expand Down
2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"dependencies": {
"@tanstack/angular-table": "^8.21.4",
"@tedi-design-system/core": "^6.8.1"
"@tedi-design-system/core": "^6.11.1"
},
"publishConfig": {
"access": "public"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Directive, inject, TemplateRef } from "@angular/core";
import { FileDropzoneFile } from "./file-dropzone.types";

export interface FileDropzoneFileContext {
/** The file this row renders, bound by `let-file` without naming a key. */
$implicit: FileDropzoneFile;
/** Removes this file from the dropzone. */
remove: () => void;
/** Translated accessible name for the remove control, including the file name. */
removeLabel: string;
}

/**
* Replaces the built-in `tedi-attachment` row for every selected file. Use it
* to add a progress bar, extra actions or per-file feedback.
*
* @example
* ```html
* <tedi-file-dropzone multiple [(files)]="files">
* <ng-template tediFileDropzoneFile let-file let-remove="remove" let-removeLabel="removeLabel">
* <tedi-attachment [name]="file.name" icon="description">
* @if (file.isLoading) {
* <tedi-progress-bar [value]="progressFor(file)" />
* }
* <tedi-attachment-actions>
* <button tedi-button variant="neutral" [attr.aria-label]="removeLabel" (click)="remove()">
* <tedi-icon name="delete" [size]="18" color="inherit" />
* </button>
* </tedi-attachment-actions>
* </tedi-attachment>
* </ng-template>
* </tedi-file-dropzone>
* ```
*/
@Directive({
selector: "[tediFileDropzoneFile]",
standalone: true,
})
export class FileDropzoneFileDirective {
readonly template = inject<TemplateRef<FileDropzoneFileContext>>(TemplateRef);

static ngTemplateContextGuard(
_directive: FileDropzoneFileDirective,
_context: unknown,
): _context is FileDropzoneFileContext {
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<div
#zone
role="button"
class="tedi-file-dropzone__zone"
[class.tedi-file-dropzone__zone--invalid]="isInvalid()"
[class.tedi-file-dropzone__zone--valid]="isValid()"
[class.tedi-file-dropzone__zone--drop-over]="isDragActive()"
[attr.tabindex]="isDisabled() ? -1 : 0"
[attr.aria-disabled]="isDisabled() || null"
[attr.aria-invalid]="isInvalid() || null"
[attr.aria-describedby]="describedBy.attribute()"
(click)="openPicker()"
(keydown)="handleKeydown($event)"
(blur)="handleBlur()"
(dragenter)="handleDragEnter($event)"
(dragover)="handleDragOver($event)"
(dragleave)="handleDragLeave()"
(drop)="handleDrop($event)"
>
<tedi-icon
class="tedi-file-dropzone__icon"
name="attach_file"
[size]="24"
[color]="isDisabled() ? 'inherit' : 'secondary'"
/>
<span class="tedi-file-dropzone__label">{{ resolvedLabel() }}</span>
</div>

<input
#fileInput
class="tedi-file-dropzone__input"
type="file"
tabindex="-1"
aria-hidden="true"
[id]="resolvedId()"
[attr.name]="name()"
[attr.accept]="accept()"
[multiple]="multiple()"
[disabled]="isDisabled()"
(change)="handleSelection($event)"
(cancel)="handleDialogCancel()"
/>

@if (errorText(); as error) {
<tedi-feedback-text [id]="feedbackIds().error" [text]="error" type="error" />
} @else if (successText(); as success) {
<tedi-feedback-text
[id]="feedbackIds().success"
[text]="success"
type="valid"
/>
}

@if (hintText(); as hint) {
<tedi-feedback-text [id]="feedbackIds().hint" [text]="hint" />
}

@if (files().length) {
<ul
class="tedi-file-dropzone__files"
role="list"
[attr.aria-label]="selectedFilesLabel()"
>
@for (row of fileRows(); track row.file) {
<li class="tedi-file-dropzone__file">
@if (fileTemplate(); as template) {
<ng-container
[ngTemplateOutlet]="template.template"
[ngTemplateOutletContext]="row.context"
/>
} @else {
<tedi-attachment
[name]="row.name"
[fileSize]="row.size"
[error]="row.file.error"
[invalid]="row.file.isValid === false"
>
<tedi-attachment-actions>
<button
tedi-button
type="button"
variant="neutral"
[disabled]="isDisabled()"
[attr.aria-label]="row.removeLabel"
(click)="removeFile(row.file)"
>
<tedi-icon name="delete" [size]="18" color="inherit" />
</button>
</tedi-attachment-actions>
</tedi-attachment>
}
</li>
}
</ul>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
.tedi-file-dropzone {
display: flex;
flex-direction: column;
gap: var(--layout-grid-gutters-04);
}

.tedi-file-dropzone__zone {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: var(--file-dropzone-padding-y) var(--file-dropzone-padding-x);
font: inherit;
color: var(--file-dropzone-text-default);
text-align: center;
cursor: pointer;
background-color: var(--file-dropzone-background-default);
border: var(--tedi-borders-02) dotted var(--file-dropzone-border-default);
border-radius: var(--form-field-radius);
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out;

&:hover {
background-color: var(--file-dropzone-background-hover);
border-color: var(--file-dropzone-border-hover);
}

&:active {
border-color: var(--file-dropzone-border-active);
}

&:focus-visible {
outline: var(--tedi-borders-02) solid var(--file-dropzone-border-focus);
outline-offset: var(--tedi-borders-01);
border-color: var(--file-dropzone-border-focus);
}
}

.tedi-file-dropzone__zone--drop-over.tedi-file-dropzone__zone {
color: var(--file-dropzone-text-drop-over);
background-color: var(--file-dropzone-background-drop-over);
border-color: var(--file-dropzone-border-drop-over);
}

.tedi-file-dropzone__zone--invalid.tedi-file-dropzone__zone {
border-color: var(--form-general-feedback-error-border);
}

.tedi-file-dropzone__zone--valid.tedi-file-dropzone__zone {
border-color: var(--form-general-feedback-success-border);
}

.tedi-file-dropzone--disabled .tedi-file-dropzone__zone {
color: var(--general-text-disabled);
cursor: not-allowed;
background-color: var(--file-dropzone-background-disabled);
border-color: var(--file-dropzone-border-disabled);
}

.tedi-file-dropzone__input {
display: none;
}

.tedi-file-dropzone__files {
display: flex;
flex-direction: column;
gap: var(--layout-grid-gutters-08);
padding-left: 0;
margin: var(--layout-grid-gutters-04) 0 0;
list-style: none;
}
Loading
Loading