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
23 changes: 20 additions & 3 deletions skills/tedi-angular/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,23 @@ 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.

### Clear buttons

- **`showClearOnInteraction` belongs on the component that renders the clear button.** For a text
field or textarea wrapped in `<tedi-form-field>`, set it on the form field, not the
control. `<tedi-search>` owns its form field and exposes the input itself; `<tedi-select>` exposes
it for its own clear button. In each case, `clearable` must be true and the field must have a value.
- With the option enabled, the clear button appears while the field is hovered, active, or contains
focus (including keyboard focus). The default is false, which keeps the clear button visible
whenever the clearable field has a value.

```html
<tedi-form-field [clearable]="true" [showClearOnInteraction]="true">
<label tedi-label for="query">Query</label>
<input tedi-text-field id="query" [(value)]="query" />
</tedi-form-field>
```

### Search suggestions

- **`tedi-search` does not filter.** Bind `suggestions` to a list you have already filtered and react
Expand Down Expand Up @@ -233,9 +250,9 @@ Both entry points declare `[tedi-floating-button]`. The Community component is
- **`tedi-table`, not `tedi-table-styles`.** The Community `tedi-table-styles` only paints a
hand-rolled `<table>`; the TEDI-Ready `tedi-table` brings TanStack sorting, filtering and
pagination.
- **`tedi-form-field` is only needed for a label, feedback text, or a `characterLimit` counter.**
Controls paint their own field surface, so wrapping is otherwise redundant. `tedi-search` renders
its own and must **not** be wrapped.
- **`tedi-form-field` adds a label, feedback text, a `characterLimit` counter, an icon, or a clear
button to controls that support it.** A standalone text field paints its own surface, so wrapping
it is otherwise redundant. `tedi-search` renders its own form field and must **not** be wrapped.
- **`tedi-heading-with-icon` over a hand-composed `<hN tedi-text>` plus `tedi-icon`** when the heading
can wrap: it centres the icon on the *first line* rather than the middle of the block. A heading
value in `modifiers` overrides `element`'s typography and the icon follows it, so
Expand Down
10 changes: 10 additions & 0 deletions src/tedi/components/form/form-field/form-field.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@
}
}

&--clear-on-interaction &__buttons {
visibility: hidden;
}

&--clear-on-interaction &__box:hover &__buttons,
&--clear-on-interaction &__box:active &__buttons,
&--clear-on-interaction &__box:focus-within &__buttons {
visibility: visible;
}

