Skip to content
Snippets Groups Projects
Commit 894b44df authored by OZGCloud's avatar OZGCloud
Browse files

Merge pull request 'OZG-6129-zufi-searchbar' (#702) from OZG-6129-zufi-searchbar into master

parents e57a18b0 e3793354
No related branches found
No related tags found
No related merge requests found
Showing
with 1085 additions and 40 deletions
...@@ -14,6 +14,16 @@ ...@@ -14,6 +14,16 @@
<nav>NAV</nav> <nav>NAV</nav>
</div> </div>
<main class="flex-auto bg-background-50 p-6"> <main class="flex-auto bg-background-50 p-6">
<div class="my-5">
<ods-instant-search
headerText="In der OZG-Cloud"
placeholder="zuständige Stelle suchen"
[control]="instantSearchFormControl"
[searchResults]="getInstantSearchResults()"
(searchResultSelected)="selectSearchResult($event)"
(searchQueryChanged)="onSearchQueryChanged($event)"
></ods-instant-search>
</div>
<div class="w-96"> <div class="w-96">
<ods-attachment-wrapper> <ods-attachment-wrapper>
<ods-attachment <ods-attachment
...@@ -21,6 +31,7 @@ ...@@ -21,6 +31,7 @@
description="234 kB" description="234 kB"
fileType="pdf" fileType="pdf"
isLoading="true" isLoading="true"
loadingCaption="Mein_Bescheid.pdf wird heruntergeladen..."
> >
</ods-attachment> </ods-attachment>
<ods-attachment caption="Mein_Bescheid.xml" description="234 kB" fileType="xml"> <ods-attachment caption="Mein_Bescheid.xml" description="234 kB" fileType="xml">
...@@ -94,7 +105,7 @@ ...@@ -94,7 +105,7 @@
value="abgelehnt" value="abgelehnt"
variant="bescheid_abgelehnt" variant="bescheid_abgelehnt"
> >
<ods-close-icon class="fill-abgelehnt" /> <ods-close-icon class="fill-abgelehnt" size="large" />
</ods-radio-button-card> </ods-radio-button-card>
</div> </div>
</form> </form>
...@@ -209,14 +220,6 @@ ...@@ -209,14 +220,6 @@
<p text class="text-center">Bescheiddokument<br />hochladen</p></ods-file-upload-button <p text class="text-center">Bescheiddokument<br />hochladen</p></ods-file-upload-button
> >
</div> </div>
<div class="mt-4">
<ods-file-upload-button class="w-72" [isLoading]="false" id="upload129">
<ods-bescheid-upload-icon />
<ods-spinner-icon spinner size="medium" />
<div text class="text-center">Anhang hochladen</div></ods-file-upload-button
>
</div>
<div class="mt-4"> <div class="mt-4">
<ods-file-upload-button class="w-72" [isLoading]="true" id="upload130"> <ods-file-upload-button class="w-72" [isLoading]="true" id="upload130">
<ods-attachment-icon icon /> <ods-attachment-icon icon />
......
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
ErrorMessageComponent, ErrorMessageComponent,
FileIconComponent, FileIconComponent,
FileUploadButtonComponent, FileUploadButtonComponent,
InstantSearchComponent,
RadioButtonCardComponent, RadioButtonCardComponent,
SaveIconComponent, SaveIconComponent,
SendIconComponent, SendIconComponent,
...@@ -24,6 +25,11 @@ import { ...@@ -24,6 +25,11 @@ import {
TextareaComponent, TextareaComponent,
} from '@ods/system'; } from '@ods/system';
import { EMPTY_STRING } from '@alfa-client/tech-shared';
import {
InstantSearchQuery,
InstantSearchResult,
} from 'libs/design-system/src/lib/instant-search/instant-search/instant-search.model';
import { BescheidDialogExampleComponent } from './components/bescheid-dialog/bescheid-dialog.component'; import { BescheidDialogExampleComponent } from './components/bescheid-dialog/bescheid-dialog.component';
import { BescheidPaperComponent } from './components/bescheid-paper/bescheid-paper.component'; import { BescheidPaperComponent } from './components/bescheid-paper/bescheid-paper.component';
import { BescheidStepperComponent } from './components/bescheid-stepper/bescheid-stepper.component'; import { BescheidStepperComponent } from './components/bescheid-stepper/bescheid-stepper.component';
...@@ -46,6 +52,7 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com ...@@ -46,6 +52,7 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com
BescheidPaperComponent, BescheidPaperComponent,
RadioButtonCardComponent, RadioButtonCardComponent,
ReactiveFormsModule, ReactiveFormsModule,
InstantSearchComponent,
SaveIconComponent, SaveIconComponent,
SendIconComponent, SendIconComponent,
StampIconComponent, StampIconComponent,
...@@ -64,14 +71,49 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com ...@@ -64,14 +71,49 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com
templateUrl: './app.component.html', templateUrl: './app.component.html',
}) })
export class AppComponent { export class AppComponent {
title = 'demo';
darkMode = signal<boolean>(JSON.parse(window.localStorage.getItem('darkMode') ?? 'false')); darkMode = signal<boolean>(JSON.parse(window.localStorage.getItem('darkMode') ?? 'false'));
@HostBinding('class.dark') get mode() { @HostBinding('class.dark') get mode() {
return this.darkMode(); return this.darkMode();
} }
title = 'demo';
instantSearchItems: InstantSearchResult<unknown>[] = [
{
title: 'Landeshauptstadt Kiel - Ordnungsamt, Gewerbe- und Schornsteinfegeraufsicht',
description: 'Fabrikstraße 8-10, 24103 Kiel',
data: { resource: 'dummy 1' },
},
{
title: 'Amt für Digitalisierung, Breitband und Vermessung Nürnberg Außenstelle Hersbruck',
description: 'Rathausmarkt 7, Hersbruck',
data: { resource: 'dummy 2' },
},
{
title: 'Amt für Digitalisierung, Breitband und Vermessung Stuttgart',
description: 'Rathausmarkt 7, Stuttgart',
data: { resource: 'dummy 3' },
},
{
title: 'Amt für Digitalisierung, Breitband und Vermessung Ulm',
description: 'Rathausmarkt 7, Ulm',
data: { resource: 'dummy 4' },
},
];
instantSearchFormControl = new FormControl(EMPTY_STRING);
getInstantSearchResults() {
if (this.instantSearchFormControl.value.length < 2) return [];
return this.instantSearchItems.filter((item) =>
item.title.toLowerCase().includes(this.instantSearchFormControl.value.toLowerCase()),
);
}
selectSearchResult(result: InstantSearchResult<unknown>) {
console.log(result);
}
exampleForm = new FormGroup({ exampleForm = new FormGroup({
exampleName: new FormControl('bewilligt'), exampleName: new FormControl('bewilligt'),
}); });
...@@ -87,4 +129,8 @@ export class AppComponent { ...@@ -87,4 +129,8 @@ export class AppComponent {
window.localStorage.setItem('darkMode', JSON.stringify(this.darkMode())); window.localStorage.setItem('darkMode', JSON.stringify(this.darkMode()));
}); });
} }
public onSearchQueryChanged(searchQuery: InstantSearchQuery) {
console.info('Search query: %o', searchQuery);
}
} }
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
<ods-close-icon <ods-close-icon
*ngIf="!bescheid.bewilligt" *ngIf="!bescheid.bewilligt"
data-test-id="abgelehnt-icon" data-test-id="abgelehnt-icon"
size="small"
class="fill-abgelehnt" class="fill-abgelehnt"
/> />
......
...@@ -22,4 +22,5 @@ export * from './lib/icons/save-icon/save-icon.component'; ...@@ -22,4 +22,5 @@ export * from './lib/icons/save-icon/save-icon.component';
export * from './lib/icons/send-icon/send-icon.component'; export * from './lib/icons/send-icon/send-icon.component';
export * from './lib/icons/spinner-icon/spinner-icon.component'; export * from './lib/icons/spinner-icon/spinner-icon.component';
export * from './lib/icons/stamp-icon/stamp-icon.component'; export * from './lib/icons/stamp-icon/stamp-icon.component';
export * from './lib/instant-search/instant-search/instant-search.component';
export * from './lib/testbtn/testbtn.component'; export * from './lib/testbtn/testbtn.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AriaLiveRegionComponent } from './aria-live-region.component';
describe('AriaLiveRegionComponent', () => {
let component: AriaLiveRegionComponent;
let fixture: ComponentFixture<AriaLiveRegionComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AriaLiveRegionComponent],
}).compileComponents();
fixture = TestBed.createComponent(AriaLiveRegionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
@Component({
selector: 'ods-aria-live-region',
standalone: true,
imports: [CommonModule],
template: `<span aria-live="polite" class="sr-only" role="status">{{ text }}</span>`,
})
export class AriaLiveRegionComponent {
@Input() text: string = '';
}
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { AriaLiveRegionComponent } from './aria-live-region.component';
const meta: Meta<AriaLiveRegionComponent> = {
title: 'Aria live region',
component: AriaLiveRegionComponent,
excludeStories: /.*Data$/,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<AriaLiveRegionComponent>;
export const Default: Story = {
args: {
text: '',
},
render: (args) => ({
props: args,
template: `
<h2>This component is suitable for screen reader. Fill text field to make screen reader read it aloud.</h2>
<ods-aria-live-region ${argsToTemplate(args)} />
`,
}),
};
import { argsToTemplate, moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
import { DownloadButtonComponent } from '../../../../design-component/src/lib/download-button/download-button.component'; import { DownloadButtonComponent } from '../../../../design-component/src/lib/download-button/download-button.component';
import { AttachmentHeaderComponent } from '../attachment-header/attachment-header.component';
import { AttachmentComponent } from '../attachment/attachment.component'; import { AttachmentComponent } from '../attachment/attachment.component';
import { AttachmentWrapperComponent } from './attachment-wrapper.component'; import { AttachmentWrapperComponent } from './attachment-wrapper.component';
...@@ -17,7 +18,12 @@ const meta: Meta<AttachmentWrapperComponent> = { ...@@ -17,7 +18,12 @@ const meta: Meta<AttachmentWrapperComponent> = {
}, },
decorators: [ decorators: [
moduleMetadata({ moduleMetadata({
imports: [AttachmentWrapperComponent, AttachmentComponent, DownloadButtonComponent], imports: [
AttachmentWrapperComponent,
AttachmentComponent,
DownloadButtonComponent,
AttachmentHeaderComponent,
],
}), }),
], ],
excludeStories: /.*Data$/, excludeStories: /.*Data$/,
...@@ -28,18 +34,11 @@ export default meta; ...@@ -28,18 +34,11 @@ export default meta;
type Story = StoryObj<AttachmentWrapperComponent>; type Story = StoryObj<AttachmentWrapperComponent>;
export const Default: Story = { export const Default: Story = {
args: { render: () => ({
title: 'Anhänge', template: `<ods-attachment-wrapper>
}, <ods-attachment-header title="Anhänge">
argTypes: {
title: {
description: 'Title for group of files',
},
},
render: (args) => ({
props: args,
template: `<ods-attachment-wrapper ${argsToTemplate(args)}>
<ods-download-button action-buttons /> <ods-download-button action-buttons />
</ods-attachment-header>
<ods-attachment caption="Attachment" description="200 kB" fileType="pdf"></ods-attachment> <ods-attachment caption="Attachment" description="200 kB" fileType="pdf"></ods-attachment>
<ods-attachment caption="Second attachment" description="432 kB" fileType="doc"></ods-attachment> <ods-attachment caption="Second attachment" description="432 kB" fileType="doc"></ods-attachment>
</ods-attachment-wrapper>`, </ods-attachment-wrapper>`,
......
...@@ -13,7 +13,7 @@ import { StampIconComponent } from '../icons/stamp-icon/stamp-icon.component'; ...@@ -13,7 +13,7 @@ import { StampIconComponent } from '../icons/stamp-icon/stamp-icon.component';
><ods-stamp-icon size="medium" class="fill-bewilligt" />Bewilligt am {{ dateText }}</span ><ods-stamp-icon size="medium" class="fill-bewilligt" />Bewilligt am {{ dateText }}</span
> >
<span class="flex items-center gap-2" *ngIf="!bewilligt" <span class="flex items-center gap-2" *ngIf="!bewilligt"
><ods-close-icon size="medium" class="fill-abgelehnt" />Abgelehnt am ><ods-close-icon class="fill-abgelehnt" />Abgelehnt am
{{ dateText }} {{ dateText }}
</span> </span>
<span <span
......
...@@ -53,7 +53,7 @@ export const Default: Story = { ...@@ -53,7 +53,7 @@ export const Default: Story = {
value="abgelehnt" value="abgelehnt"
variant="bescheid_abgelehnt" variant="bescheid_abgelehnt"
> >
<ods-close-icon class="fill-abgelehnt" /> <ods-close-icon class="fill-abgelehnt" size="large" />
</ods-radio-button-card> </ods-radio-button-card>
</div>`, </div>`,
}), }),
......
import { convertForDataTest, TechSharedModule } from '@alfa-client/tech-shared'; import { convertForDataTest, EMPTY_STRING, TechSharedModule } from '@alfa-client/tech-shared';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, ElementRef, Input, ViewChild } from '@angular/core'; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { cva, VariantProps } from 'class-variance-authority'; import { cva, VariantProps } from 'class-variance-authority';
import { ErrorMessageComponent } from '../error-message/error-message.component'; import { ErrorMessageComponent } from '../error-message/error-message.component';
const textInputVariants = cva( const textInputVariants = cva(
'block w-full rounded-lg border bg-background-50 px-3 py-2 text-base leading-5 text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2', 'w-full h-10 rounded-lg border bg-background-50 px-3 py-2 text-base leading-5 text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
{ {
variants: { variants: {
variant: { variant: {
...@@ -28,23 +28,40 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; ...@@ -28,23 +28,40 @@ type TextInputVariants = VariantProps<typeof textInputVariants>;
standalone: true, standalone: true,
imports: [CommonModule, ErrorMessageComponent, ReactiveFormsModule, TechSharedModule], imports: [CommonModule, ErrorMessageComponent, ReactiveFormsModule, TechSharedModule],
template: ` template: `
<div> <div class="relative">
<label [for]="id" class="text-md mb-2 block font-medium text-text"> <label [for]="id" class="text-md mb-2 block font-medium text-text">
{{ inputLabel }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container> {{ inputLabel }}<ng-container *ngIf="required"><i aria-hidden="true">*</i></ng-container>
</label> </label>
<div class="mt-2"> <div class="mt-2">
<div
*ngIf="withPrefix"
class="pointer-events-none absolute bottom-2 left-2 flex size-6 items-center justify-center"
>
<ng-content select="[prefix]" />
</div>
<input <input
type="text" type="text"
[id]="id" [id]="id"
[formControl]="fieldControl" [formControl]="fieldControl"
[ngClass]="textInputVariants({ variant })" [ngClass]="[
textInputVariants({ variant }),
withPrefix ? 'pl-10' : '',
withSuffix ? 'pr-10' : '',
]"
[placeholder]="placeholder" [placeholder]="placeholder"
[autocomplete]="autocomplete" [autocomplete]="autocomplete"
[attr.aria-required]="required" [attr.aria-required]="required"
[attr.aria-invalid]="variant === 'error'" [attr.aria-invalid]="variant === 'error'"
[attr.data-test-id]="(inputLabel | convertForDataTest) + '-text-input'" [attr.data-test-id]="(inputLabel | convertForDataTest) + '-text-input'"
(click)="clickEmitter.emit()"
#inputElement #inputElement
/> />
<div
*ngIf="withSuffix"
class="absolute bottom-2 right-2 flex size-6 items-center justify-center"
>
<ng-content select="[suffix]" />
</div>
</div> </div>
<ng-content select="[error]"></ng-content> <ng-content select="[error]"></ng-content>
</div> </div>
...@@ -60,8 +77,10 @@ export class TextInputComponent { ...@@ -60,8 +77,10 @@ export class TextInputComponent {
@Input() placeholder: string = ''; @Input() placeholder: string = '';
@Input() autocomplete: string = 'off'; @Input() autocomplete: string = 'off';
@Input() variant: TextInputVariants['variant']; @Input() variant: TextInputVariants['variant'];
@Input() fieldControl: FormControl; @Input() fieldControl: FormControl = new FormControl(EMPTY_STRING);
@Input() required: boolean = false; @Input() required: boolean = false;
@Input() withPrefix: boolean = false;
@Input() withSuffix: boolean = false;
@Input() set focus(value: boolean) { @Input() set focus(value: boolean) {
if (value && this.inputElement) { if (value && this.inputElement) {
...@@ -69,6 +88,8 @@ export class TextInputComponent { ...@@ -69,6 +88,8 @@ export class TextInputComponent {
} }
} }
@Output() clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>();
inputLabel: string; inputLabel: string;
id: string; id: string;
textInputVariants = textInputVariants; textInputVariants = textInputVariants;
......
import { TechSharedModule, convertForDataTest } from '@alfa-client/tech-shared'; import { EMPTY_STRING, TechSharedModule, convertForDataTest } from '@alfa-client/tech-shared';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, ElementRef, Input, ViewChild } from '@angular/core'; import { Component, ElementRef, Input, ViewChild } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
...@@ -59,7 +59,7 @@ export class TextareaComponent { ...@@ -59,7 +59,7 @@ export class TextareaComponent {
@Input() rows: number = 3; @Input() rows: number = 3;
@Input() autocomplete: string = 'off'; @Input() autocomplete: string = 'off';
@Input() variant: TextareaVariants['variant']; @Input() variant: TextareaVariants['variant'];
@Input() fieldControl: FormControl; @Input() fieldControl: FormControl = new FormControl(EMPTY_STRING);
@Input() required: boolean = false; @Input() required: boolean = false;
@Input() set focus(value: boolean) { @Input() set focus(value: boolean) {
......
...@@ -12,16 +12,16 @@ import { IconVariants, iconVariants } from '../iconVariants'; ...@@ -12,16 +12,16 @@ import { IconVariants, iconVariants } from '../iconVariants';
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
[ngClass]="[twMerge(iconVariants({ size }), 'fill-black', class)]" [ngClass]="[twMerge(iconVariants({ size }), 'fill-black', class)]"
aria-hidden="true" aria-hidden="true"
viewBox="0 0 14 14" viewBox="0 0 24 24"
fill="inherit" fill="inherit"
> >
<path <path
d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41Z"
/> />
</svg>`, </svg>`,
}) })
export class CloseIconComponent { export class CloseIconComponent {
@Input() size: IconVariants['size'] = 'small'; @Input() size: IconVariants['size'] = 'medium';
@Input() class: string = undefined; @Input() class: string = undefined;
iconVariants = iconVariants; iconVariants = iconVariants;
......
...@@ -20,7 +20,7 @@ export const Default: Story = { ...@@ -20,7 +20,7 @@ export const Default: Story = {
options: ['small', 'medium', 'large', 'extra-large', 'full'], options: ['small', 'medium', 'large', 'extra-large', 'full'],
description: 'Size of icon. Property "full" means 100%', description: 'Size of icon. Property "full" means 100%',
table: { table: {
defaultValue: { summary: 'small' }, defaultValue: { summary: 'medium' },
}, },
}, },
}, },
......
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchIconComponent } from './search-icon.component';
describe('SearchIconComponent', () => {
let component: SearchIconComponent;
let fixture: ComponentFixture<SearchIconComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SearchIconComponent],
}).compileComponents();
fixture = TestBed.createComponent(SearchIconComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
import { NgClass } from '@angular/common';
import { Component, Input } from '@angular/core';
import { twMerge } from 'tailwind-merge';
import { IconVariants, iconVariants } from '../iconVariants';
@Component({
selector: 'ods-search-icon',
standalone: true,
imports: [NgClass],
template: `<svg
[ngClass]="twMerge(iconVariants({ size }), 'fill-primary', class)"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.5 14H14.71L14.43 13.73C15.41 12.59 16 11.11 16 9.5C16 5.91 13.09 3 9.5 3C5.91 3 3 5.91 3 9.5C3 13.09 5.91 16 9.5 16C11.11 16 12.59 15.41 13.73 14.43L14 14.71V15.5L19 20.49L20.49 19L15.5 14ZM9.5 14C7.01 14 5 11.99 5 9.5C5 7.01 7.01 5 9.5 5C11.99 5 14 7.01 14 9.5C14 11.99 11.99 14 9.5 14Z"
/>
</svg>`,
})
export class SearchIconComponent {
@Input() size: IconVariants['size'] = 'medium';
@Input() class: string = '';
iconVariants = iconVariants;
twMerge = twMerge;
}
import type { Meta, StoryObj } from '@storybook/angular';
import { SearchIconComponent } from './search-icon.component';
const meta: Meta<SearchIconComponent> = {
title: 'Icons/Search icon',
component: SearchIconComponent,
excludeStories: /.*Data$/,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<SearchIconComponent>;
export const Default: Story = {
args: { size: 'large' },
argTypes: {
size: {
control: 'select',
options: ['small', 'medium', 'large', 'extra-large', 'full'],
description: 'Size of icon. Property "full" means 100%',
table: {
defaultValue: { summary: 'medium' },
},
},
},
};
import { Mock, mock, useFromMock } from '@alfa-client/test-utils';
import { EventEmitter } from '@angular/core';
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
tick,
} from '@angular/core/testing';
import { Subscription } from 'rxjs';
import { InstantSearchComponent } from './instant-search.component';
import { InstantSearchQuery, InstantSearchResult } from './instant-search.model';
describe('InstantSearchComponent', () => {
let component: InstantSearchComponent;
let fixture: ComponentFixture<InstantSearchComponent>;
const searchResults: InstantSearchResult<unknown>[] = [
{ title: 'test', description: 'test' },
{ title: 'caption', description: 'desc' },
];
const searchBy: string = 'query';
let searchQueryChanged: Mock<EventEmitter<any>>;
let searchResultSelected: Mock<EventEmitter<any>>;
beforeEach(async () => {
searchQueryChanged = <any>mock(EventEmitter);
searchResultSelected = <any>mock(EventEmitter);
await TestBed.configureTestingModule({
imports: [InstantSearchComponent],
}).compileComponents();
fixture = TestBed.createComponent(InstantSearchComponent);
component = fixture.componentInstance;
component.searchQueryChanged = useFromMock(searchQueryChanged);
component.searchResultSelected = useFromMock(searchResultSelected);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('ngOnInit', () => {
it('should handle value changes', () => {
component.handleValueChanges = jest.fn();
component.ngOnInit();
expect(component.handleValueChanges).toHaveBeenCalled();
});
});
describe('handleValueChanges', () => {
beforeEach(() => {
component.showResults = jest.fn();
});
it('should subscribe to value changes', () => {
component.control.valueChanges.subscribe = jest.fn();
component.handleValueChanges();
expect(component.control.valueChanges.subscribe).toHaveBeenCalled();
});
it('should emit query', fakeAsync(() => {
component.handleValueChanges();
component.control.setValue(searchBy);
tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS);
component.control.valueChanges.subscribe();
expect(searchQueryChanged.emit).toHaveBeenCalledWith({ searchBy } as InstantSearchQuery);
}));
it('should not emit query', fakeAsync(() => {
component.handleValueChanges();
const searchBy: string = 'q';
component.control.setValue(searchBy);
tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS);
component.control.valueChanges.subscribe();
expect(searchQueryChanged.emit).not.toHaveBeenCalled();
}));
describe('result are already visible', () => {
beforeEach(() => {
component.areResultsVisible = true;
});
it('should not show results', fakeAsync(() => {
component.handleValueChanges();
component.control.setValue(searchBy);
tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS);
component.control.valueChanges.subscribe();
expect(component.showResults).not.toHaveBeenCalled();
discardPeriodicTasks();
}));
});
describe('results are not visible', () => {
beforeEach(() => {
component.areResultsVisible = false;
});
it('should show results', fakeAsync(() => {
component.handleValueChanges();
component.control.setValue(searchBy);
tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS);
component.control.valueChanges.subscribe();
expect(component.showResults).toHaveBeenCalled();
discardPeriodicTasks();
}));
it('should not show results if debounce time not reached', fakeAsync(() => {
component.handleValueChanges();
component.control.setValue(searchBy);
tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS - 1);
component.control.valueChanges.subscribe();
expect(component.showResults).not.toHaveBeenCalled();
discardPeriodicTasks();
}));
it('should not show results if not enough characters entered', fakeAsync(() => {
component.handleValueChanges();
component.control.setValue('q');
tick(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS);
component.control.valueChanges.subscribe();
expect(component.showResults).not.toHaveBeenCalled();
discardPeriodicTasks();
}));
});
});
describe('ngOnDestroy', () => {
it('should subscribe to value changes', () => {
component.formControlSubscription = new Subscription();
component.formControlSubscription.unsubscribe = jest.fn();
component.ngOnDestroy();
expect(component.formControlSubscription.unsubscribe).toHaveBeenCalled();
});
});
describe('set searchResults', () => {
describe('on different results', () => {
it('should call setSearchResults', () => {
component.setSearchResults = jest.fn();
component.searchResults = searchResults;
expect(component.setSearchResults).toHaveBeenCalled();
});
});
describe('on same results', () => {
it('should not call setSearchResults', () => {
component.setSearchResults = jest.fn();
component.searchResults = [];
expect(component.setSearchResults).not.toHaveBeenCalled();
});
});
describe('on null or undefined', () => {
it.each([null, undefined])(
'should not call setSearchResults for %s',
(searchResults: InstantSearchResult<unknown>[]) => {
component.setSearchResults = jest.fn();
component.searchResults = searchResults;
expect(component.setSearchResults).not.toHaveBeenCalled();
},
);
});
});
describe('setSearchResults', () => {
it('should set results', () => {
component.setSearchResults(searchResults);
expect(component.results).toEqual(searchResults);
});
it('should call buildAriaLiveText with search results length', () => {
component.buildAriaLiveText = jest.fn();
component.setSearchResults(searchResults);
expect(component.buildAriaLiveText).toHaveBeenCalledWith(searchResults.length);
});
});
describe('setFocusOnResultItem', () => {
beforeEach(() => {
component.resultsRef.get = jest.fn().mockReturnValue({ setFocus: jest.fn() });
});
it('should call get for resultsRef with index', () => {
component.setFocusOnResultItem(1);
expect(component.resultsRef.get).toHaveBeenCalledWith(1);
});
it('should call setFocus', () => {
component.setFocusOnResultItem(1);
expect(component.resultsRef.get(1).setFocus).toHaveBeenCalled();
});
});
describe('handleArrowNavigation', () => {
const event: KeyboardEvent = new KeyboardEvent('arrow');
beforeEach(() => {
component.getResultIndexForKey = jest.fn();
component.setFocusOnResultItem = jest.fn();
});
it('should call prevent default', () => {
event.preventDefault = jest.fn();
component.handleArrowNavigation(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it('should call getResultIndexForKey', () => {
component.handleArrowNavigation(event);
expect(component.getResultIndexForKey).toHaveBeenCalledWith(event.key);
});
it('should call setFocusOnResultItem', () => {
component.getResultIndexForKey = jest.fn().mockReturnValue(0);
component.handleArrowNavigation(event);
expect(component.setFocusOnResultItem).toHaveBeenCalledWith(0);
});
});
describe('handleEscape', () => {
const event: KeyboardEvent = new KeyboardEvent('esc');
it('should call prevent default', () => {
event.preventDefault = jest.fn();
component.handleEscape(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it('should call hideResults', () => {
component.hideResults = jest.fn();
component.handleEscape(event);
expect(component.hideResults).toHaveBeenCalled();
});
});
describe('getNextResultIndex', () => {
it('should return 0 if index is undefined', () => {
const result: number = component.getNextResultIndex(undefined, 2);
expect(result).toBe(0);
});
it('should return 0 if current index is last', () => {
const result: number = component.getNextResultIndex(1, 2);
expect(result).toBe(0);
});
it('should return next search result index', () => {
const result: number = component.getNextResultIndex(0, 2);
expect(result).toBe(1);
});
});
describe('getPreviousResultIndex', () => {
it('should return last index if current index is undefined', () => {
const result: number = component.getPreviousResultIndex(undefined, 2);
expect(result).toBe(1);
});
it('should return last index if current index is first', () => {
const result: number = component.getPreviousResultIndex(0, 2);
expect(result).toBe(1);
});
it('should return previous search result index', () => {
const result: number = component.getPreviousResultIndex(1, 2);
expect(result).toBe(0);
});
});
describe('getResultIndexForKey', () => {
it('should call getNextResultIndex if ArrowDown', () => {
component.getNextResultIndex = jest.fn();
component.getResultIndexForKey('ArrowDown');
expect(component.getNextResultIndex).toHaveBeenCalled();
});
it('should call getPreviousResultIndex if ArrowUp', () => {
component.getPreviousResultIndex = jest.fn();
component.getResultIndexForKey('ArrowUp');
expect(component.getPreviousResultIndex).toHaveBeenCalled();
});
});
describe('getLastItemIndex', () => {
it('should return 0', () => {
const result: number = component.getLastItemIndex(0);
expect(result).toBe(0);
});
it('should return decrement of array length', () => {
const result: number = component.getLastItemIndex(5);
expect(result).toBe(4);
});
});
describe('buildAriaLiveText', () => {
beforeEach(() => {
component.control.setValue('test');
});
it('should return text for one result', () => {
const result: string = component.buildAriaLiveText(1);
expect(result).toBe(
'Ein Suchergebnis für Eingabe test. Nutze Pfeiltaste nach unten, um das zu erreichen.',
);
});
it('should return text for many results', () => {
const result: string = component.buildAriaLiveText(4);
expect(result).toBe(
'4 Suchergebnisse für Eingabe test. Nutze Pfeiltaste nach unten, um diese zu erreichen.',
);
});
it('should return text for no results', () => {
const result: string = component.buildAriaLiveText(0);
expect(result).toBe('Keine Ergebnisse');
});
});
describe('showResults', () => {
it('should set isShowResults to true', () => {
component.showResults();
expect(component.areResultsVisible).toBe(true);
});
});
describe('hideResults', () => {
it('should set isShowResults to false', () => {
component.hideResults();
expect(component.areResultsVisible).toBe(false);
});
});
describe('onKeydownHandler', () => {
const keyboardEvent: KeyboardEvent = new KeyboardEvent('a');
beforeEach(() => {
component.isSearchResultsEmpty = jest.fn();
component.isArrowNavigationKey = jest.fn();
component.isEscapeKey = jest.fn();
component.handleArrowNavigation = jest.fn();
component.handleEscape = jest.fn();
});
it('should check for empty result', () => {
component.onKeydownHandler(keyboardEvent);
expect(component.isSearchResultsEmpty).toHaveBeenCalled();
});
describe('search result is empty', () => {
beforeEach(() => {
component.isSearchResultsEmpty = jest.fn().mockReturnValue(true);
});
it('should ignore key navigation', () => {
component.onKeydownHandler(keyboardEvent);
expect(component.isArrowNavigationKey).not.toHaveBeenCalled();
});
it('should ignore escape key handling', () => {
component.onKeydownHandler(keyboardEvent);
expect(component.isEscapeKey).not.toHaveBeenCalled();
});
});
describe('search result is not empty', () => {
beforeEach(() => {
component.isSearchResultsEmpty = jest.fn().mockReturnValue(false);
});
it('should check if arrow navigation', () => {
component.onKeydownHandler(keyboardEvent);
expect(component.isArrowNavigationKey).toHaveBeenCalled();
});
it('should handle arrow navigation', () => {
component.isArrowNavigationKey = jest.fn().mockReturnValue(true);
component.onKeydownHandler(keyboardEvent);
expect(component.handleArrowNavigation).toHaveBeenCalled();
});
it('should not handle arrow navigation', () => {
component.isArrowNavigationKey = jest.fn().mockReturnValue(false);
component.onKeydownHandler(keyboardEvent);
expect(component.handleArrowNavigation).not.toHaveBeenCalled();
});
describe('is not arrow navigation', () => {
beforeEach(() => {
component.isArrowNavigationKey = jest.fn().mockReturnValue(false);
});
it('should check for escape key', () => {
component.onKeydownHandler(keyboardEvent);
expect(component.isEscapeKey).toHaveBeenCalled();
});
it('should handle escape key', () => {
component.isEscapeKey = jest.fn().mockReturnValue(true);
component.onKeydownHandler(keyboardEvent);
expect(component.handleEscape).toHaveBeenCalled();
});
it('should not handle escape key', () => {
component.isEscapeKey = jest.fn().mockReturnValue(false);
component.onKeydownHandler(keyboardEvent);
expect(component.handleEscape).not.toHaveBeenCalled();
});
});
});
});
describe('isSearchResultsEmpty', () => {
it('should return true', () => {
component.results = [];
const result: boolean = component.isSearchResultsEmpty();
expect(result).toBe(true);
});
it('should return false', () => {
component.results = searchResults;
const result: boolean = component.isSearchResultsEmpty();
expect(result).toBe(false);
});
});
describe('isArrowNavigationKey', () => {
it.each(['ArrowUp', 'ArrowDown'])('should return true for key %s', (key: string) => {
const keyboardEvent: KeyboardEvent = { ...new KeyboardEvent('key'), key };
const result: boolean = component.isArrowNavigationKey(keyboardEvent);
expect(result).toBeTruthy();
});
it('should return false', () => {
const result: boolean = component.isArrowNavigationKey(new KeyboardEvent('not arrow'));
expect(result).toBe(false);
});
});
describe('isEscapeKey', () => {
it('should return true', () => {
const escapeKeyEvent = { ...new KeyboardEvent('esc'), key: 'Escape' };
const result: boolean = component.isEscapeKey(escapeKeyEvent);
expect(result).toBe(true);
});
it('should return false', () => {
const result: boolean = component.isEscapeKey(new KeyboardEvent('not escape'));
expect(result).toBe(false);
});
});
describe('isLastItemOrOutOfArray', () => {
it.each([3, 5])('should return true for %s', (index: number) => {
const result: boolean = component.isLastItemOrOutOfArray(index, 4);
expect(result).toBe(true);
});
it('should return false', () => {
const result: boolean = component.isLastItemOrOutOfArray(1, 3);
expect(result).toBe(false);
});
});
describe('isFirstItemOrOutOfArray', () => {
it.each([0, -1])('should return true for %s', (index: number) => {
const result: boolean = component.isFirstItemOrOutOfArray(index);
expect(result).toBe(true);
});
it('should return false', () => {
const result: boolean = component.isFirstItemOrOutOfArray(1);
expect(result).toBe(false);
});
});
describe('onItemClicked', () => {
beforeEach(() => {
component.hideResults = jest.fn();
});
it('should emit searchResultSelected', () => {
component.onItemClicked(searchResults[0], 0);
expect(searchResultSelected.emit).toHaveBeenCalledWith(searchResults[0]);
});
it('should hide results', () => {
component.onItemClicked(searchResults[0], 0);
expect(component.hideResults).toHaveBeenCalled();
});
});
describe('onClickHandler', () => {
const e: MouseEvent = { ...new MouseEvent('test') };
beforeEach(() => {
component.hideResults = jest.fn();
});
it('should call hideResults if instant search does not contain event target', () => {
component.onClickHandler(e);
expect(component.hideResults).toHaveBeenCalled();
});
it('should not call hideResults if instant search contains event target', () => {
component.ref.nativeElement.contains = jest.fn().mockReturnValue(true);
component.onClickHandler(e);
expect(component.hideResults).not.toHaveBeenCalled();
});
});
});
import { EMPTY_STRING, isNotNil } from '@alfa-client/tech-shared';
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChildren,
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { isEqual, isUndefined } from 'lodash-es';
import { Subscription, debounceTime, distinctUntilChanged, filter } from 'rxjs';
import { AriaLiveRegionComponent } from '../../aria-live-region/aria-live-region.component';
import { SearchFieldComponent } from '../search-field/search-field.component';
import { SearchResultHeaderComponent } from '../search-result-header/search-result-header.component';
import { SearchResultItemComponent } from '../search-result-item/search-result-item.component';
import { SearchResultLayerComponent } from '../search-result-layer/search-result-layer.component';
import { InstantSearchQuery, InstantSearchResult } from './instant-search.model';
@Component({
selector: 'ods-instant-search',
standalone: true,
imports: [
CommonModule,
SearchFieldComponent,
SearchResultHeaderComponent,
SearchResultItemComponent,
SearchResultLayerComponent,
AriaLiveRegionComponent,
],
template: ` <div class="relative">
<ods-search-field
[placeholder]="placeholder"
[label]="label"
[attr.aria-expanded]="results.length"
[control]="control"
aria-controls="results"
(inputClicked)="showResults()"
#searchField
/>
<ods-aria-live-region [text]="ariaLiveText" />
<ods-search-result-layer
*ngIf="results.length && areResultsVisible"
class="absolute z-50 mt-3 w-full"
id="results"
>
<ods-search-result-header [text]="headerText" [count]="results.length" header />
<ods-search-result-item
*ngFor="let result of results; let i = index"
[title]="result.title"
[description]="result.description"
(itemClicked)="onItemClicked(result, i)"
#results
></ods-search-result-item>
</ods-search-result-layer>
</div>`,
})
export class InstantSearchComponent implements OnInit, OnDestroy {
static readonly DEBOUNCE_TIME_IN_MILLIS: number = 300;
@Input() label: string = EMPTY_STRING;
@Input() placeholder: string = EMPTY_STRING;
@Input() headerText: string = EMPTY_STRING;
@Input() control: FormControl<string> = new FormControl(EMPTY_STRING);
@Input() set searchResults(searchResults: InstantSearchResult<unknown>[]) {
if (!isEqual(searchResults, this.results) && isNotNil(searchResults)) {
this.setSearchResults(searchResults);
}
}
@Output() searchResultSelected: EventEmitter<InstantSearchResult<unknown>> = new EventEmitter<
InstantSearchResult<unknown>
>();
@Output() searchQueryChanged: EventEmitter<InstantSearchQuery> =
new EventEmitter<InstantSearchQuery>();
readonly FIRST_ITEM_INDEX: number = 0;
readonly PREVIEW_SEARCH_STRING_MIN_LENGTH: number = 2;
results: InstantSearchResult<unknown>[] = [];
ariaLiveText: string = '';
areResultsVisible: boolean = true;
private focusedResult: number | undefined = undefined;
formControlSubscription: Subscription;
constructor(public ref: ElementRef) {}
@ViewChildren('results') resultsRef: QueryList<SearchResultItemComponent>;
ngOnInit(): void {
this.handleValueChanges();
}
handleValueChanges() {
this.formControlSubscription = this.control.valueChanges
.pipe(
debounceTime(InstantSearchComponent.DEBOUNCE_TIME_IN_MILLIS),
filter((value: string) => value.length >= this.PREVIEW_SEARCH_STRING_MIN_LENGTH),
distinctUntilChanged(),
)
.subscribe((searchBy: string) => {
this.searchQueryChanged.emit({ searchBy });
if (!this.areResultsVisible) {
this.showResults();
}
});
}
ngOnDestroy(): void {
if (isNotNil(this.formControlSubscription)) this.formControlSubscription.unsubscribe();
}
@HostListener('document:keydown', ['$event'])
onKeydownHandler(e: KeyboardEvent): void {
if (this.isSearchResultsEmpty()) return;
if (this.isArrowNavigationKey(e)) this.handleArrowNavigation(e);
if (this.isEscapeKey(e)) this.handleEscape(e);
}
@HostListener('document:click', ['$event'])
onClickHandler(e: MouseEvent): void {
if (!this.ref.nativeElement.contains(e.target)) {
this.hideResults();
}
}
handleArrowNavigation(e: KeyboardEvent): void {
e.preventDefault();
const newIndex = this.getResultIndexForKey(e.key);
this.focusedResult = newIndex;
this.setFocusOnResultItem(newIndex);
}
handleEscape(e: KeyboardEvent): void {
e.preventDefault();
this.hideResults();
}
setFocusOnResultItem(index: number): void {
this.resultsRef.get(index).setFocus();
}
setSearchResults(searchResults: InstantSearchResult<unknown>[]): void {
this.results = searchResults;
this.ariaLiveText = this.buildAriaLiveText(searchResults.length);
}
getNextResultIndex(index: number | undefined, resultLength: number): number {
if (isUndefined(index)) return this.FIRST_ITEM_INDEX;
if (this.isLastItemOrOutOfArray(index, resultLength)) return this.FIRST_ITEM_INDEX;
return index + 1;
}
getPreviousResultIndex(index: number | undefined, resultLength: number): number {
if (isUndefined(index)) return this.getLastItemIndex(resultLength);
if (this.isFirstItemOrOutOfArray(index)) return this.getLastItemIndex(resultLength);
return index - 1;
}
getLastItemIndex(arrayLength: number): number {
if (arrayLength < 1) return this.FIRST_ITEM_INDEX;
return arrayLength - 1;
}
getResultIndexForKey(key: string): number {
switch (key) {
case 'ArrowDown':
return this.getNextResultIndex(this.focusedResult, this.results.length);
case 'ArrowUp':
return this.getPreviousResultIndex(this.focusedResult, this.results.length);
default:
console.error('Key %s not allowed', key);
}
}
buildAriaLiveText(resultsLength: number): string {
if (resultsLength === 1)
return `Ein Suchergebnis für Eingabe ${this.control.value}. Nutze Pfeiltaste nach unten, um das zu erreichen.`;
if (resultsLength > 1)
return `${resultsLength} Suchergebnisse für Eingabe ${this.control.value}. Nutze Pfeiltaste nach unten, um diese zu erreichen.`;
return 'Keine Ergebnisse';
}
showResults(): void {
this.areResultsVisible = true;
this.focusedResult = undefined;
}
hideResults(): void {
this.areResultsVisible = false;
this.focusedResult = undefined;
}
isLastItemOrOutOfArray(index: number, arrayLength: number): boolean {
return index >= arrayLength - 1;
}
isFirstItemOrOutOfArray(index: number): boolean {
return index <= this.FIRST_ITEM_INDEX;
}
isSearchResultsEmpty(): boolean {
return this.results.length === 0;
}
isArrowNavigationKey(e: KeyboardEvent): boolean {
return e.key === 'ArrowDown' || e.key === 'ArrowUp';
}
isEscapeKey(e: KeyboardEvent): boolean {
return e.key === 'Escape';
}
onItemClicked(searchResult: InstantSearchResult<unknown>, index: number) {
this.searchResultSelected.emit(searchResult);
this.focusedResult = index;
this.hideResults();
}
}
export interface InstantSearchResult<T> {
title: string;
description: string;
data?: T;
}
export interface InstantSearchQuery {
searchBy: string;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment