From 82cfff960ae13d244a714c262a937dddcff6f372 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 9 Oct 2026 16:26:00 +0300 Subject: [PATCH 1/2] fix(search): add autocomplete design review fixes #727 --- .../form/date-field/date-field.component.ts | 29 ++++++++++-- .../form/form-field/field-overlay.ts | 5 ++ .../search/search-suggestion.component.scss | 10 ++++ .../form/search/search.component.html | 10 +--- .../form/search/search.component.scss | 10 +--- .../form/search/search.component.ts | 47 ++++++++++++------- 6 files changed, 74 insertions(+), 37 deletions(-) create mode 100644 src/tedi/components/form/form-field/field-overlay.ts diff --git a/src/tedi/components/form/date-field/date-field.component.ts b/src/tedi/components/form/date-field/date-field.component.ts index 5f7a9ac5e..6a26c623d 100644 --- a/src/tedi/components/form/date-field/date-field.component.ts +++ b/src/tedi/components/form/date-field/date-field.component.ts @@ -64,6 +64,7 @@ import { } from "../../../utils/date.util"; import { matchAny, Matcher } from "../../../utils/matchers.util"; import { ModalService } from "../../overlay/modal/modal.service"; +import { FIELD_OVERLAY_GAP } from "../form-field/field-overlay"; import { ModalRef } from "../../overlay/modal/modal-ref"; import { ModalFullscreen } from "../../overlay/modal/modal.types"; import { @@ -373,10 +374,30 @@ export class DateFieldComponent ? { originX: "start", overlayX: "start" } : { originX: "end", overlayX: "end" }; return [ - { ...aligned, originY: "bottom", overlayY: "top", offsetY: 4 }, - { ...aligned, originY: "top", overlayY: "bottom", offsetY: -4 }, - { ...opposite, originY: "bottom", overlayY: "top", offsetY: 4 }, - { ...opposite, originY: "top", overlayY: "bottom", offsetY: -4 }, + { + ...aligned, + originY: "bottom", + overlayY: "top", + offsetY: FIELD_OVERLAY_GAP, + }, + { + ...aligned, + originY: "top", + overlayY: "bottom", + offsetY: -FIELD_OVERLAY_GAP, + }, + { + ...opposite, + originY: "bottom", + overlayY: "top", + offsetY: FIELD_OVERLAY_GAP, + }, + { + ...opposite, + originY: "top", + overlayY: "bottom", + offsetY: -FIELD_OVERLAY_GAP, + }, ]; }); diff --git a/src/tedi/components/form/form-field/field-overlay.ts b/src/tedi/components/form/form-field/field-overlay.ts new file mode 100644 index 000000000..3b760fd89 --- /dev/null +++ b/src/tedi/components/form/form-field/field-overlay.ts @@ -0,0 +1,5 @@ +/** + * Gap in px between a form field and its dropdown panel. Internal: not exported + * from the package. + */ +export const FIELD_OVERLAY_GAP = 4; diff --git a/src/tedi/components/form/search/search-suggestion.component.scss b/src/tedi/components/form/search/search-suggestion.component.scss index 26e2cd485..271beb555 100644 --- a/src/tedi/components/form/search/search-suggestion.component.scss +++ b/src/tedi/components/form/search/search-suggestion.component.scss @@ -7,9 +7,19 @@ &:hover, &--active { + color: var(--dropdown-item-hover-text); background: var(--dropdown-item-hover-background); } + // Label segments and the description are `tedi-text`, whose colour modifier + // class would otherwise outrank the row's hover colour. + &:hover &__label [class*="tedi-text--"], + &--active &__label [class*="tedi-text--"], + &:hover &__description, + &--active &__description { + color: inherit; + } + // Only projected rows get a gap. The default row is a run of highlighted and // unhighlighted label segments, which must stay visually joined. &--custom { diff --git a/src/tedi/components/form/search/search.component.html b/src/tedi/components/form/search/search.component.html index 906f520fb..54d9c4a49 100644 --- a/src/tedi/components/form/search/search.component.html +++ b/src/tedi/components/form/search/search.component.html @@ -1,7 +1,5 @@ -
+
@if (loading()) {
diff --git a/src/tedi/components/form/search/search.component.scss b/src/tedi/components/form/search/search.component.scss index 6fe07cced..64227a312 100644 --- a/src/tedi/components/form/search/search.component.scss +++ b/src/tedi/components/form/search/search.component.scss @@ -41,18 +41,10 @@ overflow: hidden; background: var(--dropdown-item-default-background); border: 1px solid var(--card-border-primary); - border-radius: 0 0 var(--form-select-area-radius) - var(--form-select-area-radius); + border-radius: var(--form-select-area-radius); box-shadow: 0 1px 5px 0 var(--general-effect-colors-elevation-dropdown-area-drop-shadow); - // On the fallback position the panel sits above the field, so the joined edge - // — and with it the square corners — moves to the bottom. - &--above { - border-radius: var(--form-select-area-radius) var(--form-select-area-radius) - 0 0; - } - // The list scrolls, not the panel, so its scrollport ends where the footer // begins. Scrolling the panel instead would let `scrollIntoView` leave the // active option underneath the footer, which it cannot detect. diff --git a/src/tedi/components/form/search/search.component.ts b/src/tedi/components/form/search/search.component.ts index bb4a282a5..41ee04697 100644 --- a/src/tedi/components/form/search/search.component.ts +++ b/src/tedi/components/form/search/search.component.ts @@ -26,8 +26,6 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { ActiveDescendantKeyManager } from "@angular/cdk/a11y"; import { CdkConnectedOverlay, - CdkOverlayOrigin, - ConnectedOverlayPositionChange, ConnectedPosition, OverlayModule, } from "@angular/cdk/overlay"; @@ -39,6 +37,7 @@ import { import { IconComponent } from "../../base/icon/icon.component"; import { TextComponent } from "../../base/text/text.component"; import { SpinnerComponent } from "../../loader/spinner/spinner.component"; +import { FIELD_OVERLAY_GAP } from "../form-field/field-overlay"; import { TediTranslationService } from "../../../services/translation/translation.service"; import { ComponentInputs } from "../../../types/inputs.type"; import { getFocusableElements } from "../../../utils/elements.util"; @@ -87,13 +86,22 @@ export interface SearchButton { ariaLabel?: string; } -/** - * Flush against the field, flipping above when it would overflow. The panel - * border meets the field border, so there is no offset. - */ +/** Below the field, flipping above when it would overflow. */ const SEARCH_OVERLAY_POSITIONS: ConnectedPosition[] = [ - { originX: "start", originY: "bottom", overlayX: "start", overlayY: "top" }, - { originX: "start", originY: "top", overlayX: "start", overlayY: "bottom" }, + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + offsetY: FIELD_OVERLAY_GAP, + }, + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + offsetY: -FIELD_OVERLAY_GAP, + }, ]; @Component({ @@ -263,7 +271,6 @@ export class SearchComponent implements ControlValueAccessor { readonly footerTemplate = contentChild(SearchFooterTemplateDirective); private readonly inputRef = viewChild("searchInput", { read: ElementRef }); - private readonly overlayOrigin = viewChild.required(CdkOverlayOrigin); private readonly connectedOverlay = viewChild(CdkConnectedOverlay); private readonly suggestionRows = viewChildren(SearchSuggestionComponent); @@ -294,10 +301,10 @@ export class SearchComponent implements ControlValueAccessor { readonly overlayPositions = SEARCH_OVERLAY_POSITIONS; readonly panelWidth = signal(0); /** - * True while the panel sits above the field on the fallback position, so the - * rounded edge can move to the side that is not joined to the field. + * The bordered input box, not the whole form field, so the panel overlays the + * hint instead of opening below it. */ - readonly panelAbove = signal(false); + readonly panelOrigin = signal(this.hostElement.nativeElement); readonly isDisabled = computed(() => this.disabled() || this.formDisabled()); @@ -496,10 +503,22 @@ export class SearchComponent implements ControlValueAccessor { openPanel(): void { if (!this.autocomplete()) return; + this.panelOrigin.set(this.fieldSurface()); this.panelWidth.set(this.hostElement.nativeElement.offsetWidth); this.panelOpen.set(true); } + /** The box wrapping input and icons, or the input itself when it owns the surface. */ + private fieldSurface(): Element { + const input = this.inputRef()?.nativeElement as HTMLElement | undefined; + + return ( + input?.closest(".tedi-form-field__box") ?? + input ?? + this.hostElement.nativeElement + ); + } + closePanel(): void { this.cleanupScrollListener(); this.panelOpen.set(false); @@ -508,10 +527,6 @@ export class SearchComponent implements ControlValueAccessor { this.resetActiveOption(); } - onPositionChange(change: ConnectedOverlayPositionChange): void { - this.panelAbove.set(change.connectionPair.overlayY === "bottom"); - } - onOverlayAttach(): void { if (this.hideOnScroll()) { this.setupScrollListener(); From 2ba11b2cd06424fad40b1c2dfdce3a2fa7be105f Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 9 Oct 2026 16:30:44 +0300 Subject: [PATCH 2/2] test(search): add missing coverage for autocomplete #727 --- .../search/search-templates.directive.spec.ts | 10 ++++++++++ .../form/search/search.component.spec.ts | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/tedi/components/form/search/search-templates.directive.spec.ts b/src/tedi/components/form/search/search-templates.directive.spec.ts index 05066ffb1..1d655b3fd 100644 --- a/src/tedi/components/form/search/search-templates.directive.spec.ts +++ b/src/tedi/components/form/search/search-templates.directive.spec.ts @@ -28,6 +28,16 @@ describe("search template directives", () => { ); }); + it("narrows the suggestion template context", () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const directive = fixture.componentInstance.suggestion()!; + + expect( + SearchSuggestionTemplateDirective.ngTemplateContextGuard(directive, {}), + ).toBe(true); + }); + it("exposes the footer TemplateRef", () => { const fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); diff --git a/src/tedi/components/form/search/search.component.spec.ts b/src/tedi/components/form/search/search.component.spec.ts index e7982c66b..9b1d7af3b 100644 --- a/src/tedi/components/form/search/search.component.spec.ts +++ b/src/tedi/components/form/search/search.component.spec.ts @@ -737,6 +737,16 @@ describe("SearchComponent", () => { expect(getInput().getAttribute("aria-activedescendant")).toBeNull(); }); + it("ArrowDown opens a closed panel without activating an option", () => { + component.closePanel(); + fixture.detectChanges(); + + press("ArrowDown"); + + expect(component.panelOpen()).toBe(true); + expect(getInput().getAttribute("aria-activedescendant")).toBeNull(); + }); + it("ArrowDown activates the first option", () => { press("ArrowDown"); @@ -953,6 +963,16 @@ describe("SearchComponent", () => { expect(component.panelOpen()).toBe(false); }); + it("closes when focus leaves the page", () => { + component.openPanel(); + fixture.detectChanges(); + + component.onFocusOut(new FocusEvent("focusout", { relatedTarget: null })); + fixture.detectChanges(); + + expect(component.panelOpen()).toBe(false); + }); + it("stays open when focus moves within the component", () => { component.openPanel(); fixture.detectChanges();