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
29 changes: 25 additions & 4 deletions src/tedi/components/form/date-field/date-field.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
},
];
});

Expand Down
5 changes: 5 additions & 0 deletions src/tedi/components/form/form-field/field-overlay.ts
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions src/tedi/components/form/search/search-suggestion.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions src/tedi/components/form/search/search-templates.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 2 additions & 8 deletions src/tedi/components/form/search/search.component.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<tedi-form-field
class="tedi-search__field"
cdkOverlayOrigin
#origin="cdkOverlayOrigin"
[size]="size()"
[icon]="fieldIcon()"
[clearable]="clearable() && !isDisabled()"
Expand Down Expand Up @@ -73,20 +71,16 @@

<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOrigin]="panelOrigin()"
[cdkConnectedOverlayOpen]="panelVisible()"
[cdkConnectedOverlayWidth]="panelWidth()"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayDisableClose]="true"
(overlayOutsideClick)="closePanel()"
(positionChange)="onPositionChange($event)"
(attach)="onOverlayAttach()"
(detach)="onOverlayDetach()"
>
<div
class="tedi-search__panel"
[class.tedi-search__panel--above]="panelAbove()"
>
<div class="tedi-search__panel">
@if (loading()) {
<div class="tedi-search__panel-status">
<tedi-spinner [size]="16" />
Expand Down
10 changes: 1 addition & 9 deletions src/tedi/components/form/search/search.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions src/tedi/components/form/search/search.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
Expand Down
47 changes: 31 additions & 16 deletions src/tedi/components/form/search/search.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -263,7 +271,6 @@ export class SearchComponent<T = unknown> 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);

Expand Down Expand Up @@ -294,10 +301,10 @@ export class SearchComponent<T = unknown> 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<Element>(this.hostElement.nativeElement);

readonly isDisabled = computed(() => this.disabled() || this.formDisabled());

Expand Down Expand Up @@ -496,10 +503,22 @@ export class SearchComponent<T = unknown> 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);
Expand All @@ -508,10 +527,6 @@ export class SearchComponent<T = unknown> implements ControlValueAccessor {
this.resetActiveOption();
}

onPositionChange(change: ConnectedOverlayPositionChange): void {
this.panelAbove.set(change.connectionPair.overlayY === "bottom");
}

onOverlayAttach(): void {
if (this.hideOnScroll()) {
this.setupScrollListener();
Expand Down
Loading