Skip to content
Merged
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
20 changes: 17 additions & 3 deletions skills/tedi-angular/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ TEDI form controls implement Angular's `ControlValueAccessor` interface, integra
|-----------|----------|------------|
| TextFieldComponent | `input[tedi-text-field]` | `string` |
| NumberFieldComponent | `tedi-number-field` | `number` |
| CheckboxComponent | `input[tedi-checkbox]` | `boolean` |
| SearchComponent | `tedi-search` | `string` |
| SliderComponent | `tedi-slider` | `number` |
| CheckboxGroupComponent | `tedi-checkbox-group` | `string[]` |
| RadioGroupComponent | `tedi-radio-group` | `string \| null` |
| ToggleComponent | `tedi-toggle` | `boolean` |
| DateFieldComponent | `tedi-date-field` | `Date \| Date[] \| DateRange \| null` |
| 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) |
| DropdownComponent | `tedi-dropdown` | `string` |
| SelectComponent | `tedi-select` | `T \| T[]` |

`CheckboxComponent` (`input[type=checkbox][tedi-checkbox]`) is **not** a TEDI value accessor — it styles a native checkbox, so `[formControl]` on it is handled by Angular's built-in `CheckboxControlValueAccessor` and yields a `boolean`. Inside a managed `<tedi-checkbox-group>`, its `value` input is a `string` identity instead. `DropdownComponent` (`tedi-dropdown`) lives in `overlay/` and is not a form control — it exposes `[(value)]` but implements no `ControlValueAccessor`.

## Basic Usage with Reactive Forms

```typescript
Expand Down Expand Up @@ -56,7 +58,7 @@ import {
<input tedi-text-field formControlName="email" type="email" />
</tedi-form-field>

<input tedi-checkbox formControlName="agree" />
<input tedi-checkbox type="checkbox" formControlName="agree" />
</form>
`,
})
Expand Down Expand Up @@ -143,6 +145,18 @@ this.control.disable();

The component combines native disabled state with form-disabled state internally.

## Search

`SearchComponent` (`tedi-search`) is a `string` value accessor that renders its own `tedi-form-field` — do not wrap it in one. It requires `inputId`, and takes an optional trailing `button` (`{ text?, icon?, variant?, ariaLabel? }`); `searchEvent` fires on Enter or button click.

```html
<tedi-search inputId="search" label="Otsing" [formControl]="query" (searchEvent)="run($event)" />
```

The host is a `role="search"` landmark whose accessible name falls back to `ariaLabel` → `label` → `placeholder` → the translated "search". **When a page renders more than one `tedi-search`, give each a distinct `ariaLabel`** — identically named landmarks of the same type fail axe's `landmark-unique` rule. The visible `<label>` is unaffected; `ariaLabel` names only the landmark.

If you build a suggestion panel around the field, `aria-expanded` is the attribute to watch: it is not permitted on a plain textbox (`aria-allowed-attr`), so the input needs `role="combobox"`. `aria-controls` and `aria-haspopup` are global attributes and are valid on a plain text input either way. Point `aria-controls` at a `role="listbox"` popup for a list of options, or a `role="dialog"` popup (with `aria-haspopup="dialog"`) when the panel mixes results with other controls. Bind it conditionally — `[attr.aria-controls]="open() ? 'panel-id' : null"` — since a popup rendered with `@if` or a CDK overlay is absent while closed, and a reference to a missing id fails `aria-valid-attr-value`.

## Date Selection

