diff --git a/src/tedi/components/content/table/table-demo.constants.ts b/src/tedi/components/content/table/table-demo.constants.ts index c57b1d948..23bcab298 100644 --- a/src/tedi/components/content/table/table-demo.constants.ts +++ b/src/tedi/components/content/table/table-demo.constants.ts @@ -95,6 +95,7 @@ function editableLocationCellTemplate(tplName = "locationCell"): string { { expect(labelText(fixture)).toBe("10"); }); }); + +@Component({ + standalone: true, + imports: [SelectComponent, ReactiveFormsModule], + template: ` + + + + `, +}) +class ClearableDefaultsHostComponent { + options = [{ label: "Option 1", value: "Option 1" }]; + defaultControl = new FormControl("Option 1"); + bareControl = new FormControl("Option 1"); + offControl = new FormControl("Option 1"); +} + +describe("SelectComponent clearable", () => { + let hostEl: HTMLElement; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [ClearableDefaultsHostComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }); + const fixture = TestBed.createComponent(ClearableDefaultsHostComponent); + hostEl = fixture.nativeElement; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + const clearButton = (id: string) => + hostEl.querySelector(`#${id} .tedi-select__clear`); + + it("shows a clear button by default once a value is selected", () => { + expect(clearButton("default")).toBeTruthy(); + }); + + it("treats a bare clearable attribute as true", () => { + expect(clearButton("bare")).toBeTruthy(); + }); + + it('treats clearable="false" as false', () => { + expect(clearButton("off")).toBeNull(); + }); +}); + +@Component({ + standalone: true, + imports: [SelectComponent, ReactiveFormsModule], + template: ` + + + + `, +}) +class EmptyStringHostComponent { + cities = [ + { label: "Tallinn", value: "tallinn" }, + { label: "Tartu", value: "tartu" }, + ]; + statuses = [ + { label: "Kõik", value: "" }, + { label: "Aktiivne", value: "active" }, + ]; + emptyString = new FormControl(""); + allStatuses = new FormControl(""); + multipleEmpty = new FormControl([""]); +} + +describe("SelectComponent with an empty-string value", () => { + let fixture: ComponentFixture; + let host: EmptyStringHostComponent; + + const render = () => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + }; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [EmptyStringHostComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }); + fixture = TestBed.createComponent(EmptyStringHostComponent); + host = fixture.componentInstance; + render(); + })); + + const el = (id: string) => + fixture.nativeElement.querySelector(`#${id}`) as HTMLElement; + const clearButton = (id: string) => + el(id).querySelector(".tedi-select__clear"); + const shownText = (id: string) => + el(id).querySelector(".tedi-select__trigger")?.textContent?.trim(); + + it("shows a '' form value as empty, with no clear button", () => { + expect(clearButton("no-empty-option")).toBeNull(); + expect(shownText("no-empty-option")).toContain("Vali..."); + }); + + it("leaves the stored value as ''", () => { + expect(host.emptyString.value).toBe(""); + }); + + it("shows '' as empty even when an option has that value", () => { + expect(clearButton("empty-option")).toBeNull(); + expect(shownText("empty-option")).toContain("Vali..."); + }); + + it("resets when the option with value '' is picked", fakeAsync(() => { + host.allStatuses.setValue("active"); + render(); + expect(clearButton("empty-option")).toBeTruthy(); + + ( + el("empty-option").querySelector(".tedi-select__trigger") as HTMLElement + ).click(); + render(); + const all = Array.from( + document.querySelectorAll(".tedi-dropdown-item"), + ).find((item) => item.textContent?.trim() === "Kõik") as HTMLElement; + all.click(); + render(); + + expect(host.allStatuses.value).toBe(""); + expect(clearButton("empty-option")).toBeNull(); + expect(shownText("empty-option")).toContain("Vali..."); + })); + + it("resets through the virtual-scroll path too", fakeAsync(() => { + host.allStatuses.setValue("active"); + render(); + const select = fixture.debugElement.query(By.css("#empty-option")) + .componentInstance as SelectComponent; + + select.onVirtualOptionClick(select.normalizedOptions()[0]); + render(); + + expect(select.selectedValues()).toEqual([]); + expect(host.allStatuses.value).toBe(""); + })); + + it("shows [''] as empty in a multiple select", () => { + expect(clearButton("multiple")).toBeNull(); + expect(shownText("multiple")).toContain("Vali..."); + }); +}); + +@Component({ + standalone: true, + imports: [SelectComponent, ReactiveFormsModule], + template: ` + + `, +}) +class MultiEmptyOptionHostComponent { + options = [ + { label: "None", value: "", group: "A" }, + { label: "One", value: "one", group: "A" }, + { label: "Two", value: "two", group: "A" }, + ]; + control = new FormControl([]); +} + +describe("SelectComponent multiselect with an option whose value is ''", () => { + let fixture: ComponentFixture; + let host: MultiEmptyOptionHostComponent; + let select: SelectComponent; + + const render = () => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + }; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MultiEmptyOptionHostComponent], + providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + }); + fixture = TestBed.createComponent(MultiEmptyOptionHostComponent); + host = fixture.componentInstance; + render(); + select = fixture.debugElement.query(By.directive(SelectComponent)) + .componentInstance as SelectComponent; + })); + + it("never selects the '' option on click", fakeAsync(() => { + select.onVirtualOptionClick(select.normalizedOptions()[0]); + render(); + expect(select.selectedValues()).toEqual([]); + expect(host.control.value).toEqual([]); + })); + + it("never selects it through the listbox either", fakeAsync(() => { + select.handleValueChange({ value: ["", "one"] }); + render(); + expect(select.selectedValues()).toEqual(["one"]); + expect(host.control.value).toEqual(["one"]); + })); + + it("leaves it out of select all, so select all can be toggled off again", fakeAsync(() => { + select.onVirtualSelectAllClick(); + render(); + expect(select.selectedValues()).toEqual(["one", "two"]); + expect(select.allOptionsSelected()).toBe(true); + + select.onVirtualSelectAllClick(); + render(); + expect(select.selectedValues()).toEqual([]); + })); + + it("leaves it out of group selection", fakeAsync(() => { + select.handleValueChange({ + value: [SpecialOptionControls.SELECT_GROUP + "A"], + }); + render(); + expect(select.selectedValues()).toEqual(["one", "two"]); + expect(select.isGroupSelected("A")).toBe(true); + expect(select.isGroupIndeterminate("A")).toBe(false); + })); +}); diff --git a/src/tedi/components/form/select/select.component.ts b/src/tedi/components/form/select/select.component.ts index 3669691d8..81eab9d91 100644 --- a/src/tedi/components/form/select/select.component.ts +++ b/src/tedi/components/form/select/select.component.ts @@ -10,6 +10,7 @@ import { } from "@angular/cdk/scrolling"; import { AfterContentChecked, + booleanAttribute, AfterViewChecked, ChangeDetectionStrategy, Component, @@ -190,9 +191,9 @@ export class SelectComponent /** * Whether to show a clear button when a value is selected. - * @default false + * @default true */ - clearable = input(false); + clearable = input(true, { transform: booleanAttribute }); /** * Show the clear button only while the filled select is hovered or focused. * Requires `clearable`. @@ -242,6 +243,10 @@ export class SelectComponent * Selected value, for use without a form directive. An array in multi-select * mode, the bare value otherwise. Unlike `[ngModel]` it applies on the first * render rather than a frame later. Do not combine with a form directive. + * + * `null`, `undefined` and `''` all mean nothing is selected, so + * `new FormControl('')` and `new FormControl(null)` both start the select empty, + * showing its placeholder. */ value = input(undefined); @@ -676,7 +681,7 @@ export class SelectComponent const options = this.searchTerm().trim() ? this.filteredOptions() : this.normalizedOptions(); - const enabledOptions = options.filter((o) => !o.disabled); + const enabledOptions = this.bulkSelectable(options); if (enabledOptions.length === 0) return false; if (this.usesDefaultCompare()) { @@ -695,7 +700,7 @@ export class SelectComponent const options = this.searchTerm().trim() ? this.filteredOptions() : this.normalizedOptions(); - const enabledOptions = options.filter((o) => !o.disabled); + const enabledOptions = this.bulkSelectable(options); if (enabledOptions.length === 0) return false; let selectedCount: number; @@ -1198,9 +1203,12 @@ export class SelectComponent const compareWith = this.compareWith(); const selected = this.selectedValues(); const isSelected = selected.some((v) => compareWith(v, value)); - const newSelection = isSelected - ? selected.filter((v) => !compareWith(v, value)) - : [...selected, value]; + const newSelection = this.toSelection( + isSelected + ? selected.filter((v) => !compareWith(v, value)) + : [...selected, value], + true, + ); this.selectedValues.set(newSelection); this.onChange(newSelection); @@ -1210,8 +1218,9 @@ export class SelectComponent this.onTouched(); } + /** Selects `value` in single-select mode, notifies the form and closes the dropdown. */ private selectSingleValue(value: unknown): void { - this.selectedValues.set([value]); + this.selectedValues.set(this.toSelection(value, false)); this.onChange(value); this.selectionChange.emit(value as T); if (this.clearSearchOnSelect()) this.searchTerm.set(""); @@ -1313,6 +1322,10 @@ export class SelectComponent } } + /** + * Handles a selection change from the listbox: the select-all and group rows, or + * the new selection, which is emitted and written to the form. + */ handleValueChange(event: { value: readonly unknown[] }): void { const values = event.value; @@ -1353,6 +1366,7 @@ export class SelectComponent } else { newSelection = [...values]; } + newSelection = this.toSelection(newSelection, true); this.selectedValues.set(newSelection); this.onChange(newSelection); this.selectionChange.emit(newSelection as T[]); @@ -1364,7 +1378,7 @@ export class SelectComponent } } else { const selected = values[0] ?? null; - this.selectedValues.set(selected != null ? [selected] : []); + this.selectedValues.set(this.toSelection(selected, false)); this.onChange(selected); this.selectionChange.emit(selected as T | null); if (this.clearSearchOnSelect()) { @@ -1480,7 +1494,7 @@ export class SelectComponent const group = this.optionGroups().find((g) => g.label === groupLabel); if (!group) return false; - const enabledGroupOptions = group.options.filter((o) => !o.disabled); + const enabledGroupOptions = this.bulkSelectable(group.options); if (enabledGroupOptions.length === 0) return false; const compareWith = this.compareWith(); @@ -1495,7 +1509,7 @@ export class SelectComponent const group = this.optionGroups().find((g) => g.label === groupLabel); if (!group) return false; - const enabledGroupOptions = group.options.filter((o) => !o.disabled); + const enabledGroupOptions = this.bulkSelectable(group.options); if (enabledGroupOptions.length === 0) return false; const compareWith = this.compareWith(); @@ -1582,7 +1596,7 @@ export class SelectComponent const options = isSearching ? this.filteredOptions() : this.normalizedOptions(); - const enabledOptions = options.filter((o) => !o.disabled); + const enabledOptions = this.bulkSelectable(options); const deselecting = this.allOptionsSelected(); let newSelection: unknown[]; @@ -1630,7 +1644,7 @@ export class SelectComponent const group = this.optionGroups().find((g) => g.label === groupLabel); if (!group) return; - const enabledGroupOptions = group.options.filter((o) => !o.disabled); + const enabledGroupOptions = this.bulkSelectable(group.options); const groupValues = enabledGroupOptions.map((o) => o.value); const isGroupSelected = this.isGroupSelected(groupLabel); const compareWith = this.compareWith(); @@ -1658,11 +1672,21 @@ export class SelectComponent onChange: (value: unknown) => void = () => {}; onTouched: () => void = () => {}; + /** Options that can take part in select-all and group selection: enabled, not `''`. */ + private bulkSelectable(options: SelectOption[]): SelectOption[] { + return options.filter((o) => !o.disabled && o.value !== ""); + } + + /** + * `null`, `undefined` and `''` all mean nothing is selected, since `''` is a common + * form default for "no value" (`new FormControl('')`). Picking an option whose + * value is `''` therefore leaves nothing selected too. + */ private toSelection(value: unknown, allowMultiple: boolean): unknown[] { if (allowMultiple) { - return Array.isArray(value) ? value : []; + return Array.isArray(value) ? value.filter((v) => v !== "") : []; } - return value != null ? [value] : []; + return value != null && value !== "" ? [value] : []; } writeValue(value: unknown): void { diff --git a/src/tedi/components/form/select/select.stories.ts b/src/tedi/components/form/select/select.stories.ts index e5ab705ee..7a0786e7f 100644 --- a/src/tedi/components/form/select/select.stories.ts +++ b/src/tedi/components/form/select/select.stories.ts @@ -136,7 +136,9 @@ const meta: Meta = { }, clearable: { control: "boolean", - description: "Whether to show a clear button when a value is selected.", + description: + "Whether to show a clear button when a value is selected. Set `false` to opt out.", + table: { defaultValue: { summary: "true" } }, }, showClearOnInteraction: { control: "boolean", @@ -262,7 +264,7 @@ const meta: Meta = { placeholder: "Vali...", state: "default", size: "default", - clearable: false, + clearable: true, showClearOnInteraction: false, allowMultiple: false, showSelectAll: false, diff --git a/src/tedi/components/navigation/pagination/pagination.component.html b/src/tedi/components/navigation/pagination/pagination.component.html index 958973258..6b10c2027 100644 --- a/src/tedi/components/navigation/pagination/pagination.component.html +++ b/src/tedi/components/navigation/pagination/pagination.component.html @@ -147,6 +147,7 @@ [inputId]="pageSizeInputId" [ariaLabelledby]="pageSizeLabelId" size="small" + [clearable]="false" [options]="pageSizeSelectOptions()" bindLabel="label" bindValue="value" diff --git a/src/tedi/components/navigation/pagination/pagination.component.spec.ts b/src/tedi/components/navigation/pagination/pagination.component.spec.ts index 957b2bd25..469f25ff8 100644 --- a/src/tedi/components/navigation/pagination/pagination.component.spec.ts +++ b/src/tedi/components/navigation/pagination/pagination.component.spec.ts @@ -318,6 +318,20 @@ describe("PaginationComponent", () => { expect(label?.textContent?.trim()).toBe("25"); }); + it("never offers to clear the page size, which must always have a value", () => { + const fixture = setup({ + pageCount: 5, + pageSize: 25, + pageSizeOptions: [10, 25, 50], + }); + + expect( + fixture.nativeElement.querySelector( + ".tedi-pagination__page-size-select .tedi-select__clear", + ), + ).toBeNull(); + }); + it("renders a labelled page-size option's text on the first change-detection pass", () => { const fixture = setup({ pageCount: 5,