&__clear:disabled {
cursor: not-allowed;
}
Expand Down
32 changes: 32 additions & 0 deletions src/tedi/components/form/form-field/form-field.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class MockControlComponent implements FormFieldControl<string> {
[size]="size"
[icon]="icon"
[clearable]="clearable"
[showClearOnInteraction]="showClearOnInteraction"
[inputClass]="inputClass"
[characterLimit]="characterLimit"
>
Expand All @@ -66,6 +67,7 @@ class TestHostComponent {
size: InputSize = "default";
icon?: string | FormFieldIcon;
clearable = false;
showClearOnInteraction = false;
inputClass?: string;
characterLimit?: number;
feedbackType: "valid" | "error" | "hint" = "hint";
Expand Down Expand Up @@ -174,6 +176,36 @@ describe("FormFieldComponent", () => {
expect(button.hasAttribute("disabled")).toBe(false);
});

it("applies the interaction modifier only to a filled clearable field when opted in", () => {
const field = fixture.nativeElement.querySelector("tedi-form-field");
host.showClearOnInteraction = true;
fixture.detectChanges();
expect(
field.classList.contains("tedi-form-field--clear-on-interaction"),
).toBe(false);

host.clearable = true;
host.mockControl.value.set("Test");
fixture.detectChanges();
expect(
field.classList.contains("tedi-form-field--clear-on-interaction"),
).toBe(true);
expect(field.querySelector(".tedi-form-field__clear")).toBeTruthy();

host.mockControl.value.set("");
fixture.detectChanges();
expect(
field.classList.contains("tedi-form-field--clear-on-interaction"),
).toBe(false);

host.mockControl.value.set("Test");
host.showClearOnInteraction = false;
fixture.detectChanges();
expect(
field.classList.contains("tedi-form-field--clear-on-interaction"),
).toBe(false);
});

it("should not render buttons slot when clearable is false", () => {
host.clearable = false;
host.mockControl.value.set("Test");
Expand Down
8 changes: 8 additions & 0 deletions src/tedi/components/form/form-field/form-field.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export class FormFieldComponent implements FieldContext {
* @default false
*/
clearable = input<boolean>(false);
/**
* Show the clear button only while the filled field is hovered or focused.
* Requires `clearable`.
* @default false
*/
showClearOnInteraction = input<boolean>(false);
/**
* Custom CSS classes for the field box.
*
Expand Down Expand Up @@ -232,6 +238,8 @@ export class FormFieldComponent implements FieldContext {
"tedi-form-field--valid": this.validationState() === "valid",
"tedi-form-field--invalid": this.validationState() === "invalid",
"tedi-form-field--disabled": this.isDisabled(),
"tedi-form-field--clear-on-interaction":
this.showClearOnInteraction() && this.showClearButton(),
"tedi-form-field--small": this.size() === "small",
"tedi-form-field--large": this.size() === "large",
};
Expand Down
1 change: 1 addition & 0 deletions src/tedi/components/form/search/search.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
[size]="size()"
[icon]="fieldIcon()"
[clearable]="clearable() && !isDisabled()"
[showClearOnInteraction]="showClearOnInteraction()"
>
@if (label()) {
<label tedi-label [for]="inputId()">
Expand Down
15 changes: 15 additions & 0 deletions src/tedi/components/form/search/search.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ describe("SearchComponent", () => {
expect(component).toBeTruthy();
});

it("passes showClearOnInteraction to its filled form field", () => {
fixture.componentRef.setInput("showClearOnInteraction", true);
typeInto("Test");
const field = el.querySelector("tedi-form-field") as HTMLElement;
expect(
field.classList.contains("tedi-form-field--clear-on-interaction"),
).toBe(true);

fixture.componentRef.setInput("clearable", false);
fixture.detectChanges();
expect(
field.classList.contains("tedi-form-field--clear-on-interaction"),
).toBe(false);
});

it("should render a search landmark with role and searchbox input", () => {
expect(el.getAttribute("role")).toBe("search");
expect(getInput().getAttribute("role")).toBe("searchbox");
Expand Down
6 changes: 6 additions & 0 deletions src/tedi/components/form/search/search.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export class SearchComponent<T = unknown> implements ControlValueAccessor {
* @default true
*/
clearable = input<boolean>(true);
/**
* Show the clear button only while the filled search field is hovered or focused.
* Requires `clearable`.
* @default false
*/
showClearOnInteraction = input<boolean>(false);
/**
* Icon shown inside the input. Ignored when `button` is set.
* @default "search"
Expand Down
24 changes: 24 additions & 0 deletions src/tedi/components/form/search/search.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ export default {
defaultValue: { summary: "true" },
},
},
showClearOnInteraction: {
description:
"Show the clear button only on hover or focus when the search field has a value. Requires clearable.",
control: { type: "boolean" },
table: {
category: "inputs",
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
searchIcon: {
description: "Icon shown inside the input. Ignored when `button` is set.",
control: { type: "object" },
Expand Down Expand Up @@ -481,6 +491,20 @@ export const Clearable: Story = {
}),
};

/** The clear button appears while the filled search field is hovered or focused. */
export const ClearButtonOnInteraction: Story = {
render: () => ({
template: `
<tedi-search
inputId="search-clear-on-interaction"
label="Otsing"
value="Lorem ipsum"
[showClearOnInteraction]="true"
/>
`,
}),
};

/**
* Clearable field paired with a search button — the clear (×) empties the field;
* the button runs the search.
Expand Down
37 changes: 31 additions & 6 deletions src/tedi/components/form/select/select.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@
display: block;
width: 100%;

&--clear-on-interaction &__clear {
visibility: hidden;
}

&--clear-on-interaction &__clear + &__arrow {
border-left-color: transparent;
}

&--clear-on-interaction &__trigger:hover &__clear,
&--clear-on-interaction &__trigger:active &__clear,
&--clear-on-interaction &__trigger:focus-within &__clear {
visibility: visible;
}

&--clear-on-interaction &__trigger:hover &__clear + &__arrow,
&--clear-on-interaction &__trigger:active &__clear + &__arrow,
&--clear-on-interaction &__trigger:focus-within &__clear + &__arrow {
border-left-color: var(--general-border-primary);
}

.tedi-feedback-text {
margin-top: var(--form-field-outer-spacing);
}
Expand Down Expand Up @@ -96,7 +116,7 @@
background: none;
border: none;

&+.tedi-select__arrow {
& + .tedi-select__arrow {
border-left: 1px solid var(--general-border-primary);
}
}
Expand Down Expand Up @@ -229,14 +249,15 @@
display: none;
}

&--custom-content:empty+&--label {
&--custom-content:empty + &--label {
display: block;
}
}

&__group-name {
display: block;
padding: var(--dropdown-group-label-padding-y) var(--dropdown-group-label-padding-x) var(--layout-grid-gutters-04);
padding: var(--dropdown-group-label-padding-y)
var(--dropdown-group-label-padding-x) var(--layout-grid-gutters-04);
font-size: var(--heading-subtitle-small-size);
font-weight: var(--heading-subtitle-small-weight);
line-height: var(--heading-subtitle-small-line-height);
Expand All @@ -251,7 +272,7 @@
text-transform: none;
letter-spacing: inherit;

&~.tedi-dropdown-item:not(.tedi-select__group-name) {
& ~ .tedi-dropdown-item:not(.tedi-select__group-name) {
padding-left: var(--form-checkbox-radio-subitem-padding-left);
}
}
Expand Down Expand Up @@ -301,7 +322,8 @@
// against a wrapper that is allowed to shrink. Without it the wrapper stays
// as wide as its content and the row overflows, cutting the tag — and its
// remove button — off instead.
&.tedi-select__multiselect-container--ellipsis .tedi-select__multiselect-tags {
&.tedi-select__multiselect-container--ellipsis
.tedi-select__multiselect-tags {
flex: 0 1 auto;
}
}
Expand Down Expand Up @@ -329,7 +351,10 @@
display: grid;
grid-template-columns: repeat(auto-fit, var(--tedi-swatch-size));
gap: var(--tedi-swatch-gap);
max-width: calc(var(--tedi-swatch-columns) * (var(--tedi-swatch-size) + var(--tedi-swatch-gap)));
max-width: calc(
var(--tedi-swatch-columns) *
(var(--tedi-swatch-size) + var(--tedi-swatch-gap))
);
padding: var(--dropdown-body-padding-y) var(--dropdown-body-padding-x);

.tedi-dropdown-item {
Expand Down
32 changes: 32 additions & 0 deletions src/tedi/components/form/select/select.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { InputState } from "../form-field/form-field.component";
[allowMultiple]="allowMultiple"
[searchable]="searchable"
[clearable]="clearable"
[showClearOnInteraction]="showClearOnInteraction"
[showSelectAll]="showSelectAll"
[selectableGroups]="selectableGroups"
[groupBy]="groupBy"
Expand Down Expand Up @@ -100,6 +101,7 @@ class TestHostComponent {
allowMultiple = false;
searchable = false;
clearable = true;
showClearOnInteraction = false;
showSelectAll = false;
selectableGroups = false;
groupBy: string | undefined = undefined;
Expand Down Expand Up @@ -407,6 +409,36 @@ describe("SelectComponent", () => {
expect(getClearButton()).toBeTruthy();
}));

it("applies the interaction modifier only to a filled clearable select when opted in", fakeAsync(() => {
const element = hostEl.querySelector("tedi-select") as HTMLElement;
host.showClearOnInteraction = true;
fixture.detectChanges();
expect(
element.classList.contains("tedi-select--clear-on-interaction"),
).toBe(false);

host.control.setValue("Option 1");
fixture.detectChanges();
tick();
expect(
element.classList.contains("tedi-select--clear-on-interaction"),
).toBe(true);
expect(getClearButton()).toBeTruthy();

host.clearable = false;
fixture.detectChanges();
expect(
element.classList.contains("tedi-select--clear-on-interaction"),
).toBe(false);

host.clearable = true;
host.showClearOnInteraction = false;
fixture.detectChanges();
expect(
element.classList.contains("tedi-select--clear-on-interaction"),
).toBe(false);
}));

it("should not show clear button when clearable=false", fakeAsync(() => {
host.clearable = false;
host.control.setValue("Option 1");
Expand Down
8 changes: 8 additions & 0 deletions src/tedi/components/form/select/select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export enum SpecialOptionControls {
host: {
class: "tedi-select",
"[class.tedi-select--multiselect]": "allowMultiple()",
"[class.tedi-select--clear-on-interaction]":
"showClearOnInteraction() && clearable() && !!selectedValues().length",
},
providers: [
{
Expand Down Expand Up @@ -191,6 +193,12 @@ export class SelectComponent<T = unknown>
* @default false
*/
clearable = input<boolean>(false);
/**
* Show the clear button only while the filled select is hovered or focused.
* Requires `clearable`.
* @default false
*/
showClearOnInteraction = input<boolean>(false);

/**
* Element reference used to determine dropdown width.
Expand Down
Loading
Loading