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
45 changes: 45 additions & 0 deletions skills/tedi-angular/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,51 @@ Both entry points declare `[tedi-floating-button]`. The Community component is
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.

### File upload

- **`tedi-file-upload`'s `inputId` names the hidden file input, but the add button is the control.**
Bind `<label tedi-label [for]>` to `inputId` as for any other control: clicking the label opens the
picker, and the field finds the label by its `for`, gives it an id if it has none, and names the add
button by the label *and* its own text. Nothing else can name the button, so a field without a label
announces only "Add attachment".
- **`tedi-file-upload` has no separate "default" input, and owns its hint.** `[files]` seeds the
list, `[(files)]` keeps the parent in sync, `[formControl]` hands it to the form — bind a stable
reference, an inline literal is written back on every check. The field owns one hint: the allowed
types and size (from `accept` / `maxSize`, off with `showRestrictions`) followed by your `hint`
text. Put guidance there rather than projecting a `tedi-feedback-text` hint, which would make a
second one. It owns one validation message the same way, shown above the hint: pass an error of
your own, a server error say, through `error`, where it joins the field's rejection message, or a
confirmation through `success`, shown only while there is no error. A projected error or success
text would show below the hint as a second message.
- **`tedi-file-upload` validates itself.** It registers on `NG_VALIDATORS`, so a bound control fails
with a `rejectedFiles` error while any `isValid: false` file is listed. By default a file that
fails `accept`, `maxSize` or `validator` is discarded and only the summary under the field remains;
`keepRejectedFiles` keeps it in the list, marked invalid, instead. It shows as a danger tag only
while two or more files are listed; a single file shows just its name. Filter on `isValid` before
uploading either way.
- **A `tedi-file-upload` `validator` adds a rule but no hint.** It runs on every selected file, and
every rule a file breaks is reported in the one rejection summary, built-in reasons first. The
automatic hint cannot describe it, so describe the rule in `hint`.
- **`maxSize` is in bytes**, the unit `File.size` is in — not megabytes as in React. The hint renders
it in whichever unit reads best.
- **It selects files, it does not upload them.** `fileRemove` emits the single entry the user removed,
one event per file when clearing, so an in-flight upload can be aborted without diffing
`filesChange`. Set `isLoading` on a file yourself while it uploads: the tag shows a spinner and
loses its remove button.
- **Below `md` it stacks**: files above a full-width button row, and the clear button becomes a text
button. There is no input for this.
- **`readOnly` renders the files as a list named by the label**, even a single file, which is a list of
one. Keep the `<label tedi-label [for]>`: the hidden file input stays rendered, disabled, so the label
still has its target and clicking it opens nothing.
- **The control turns touched when the file dialog closes**, by picking or cancelling, or when focus
leaves the add button within the page. Opening the dialog moves focus out of the page, and that blur
is ignored, so a `required` error does not appear behind the open dialog.
- **It works without `tedi-form-field`**: it paints its own surface, clear button and restrictions
hint, and a sibling label still names the add button. `error`, `success` and `hint` work the same
either way. For any other
describing element, put its id in a static `aria-describedby` on the component, but not one ending
in `-hint`, `-error` or `-success` after `inputId`: the field uses those ids for its own messages.

### Clear buttons