Use `DateFieldComponent` (`tedi-date-field`) — it is the successor to the now-deprecated `DatePickerComponent`. It wraps a typed text input with a popover (or modal) calendar, and supports `single`, `multiple` and `range` modes.
Expand Down
88 changes: 76 additions & 12 deletions tedi/components/form/search/search.stories.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { TitleCasePipe } from "@angular/common";
import {
afterNextRender,
Component,
computed,
ElementRef,
inject,
Injector,
OnDestroy,
signal,
viewChild,
Expand Down Expand Up @@ -171,7 +173,7 @@ const LIVE_RESULTS_STYLES = `
type="text"
role="combobox"
aria-autocomplete="list"
aria-controls="search-combobox-listbox"
[attr.aria-controls]="isOpen() ? 'search-combobox-listbox' : null"
[attr.aria-expanded]="isOpen()"
[attr.aria-activedescendant]="activeOptionId()"
[value]="value()"
Expand Down Expand Up @@ -317,8 +319,17 @@ class SearchSuggestionsDemoComponent {
* A single matched result with fallback actions and a hint — e.g. a
* national-registry person lookup. The result panel renders inline below the
* field (not in an overlay), so focus flows naturally: Tab from the field moves
* through the action buttons. Opens on focus, closes on Esc or when focus leaves
* the field and its panel.
* through the action buttons. It opens on focus, and ArrowDown moves focus into
* the dialog. Esc closes it from either the field or the dialog, and selecting a
* result closes it too — both hand focus back to the input rather than dropping
* it when the panel unmounts. It also closes when focus leaves the field and
* its panel.
*
* The panel mixes a result with actions, so it is not a listbox: the input is a
* `role="combobox"` with `aria-haspopup="dialog"` pointing at a `role="dialog"`
* panel. The combobox role is what makes `aria-expanded` legal — on a plain
* textbox it fails the `aria-allowed-attr` rule. `aria-controls` and
* `aria-haspopup` are global attributes, so those were valid either way.
*/
@Component({
standalone: true,
Expand All @@ -342,29 +353,34 @@ class SearchSuggestionsDemoComponent {
<tedi-form-field>
<label tedi-label for="search-result">Otsi</label>
<input
#comboboxInput
tedi-text-field
id="search-result"
type="text"
aria-controls="search-result-panel"
role="combobox"
aria-haspopup="dialog"
[attr.aria-controls]="open() ? 'search-result-panel' : null"
[attr.aria-expanded]="open()"
[value]="value()"
(valueChange)="value.set($event)"
(focus)="open.set(true)"
(focus)="onInputFocus()"
(keydown.arrowdown)="onArrowDown($event)"
(keydown.escape)="open.set(false)"
/>
</tedi-form-field>

@if (open()) {
<div
id="search-result-panel"
role="group"
role="dialog"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
aria-label="Otsingutulemus"
class="tedi-search-demo__results"
(keydown.escape)="closeAndRestoreFocus()"
>
<button
type="button"
class="tedi-search-demo__result"
(click)="open.set(false)"
(click)="closeAndRestoreFocus()"
>
<tedi-dropdown-item-value>
<tedi-dropdown-item-value-label
Expand Down Expand Up @@ -432,9 +448,46 @@ class SearchSuggestionsDemoComponent {
})
class SearchResultActionsDemoComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly input =
viewChild.required<ElementRef<HTMLInputElement>>("comboboxInput");

/** Set while focus is moved back to the input, so it does not reopen the dialog. */
private restoringFocus = false;

readonly value = signal("4954080254");
readonly open = signal(false);

onInputFocus(): void {
if (this.restoringFocus) {
this.restoringFocus = false;
return;
}
this.open.set(true);
}

/** ArrowDown moves focus into the dialog, as the combobox pattern expects. */
onArrowDown(event: Event): void {
event.preventDefault();
this.open.set(true);

// The panel renders on the next change-detection tick.
afterNextRender(
() =>
this.host.nativeElement
.querySelector<HTMLElement>(".tedi-search-demo__result")
?.focus(),
{ injector: this.injector },
);
}

/** Closing from inside the dialog must hand focus back to the input. */
closeAndRestoreFocus(): void {
this.open.set(false);
this.restoringFocus = true;
this.input().nativeElement.focus();
}

onFocusOut(event: FocusEvent): void {
const next = event.relatedTarget as Node | null;
if (!next || !this.host.nativeElement.contains(next)) {
Expand Down Expand Up @@ -791,17 +844,24 @@ export const Sizes: Story = {
<div class="tedi-search-sizes__row" *ngFor="let size of SIZES">
<p tedi-text modifiers="bold" class="tedi-search-sizes__label">{{ size | titlecase }}</p>
<div class="tedi-search-sizes__fields">
<tedi-search [inputId]="'size-' + size + '-plain'" [size]="size" label="Otsing" />
<tedi-search
[inputId]="'size-' + size + '-plain'"
[size]="size"
label="Otsing"
[ariaLabel]="'Otsing – ' + size + ', ilma nuputa'"
/>
<tedi-search
[inputId]="'size-' + size + '-icon'"
[size]="size"
label="Otsing"
[ariaLabel]="'Otsing – ' + size + ', nupp ikooniga'"
[button]="{ ariaLabel: 'Otsi' }"
/>
<tedi-search
[inputId]="'size-' + size + '-button'"
[size]="size"
label="Otsing"
[ariaLabel]="'Otsing – ' + size + ', nupp ikooni ja tekstiga'"
[button]="{ text: 'Otsi' }"
/>
</div>
Expand All @@ -826,25 +886,29 @@ export const States: Story = {
<tedi-row *ngFor="let state of PSEUDO_STATE" cols="1" [sm]="{ cols: 6 }" alignItems="center">
<tedi-col width="1"><p tedi-text modifiers="bold">{{ state }}</p></tedi-col>
<tedi-col width="1" [sm]="{ width: 5 }">
<tedi-search [inputId]="'search-states-' + state" label="Otsing" />
<tedi-search
[inputId]="'search-states-' + state"
label="Otsing"
[ariaLabel]="'Otsing – ' + state"
/>
</tedi-col>
</tedi-row>
<tedi-row cols="1" [sm]="{ cols: 6 }" alignItems="center">
<tedi-col width="1"><p tedi-text modifiers="bold">Disabled</p></tedi-col>
<tedi-col width="1" [sm]="{ width: 5 }">
<tedi-search inputId="search-states-disabled" label="Otsing" [disabled]="true" />
<tedi-search inputId="search-states-disabled" label="Otsing" ariaLabel="Otsing – Disabled" [disabled]="true" />
</tedi-col>
</tedi-row>
<tedi-row cols="1" [sm]="{ cols: 6 }" alignItems="center">
<tedi-col width="1"><p tedi-text modifiers="bold">Success</p></tedi-col>
<tedi-col width="1" [sm]="{ width: 5 }">
<tedi-search inputId="search-states-success" label="Otsing" [feedbackText]="{ text: 'Tagasiside tekst', type: 'valid' }" />
<tedi-search inputId="search-states-success" label="Otsing" ariaLabel="Otsing – Success" [feedbackText]="{ text: 'Tagasiside tekst', type: 'valid' }" />
</tedi-col>
</tedi-row>
<tedi-row cols="1" [sm]="{ cols: 6 }" alignItems="center">
<tedi-col width="1"><p tedi-text modifiers="bold">Error</p></tedi-col>
<tedi-col width="1" [sm]="{ width: 5 }">
<tedi-search inputId="search-states-error" label="Otsing" [feedbackText]="{ text: 'Tagasiside tekst', type: 'error' }" />
<tedi-search inputId="search-states-error" label="Otsing" ariaLabel="Otsing – Error" [feedbackText]="{ text: 'Tagasiside tekst', type: 'error' }" />
</tedi-col>
</tedi-row>
</tedi-row>
Expand Down
Loading