- **`showClearOnInteraction` belongs on the component that renders the clear button.** For a text
Expand Down
3 changes: 2 additions & 1 deletion skills/tedi-angular/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ component's `ɵɵComponentDeclaration` carries its real selector; see
| RadioGroupComponent | `tedi-radio-group` | `string \| null` |
| ToggleComponent | `tedi-toggle` | `boolean` |
| DateFieldComponent | `tedi-date-field` | `Date \| Date[] \| DateRange \| null` |
| FileUploadComponent | `tedi-file-upload` | `FileUploadFile[]` — also registers on `NG_VALIDATORS` |
| DatePickerComponent | `tedi-date-picker` | `Date \| null` — **deprecated**, use `DateFieldComponent` |
| TimeFieldComponent | `tedi-time-field` | `string \| null` (HH:mm) |
| TimePickerComponent | `tedi-time-picker` | `string \| null` (HH:mm) |
Expand Down Expand Up @@ -58,7 +59,7 @@ form = new FormGroup({

## Form Field Structure

Wrap a control with `tedi-form-field` to compose the label, control, and feedback text into one accessible field. `tedi-form-field` wires the feedback text to the control's `aria-describedby` for you. It does **not** associate the label: give the label a `for` and the control a matching `id` yourself.
Wrap a control with `tedi-form-field` to compose the label, control, and feedback text into one accessible field. `tedi-form-field` wires the feedback text to the control's `aria-describedby` for you. An `aria-describedby` of your own on the control is merged in, but read only once, when the control is created: give it a static value, since a `[attr.aria-describedby]` binding is not picked up. It does **not** associate the label: give the label a `for` and the control a matching `id` yourself.

`TextEditorComponent` is the exception to the `for`/`id` rule: its editing area is a `contenteditable` div, which `for` cannot target. Give the label an `id` and point the control's `ariaLabelledby` at it instead.

Expand Down
50 changes: 30 additions & 20 deletions src/tedi/components/form/_field-surface-mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,34 @@

/// Each state is matched twice: a native control carries it as `:disabled` /
/// `aria-invalid`, a wrapper as `.tedi-field-surface--disabled` / `--invalid`.
$disabled-selector: ":disabled, .tedi-field-surface--disabled";
$invalid-selector: '[aria-invalid="true"], .tedi-field-surface--invalid';
$settled-selector: '[aria-invalid="true"], .tedi-field-surface--invalid, .tedi-field-surface--valid';

/// The states a field is *in* rather than ones the pointer or focus put it in.
/// On its own for a surface that is not an input and so has no hover, active
/// or focus of its own; `surface-states` adds those on top.
@mixin settled-states {
&:not(#{$disabled-selector}) {
&:is(#{$invalid-selector}) {
--_field-border-color: var(--form-general-feedback-error-border);
}

&.tedi-field-surface--valid {
--_field-border-color: var(--form-general-feedback-success-border);
}
}

&:is(#{$disabled-selector}) {
--_field-border-color: var(--form-input-border-disabled);
--_field-background: var(--form-input-background-disabled);
--_field-ring-color: transparent;

cursor: not-allowed;
}
}

@mixin surface-states($focus: ":focus-visible", $from-descendant: false) {
$disabled: ":disabled, .tedi-field-surface--disabled";
$invalid: '[aria-invalid="true"], .tedi-field-surface--invalid';
$settled: '[aria-invalid="true"], .tedi-field-surface--invalid, .tedi-field-surface--valid';
$hovered: "&:hover";
$activated: "&:active";
$focused: "&#{$focus}";
Expand All @@ -62,22 +86,16 @@
$focused: "#{$focused}, &:has(input:focus-visible, textarea:focus-visible)";
}

&:not(#{$disabled}) {
&:is(#{$invalid}) {
--_field-border-color: var(--form-general-feedback-error-border);
}

&.tedi-field-surface--valid {
--_field-border-color: var(--form-general-feedback-success-border);
}
@include settled-states;

&:not(#{$disabled-selector}) {
#{$activated},
#{$focused} {
--_field-ring-color: var(--_field-border-color);
}
}

&:not(#{$disabled}, #{$settled}) {
&:not(#{$disabled-selector}, #{$settled-selector}) {
#{$hovered} {
--_field-border-color: var(--form-input-border-hover);
}
Expand All @@ -90,12 +108,4 @@
--_field-border-color: var(--form-input-border-focus);
}
}

&:is(#{$disabled}) {
--_field-border-color: var(--form-input-border-disabled);
--_field-background: var(--form-input-background-disabled);
--_field-ring-color: transparent;

cursor: not-allowed;
}
}
150 changes: 150 additions & 0 deletions src/tedi/components/form/file-upload/file-upload.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<ng-template #fileList>
@if (files().length > 1) {
<ul
class="tedi-file-upload__tags"
role="list"
[attr.aria-labelledby]="readOnly() ? labelId() : null"
>
@for (file of files(); track file) {
<li class="tedi-file-upload__tag">
<tedi-tag
[type]="file.isValid === false ? 'danger' : 'primary'"
[loading]="!!file.isLoading"
[closable]="isEditable() && !file.isLoading"
(closed)="removeFile(file)"
>
{{ file.name }}
@if (file.isValid === false) {
<span class="sr-only"> ({{ failedLabel() }})</span>
}
</tedi-tag>
</li>
}
</ul>
} @else if (files()[0]; as file) {
@if (readOnly()) {
<ul
class="tedi-file-upload__tags"
role="list"
[attr.aria-labelledby]="labelId()"
>
<li class="tedi-file-upload__tag">
<ng-container
[ngTemplateOutlet]="singleFile"
[ngTemplateOutletContext]="{ $implicit: file }"
/>
</li>
</ul>
} @else {
<ng-container
[ngTemplateOutlet]="singleFile"
[ngTemplateOutletContext]="{ $implicit: file }"
/>
}
}
</ng-template>

<ng-template #singleFile let-file>
<span class="tedi-file-upload__name">
{{ file.name }}
@if (file.isValid === false) {
<span class="sr-only"> ({{ failedLabel() }})</span>
}
</span>
</ng-template>

@if (readOnly()) {
<ng-container [ngTemplateOutlet]="fileList" />
} @else {
<div
class="tedi-file-upload__field"
[class.tedi-field-surface]="paintsSurface()"
[class.tedi-field-surface--invalid]="paintsSurface() && invalid()"
[class.tedi-field-surface--valid]="paintsSurface() && valid()"
[class.tedi-field-surface--disabled]="paintsSurface() && disabled()"
>
<div class="tedi-file-upload__value">
<ng-container [ngTemplateOutlet]="fileList" />
</div>

<div class="tedi-file-upload__actions">
@if (showClear()) {
<div class="tedi-file-upload__clear-group">
@if (isStacked()) {
<button
tedi-button
type="button"
variant="neutral"
class="tedi-file-upload__clear"
(click)="reset()"
>
<tedi-icon name="close" [size]="18" color="inherit" />
{{ clearLabel() }}
</button>
} @else {
<button
tedi-closing-button
type="button"
size="small"
class="tedi-file-upload__clear"
[iconSize]="18"
[ariaLabel]="clearLabel()"
(click)="reset()"
></button>
}
<tedi-separator axis="vertical" size="1.5rem" />
</div>
}

<button
#addButton
tedi-button
type="button"
variant="neutral"
class="tedi-file-upload__add"
[size]="isStacked() ? 'default' : 'small'"
[disabled]="disabled()"
[attr.aria-labelledby]="addLabelledBy()"
[attr.aria-describedby]="describedBy.attribute()"
(click)="openPicker()"
(blur)="handleBlur()"
>
<tedi-icon name="file_upload" [size]="18" color="inherit" />
<span [id]="addLabelId()">{{ addLabel() }}</span>
</button>
</div>
</div>

@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" />
}
}

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