diff --git a/alfa-client/apps/demo/src/app/app.component.html b/alfa-client/apps/demo/src/app/app.component.html index 81bc4dff284d5e739981af85b8d4e1cc0c4b3844..7dbdd36d8134a9a38fdd859b1371c5a0362820df 100644 --- a/alfa-client/apps/demo/src/app/app.component.html +++ b/alfa-client/apps/demo/src/app/app.component.html @@ -14,6 +14,16 @@ <nav>NAV</nav> </div> <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"> <ods-attachment-wrapper> <ods-attachment @@ -21,6 +31,7 @@ description="234 kB" fileType="pdf" isLoading="true" + loadingCaption="Mein_Bescheid.pdf wird heruntergeladen..." > </ods-attachment> <ods-attachment caption="Mein_Bescheid.xml" description="234 kB" fileType="xml"> @@ -94,7 +105,7 @@ value="abgelehnt" variant="bescheid_abgelehnt" > - <ods-close-icon class="fill-abgelehnt" /> + <ods-close-icon class="fill-abgelehnt" size="large" /> </ods-radio-button-card> </div> </form> @@ -209,14 +220,6 @@ <p text class="text-center">Bescheiddokument<br />hochladen</p></ods-file-upload-button > </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"> <ods-file-upload-button class="w-72" [isLoading]="true" id="upload130"> <ods-attachment-icon icon /> diff --git a/alfa-client/apps/demo/src/app/app.component.ts b/alfa-client/apps/demo/src/app/app.component.ts index 5d64b15a623f2330d29201455ec298907bcd94fa..40addd4c8966f682e403357a673cb578348d5213 100644 --- a/alfa-client/apps/demo/src/app/app.component.ts +++ b/alfa-client/apps/demo/src/app/app.component.ts @@ -15,6 +15,7 @@ import { ErrorMessageComponent, FileIconComponent, FileUploadButtonComponent, + InstantSearchComponent, RadioButtonCardComponent, SaveIconComponent, SendIconComponent, @@ -24,6 +25,11 @@ import { TextareaComponent, } 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 { BescheidPaperComponent } from './components/bescheid-paper/bescheid-paper.component'; import { BescheidStepperComponent } from './components/bescheid-stepper/bescheid-stepper.component'; @@ -46,6 +52,7 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com BescheidPaperComponent, RadioButtonCardComponent, ReactiveFormsModule, + InstantSearchComponent, SaveIconComponent, SendIconComponent, StampIconComponent, @@ -64,14 +71,49 @@ import { CustomStepperComponent } from './components/cdk-demo/custom-stepper.com templateUrl: './app.component.html', }) export class AppComponent { - title = 'demo'; - darkMode = signal<boolean>(JSON.parse(window.localStorage.getItem('darkMode') ?? 'false')); @HostBinding('class.dark') get mode() { 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({ exampleName: new FormControl('bewilligt'), }); @@ -87,4 +129,8 @@ export class AppComponent { window.localStorage.setItem('darkMode', JSON.stringify(this.darkMode())); }); } + + public onSearchQueryChanged(searchQuery: InstantSearchQuery) { + console.info('Search query: %o', searchQuery); + } } diff --git a/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html b/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html index 704a6ddd28f903eee9a9fb887c8ac40d4d56683d..61ae1d119505cb9e60d173dce420dd872cfa784e 100644 --- a/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html +++ b/alfa-client/libs/bescheid/src/lib/beschieden-date-in-vorgang-container/beschieden-date-container/beschieden-date-container.component.html @@ -9,7 +9,6 @@ <ods-close-icon *ngIf="!bescheid.bewilligt" data-test-id="abgelehnt-icon" - size="small" class="fill-abgelehnt" /> diff --git a/alfa-client/libs/design-system/src/index.ts b/alfa-client/libs/design-system/src/index.ts index 5934b94acda30b1a9db2264244086f3d11bdcdbf..9a59b13cbc25c6174275daa3727bdd8388fb0dc4 100644 --- a/alfa-client/libs/design-system/src/index.ts +++ b/alfa-client/libs/design-system/src/index.ts @@ -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/spinner-icon/spinner-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'; diff --git a/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.spec.ts b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0848c08f798f58fd91f850597ce0d15f267e065 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.spec.ts @@ -0,0 +1,21 @@ +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(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.ts b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cec9afff4c23502077704daea8b04f125158bdd --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.component.ts @@ -0,0 +1,12 @@ +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 = ''; +} diff --git a/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.stories.ts b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..7af950ebf415e5e2a634be01a2edaa32a9d78a01 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/aria-live-region/aria-live-region.stories.ts @@ -0,0 +1,25 @@ +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)} /> + `, + }), +}; diff --git a/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts b/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts index 485acda2600b59166e331fe38bffba7392c1e783..7d8f0fbec59aeb00afc99d2377c1902abd20c73d 100644 --- a/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts +++ b/alfa-client/libs/design-system/src/lib/attachment-wrapper/attachment-wrapper.stories.ts @@ -1,7 +1,8 @@ -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 { AttachmentHeaderComponent } from '../attachment-header/attachment-header.component'; import { AttachmentComponent } from '../attachment/attachment.component'; import { AttachmentWrapperComponent } from './attachment-wrapper.component'; @@ -17,7 +18,12 @@ const meta: Meta<AttachmentWrapperComponent> = { }, decorators: [ moduleMetadata({ - imports: [AttachmentWrapperComponent, AttachmentComponent, DownloadButtonComponent], + imports: [ + AttachmentWrapperComponent, + AttachmentComponent, + DownloadButtonComponent, + AttachmentHeaderComponent, + ], }), ], excludeStories: /.*Data$/, @@ -28,18 +34,11 @@ export default meta; type Story = StoryObj<AttachmentWrapperComponent>; export const Default: Story = { - args: { - 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 /> + render: () => ({ + template: `<ods-attachment-wrapper> + <ods-attachment-header title="Anhänge"> + <ods-download-button action-buttons /> + </ods-attachment-header> <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-wrapper>`, diff --git a/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts b/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts index 9ed55dd799191cfb77c2462d25a357425f60c2ab..586fcb8a140c698f4e90a5d8b0b53f0e97556ac8 100644 --- a/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts +++ b/alfa-client/libs/design-system/src/lib/bescheid-status-text/bescheid-status-text.component.ts @@ -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 > <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 }} </span> <span diff --git a/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts b/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts index 96ec7406916b5f4773f6aa448ad12112c913ebfc..dd949aeef16caab452a0ee6c8aee76bb0624cd79 100644 --- a/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts +++ b/alfa-client/libs/design-system/src/lib/form/radio-button-card/radio-button-card.stories.ts @@ -53,7 +53,7 @@ export const Default: Story = { value="abgelehnt" variant="bescheid_abgelehnt" > - <ods-close-icon class="fill-abgelehnt" /> + <ods-close-icon class="fill-abgelehnt" size="large" /> </ods-radio-button-card> </div>`, }), diff --git a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts index e136a50bb991af6df9384d7ceca287879b3cdcc8..a374f53e940446a07e40774135bdb40d7578461d 100644 --- a/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/text-input/text-input.component.ts @@ -1,12 +1,12 @@ -import { convertForDataTest, TechSharedModule } from '@alfa-client/tech-shared'; +import { convertForDataTest, EMPTY_STRING, TechSharedModule } from '@alfa-client/tech-shared'; 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 { cva, VariantProps } from 'class-variance-authority'; import { ErrorMessageComponent } from '../error-message/error-message.component'; 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: { variant: { @@ -28,23 +28,40 @@ type TextInputVariants = VariantProps<typeof textInputVariants>; standalone: true, imports: [CommonModule, ErrorMessageComponent, ReactiveFormsModule, TechSharedModule], template: ` - <div> + <div class="relative"> <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> </label> <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 type="text" [id]="id" [formControl]="fieldControl" - [ngClass]="textInputVariants({ variant })" + [ngClass]="[ + textInputVariants({ variant }), + withPrefix ? 'pl-10' : '', + withSuffix ? 'pr-10' : '', + ]" [placeholder]="placeholder" [autocomplete]="autocomplete" [attr.aria-required]="required" [attr.aria-invalid]="variant === 'error'" [attr.data-test-id]="(inputLabel | convertForDataTest) + '-text-input'" + (click)="clickEmitter.emit()" #inputElement /> + <div + *ngIf="withSuffix" + class="absolute bottom-2 right-2 flex size-6 items-center justify-center" + > + <ng-content select="[suffix]" /> + </div> </div> <ng-content select="[error]"></ng-content> </div> @@ -60,8 +77,10 @@ export class TextInputComponent { @Input() placeholder: string = ''; @Input() autocomplete: string = 'off'; @Input() variant: TextInputVariants['variant']; - @Input() fieldControl: FormControl; + @Input() fieldControl: FormControl = new FormControl(EMPTY_STRING); @Input() required: boolean = false; + @Input() withPrefix: boolean = false; + @Input() withSuffix: boolean = false; @Input() set focus(value: boolean) { if (value && this.inputElement) { @@ -69,6 +88,8 @@ export class TextInputComponent { } } + @Output() clickEmitter: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + inputLabel: string; id: string; textInputVariants = textInputVariants; diff --git a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts index 90a4fa85f7dcb0b5a55f6a926a0d08ab63e332a9..da75590e40ed4afedee224ff82f89a741c9d5bef 100644 --- a/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts +++ b/alfa-client/libs/design-system/src/lib/form/textarea/textarea.component.ts @@ -1,4 +1,4 @@ -import { TechSharedModule, convertForDataTest } from '@alfa-client/tech-shared'; +import { EMPTY_STRING, TechSharedModule, convertForDataTest } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { Component, ElementRef, Input, ViewChild } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; @@ -59,7 +59,7 @@ export class TextareaComponent { @Input() rows: number = 3; @Input() autocomplete: string = 'off'; @Input() variant: TextareaVariants['variant']; - @Input() fieldControl: FormControl; + @Input() fieldControl: FormControl = new FormControl(EMPTY_STRING); @Input() required: boolean = false; @Input() set focus(value: boolean) { diff --git a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts index 5de688d73e98d67b1296d1519332fd7e3b7c9660..48fe0de3b897881d8bb669774572ea8f68a6895e 100644 --- a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts +++ b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.component.ts @@ -12,16 +12,16 @@ import { IconVariants, iconVariants } from '../iconVariants'; xmlns="http://www.w3.org/2000/svg" [ngClass]="[twMerge(iconVariants({ size }), 'fill-black', class)]" aria-hidden="true" - viewBox="0 0 14 14" + viewBox="0 0 24 24" fill="inherit" > <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>`, }) export class CloseIconComponent { - @Input() size: IconVariants['size'] = 'small'; + @Input() size: IconVariants['size'] = 'medium'; @Input() class: string = undefined; iconVariants = iconVariants; diff --git a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts index b8a8eda0b8afab576ba5b7086d226aa2f0adcc96..9cd750050413bd481bcdc246810c8eeb24b53c8c 100644 --- a/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts +++ b/alfa-client/libs/design-system/src/lib/icons/close-icon/close-icon.stories.ts @@ -20,7 +20,7 @@ export const Default: Story = { options: ['small', 'medium', 'large', 'extra-large', 'full'], description: 'Size of icon. Property "full" means 100%', table: { - defaultValue: { summary: 'small' }, + defaultValue: { summary: 'medium' }, }, }, }, diff --git a/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.spec.ts b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5981e81db28b29d1dfe651b306e3f3f938ec0bfb --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.spec.ts @@ -0,0 +1,21 @@ +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(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.ts b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..641713c3842c42cadb4d4075051fc51141d5f2e3 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.component.ts @@ -0,0 +1,28 @@ +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; +} diff --git a/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.stories.ts b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..1952260187ba8fc7f462803ad2e2674f6d071856 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/icons/search-icon/search-icon.stories.ts @@ -0,0 +1,27 @@ +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' }, + }, + }, + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f900a81ba73a568c8be41ba225930acfc7d687a0 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.spec.ts @@ -0,0 +1,609 @@ +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(); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..c16d9fab5189ca71bb978839e0b602d114d35882 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.component.ts @@ -0,0 +1,224 @@ +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(); + } +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..5debae8ceb40be63d765fbb9a401cb47d601e5c5 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.model.ts @@ -0,0 +1,9 @@ +export interface InstantSearchResult<T> { + title: string; + description: string; + data?: T; +} + +export interface InstantSearchQuery { + searchBy: string; +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.stories.ts b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfe67d23232b417e6cddd3b33f53245a793b161e --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/instant-search/instant-search.stories.ts @@ -0,0 +1,40 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { InstantSearchComponent } from './instant-search.component'; + +const meta: Meta<InstantSearchComponent> = { + title: 'Instant search/Instant search', + component: InstantSearchComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<InstantSearchComponent>; + +export const Default: Story = { + args: { + label: '', + placeholder: 'zuständige Stelle suchen', + headerText: 'In der OZG-Cloud', + }, +}; + +export const SearchResults: Story = { + args: { + label: '', + placeholder: 'zuständige Stelle suchen', + headerText: 'In der OZG-Cloud', + searchResults: [ + { + text: 'Landeshauptstadt Kiel - Ordnungsamt, Gewerbe- und Schornsteinfegeraufsicht', + subText: 'Fabrikstraße 8-10, 24103 Kiel', + onClick: () => undefined, + }, + { + text: 'Amt für Digitalisierung, Breitband und Vermessung Nürnberg Außenstelle Hersbruck', + subText: 'Rathausmarkt 7, Hersbruck', + onClick: () => undefined, + }, + ], + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1f730f80e1b6cf82b90568b16df0ffd8b3a76bd --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.spec.ts @@ -0,0 +1,47 @@ +import { EMPTY_STRING } from '@alfa-client/tech-shared'; +import { getElementFromFixtureByType, mock } from '@alfa-client/test-utils'; +import { EventEmitter } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { TextInputComponent } from '../../form/text-input/text-input.component'; +import { SearchFieldComponent } from './search-field.component'; + +describe('SearchFieldComponent', () => { + let component: SearchFieldComponent; + let fixture: ComponentFixture<SearchFieldComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchFieldComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('inputClicked', () => { + it('should emit event', () => { + component.inputClicked = <any>mock(EventEmitter); + const input = getElementFromFixtureByType(fixture, TextInputComponent); + + input.inputElement.nativeElement.click(); + + expect(component.inputClicked.emit).toHaveBeenCalled(); + }); + }); + + describe('clearValue', () => { + it('should set empty value', () => { + component.control = new FormControl('test'); + + component.clearInput(); + + expect(component.control.value).toBe(EMPTY_STRING); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f2e01bd046af2d15ce345fed69c9a9f43122cc6 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.component.ts @@ -0,0 +1,38 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { EMPTY_STRING } from '../../../../../tech-shared/src'; +import { TextInputComponent } from '../../form/text-input/text-input.component'; +import { CloseIconComponent } from '../../icons/close-icon/close-icon.component'; +import { SearchIconComponent } from '../../icons/search-icon/search-icon.component'; + +@Component({ + selector: 'ods-search-field', + standalone: true, + imports: [CommonModule, TextInputComponent, SearchIconComponent, CloseIconComponent], + template: `<ods-text-input + [label]="label" + [fieldControl]="control" + [placeholder]="placeholder" + [withPrefix]="true" + [withSuffix]="true" + (clickEmitter)="inputClicked.emit()" + role="combobox" + > + <ods-search-icon prefix aria-hidden="true" aria-label="Suchfeld" /> + <button suffix *ngIf="control.value" (click)="clearInput()" aria-label="Eingabe löschen"> + <ods-close-icon class="fill-primary hover:fill-primary-hover" /> + </button> + </ods-text-input>`, +}) +export class SearchFieldComponent { + @Input() label: string = EMPTY_STRING; + @Input() placeholder: string = EMPTY_STRING; + @Input() control = new FormControl(EMPTY_STRING); + + @Output() inputClicked: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + + clearInput() { + this.control.setValue(EMPTY_STRING); + } +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.stories.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..fec43fa8f65cd51c47e4639fd002a34e33c8b119 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-field/search-field.stories.ts @@ -0,0 +1,19 @@ +import { type Meta, type StoryObj } from '@storybook/angular'; +import { SearchFieldComponent } from './search-field.component'; + +const meta: Meta<SearchFieldComponent> = { + title: 'Instant search/Search field', + component: SearchFieldComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj<SearchFieldComponent>; + +export const Default: Story = { + args: { + label: '', + placeholder: 'search something...', + }, +}; diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c00b9a408879b4ce0c96c2a374edce5df6739c2 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SearchResultHeaderComponent } from './search-result-header.component'; + +describe('SearchResultHeaderComponent', () => { + let component: SearchResultHeaderComponent; + let fixture: ComponentFixture<SearchResultHeaderComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultHeaderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ba8d7894dbddd157532a1418f175e28fa25c3b0 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-header/search-result-header.component.ts @@ -0,0 +1,17 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ods-search-result-header', + standalone: true, + imports: [CommonModule], + template: ` + <h3 class="mx-6 my-3 w-fit border-b-2 border-primary py-1 text-sm font-semibold text-text"> + {{ text }} ({{ count }}) + </h3> + `, +}) +export class SearchResultHeaderComponent { + @Input({ required: true }) text!: string; + @Input() count: number = 0; +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..089692b213692a024ccf7d6dc07071bd9c25d812 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.spec.ts @@ -0,0 +1,46 @@ +import { getElementFromFixture, mock } from '@alfa-client/test-utils'; +import { EventEmitter } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { getDataTestIdOf } from 'libs/tech-shared/test/data-test'; +import { SearchResultItemComponent } from './search-result-item.component'; + +describe('SearchResultItemComponent', () => { + let component: SearchResultItemComponent; + let fixture: ComponentFixture<SearchResultItemComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultItemComponent); + component = fixture.componentInstance; + component.title = 'Test'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('itemClicked', () => { + it('should emit event', () => { + component.itemClicked = <any>mock(EventEmitter); + const button = getElementFromFixture(fixture, getDataTestIdOf('item-button')); + + button.click(); + + expect(component.itemClicked.emit).toHaveBeenCalled(); + }); + }); + + describe('setFocus', () => { + it('should focus native element', () => { + component.buttonRef.nativeElement.focus = jest.fn(); + + component.setFocus(); + + expect(component.buttonRef.nativeElement.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..e02da6ddc31ea1fda0572f40be876b6a1969012b --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-item/search-result-item.component.ts @@ -0,0 +1,38 @@ +import { CommonModule } from '@angular/common'; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; + +@Component({ + selector: 'ods-search-result-item', + standalone: true, + imports: [CommonModule], + template: `<button + *ngIf="title" + [ngClass]="[ + 'flex w-full justify-between border-2 border-transparent px-6 py-3', + 'hover:border-focus focus:border-focus focus:outline-none', + ]" + role="listitem" + tabindex="-1" + (click)="itemClicked.emit()" + data-test-id="item-button" + #button + > + <div class="flex flex-col items-start justify-between text-text"> + <p class="text-base font-medium">{{ title }}</p> + <p class="text-sm">{{ description }}</p> + </div> + <ng-content select="[action-button]" /> + </button>`, +}) +export class SearchResultItemComponent { + @Input({ required: true }) title!: string; + @Input() description: string = ''; + + @Output() public itemClicked: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>(); + + @ViewChild('button') buttonRef: ElementRef; + + public setFocus() { + this.buttonRef.nativeElement.focus(); + } +} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.spec.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2c91333316ff8253ec828a228ce83b84514113c --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SearchResultLayerComponent } from './search-result-layer.component'; + +describe('SearchResultLayerComponent', () => { + let component: SearchResultLayerComponent; + let fixture: ComponentFixture<SearchResultLayerComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultLayerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultLayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..971e06b585e2292b36be429b6972ba91090ccaa0 --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.component.ts @@ -0,0 +1,15 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'ods-search-result-layer', + standalone: true, + imports: [CommonModule], + template: `<div class="rounded-lg border border-primary-600/50 bg-background-50 shadow-lg"> + <ng-content select="[header]" /> + <ul role="list"> + <ng-content /> + </ul> + </div>`, +}) +export class SearchResultLayerComponent {} diff --git a/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.stories.ts b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..02aa94c67050ca8ec652784afb653f56d596454b --- /dev/null +++ b/alfa-client/libs/design-system/src/lib/instant-search/search-result-layer/search-result-layer.stories.ts @@ -0,0 +1,30 @@ +import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular'; +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.component'; + +const meta: Meta<SearchResultLayerComponent> = { + title: 'Instant search/Search result layer', + component: SearchResultLayerComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [SearchResultItemComponent, SearchResultHeaderComponent], + }), + ], +}; + +export default meta; +type Story = StoryObj<SearchResultLayerComponent>; + +export const Default: Story = { + args: {}, + render: () => ({ + template: `<ods-search-result-layer> + <ods-search-result-header text="In der OZG-Cloud" [count]="2" header /> + <ods-search-result-item text="Amt Rantznau - Ordnungsamt" subText="Rathausmarkt 7, Kronshagen" /> + <ods-search-result-item text="Amt Burg-St. Michaelisdonn - Der Amtsvorsteher" subText="Holzmarkt 7, 25712 Rantznau" /> + </ods-search-result-layer>`, + }), +}; diff --git a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html index ad1704f9c19a82c2a3fe30cb866eb98730f4ff69..22c3e14619ff5c4636838a058b8e614684b64a83 100644 --- a/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html +++ b/alfa-client/libs/vorgang-detail/src/lib/vorgang-detail-page/vorgang-detail-bescheiden/vorgang-detail-bescheiden-steps/vorgang-detail-bescheiden-steps-content/vorgang-detail-bescheiden-antrag-bescheiden/vorgang-detail-bescheiden-antrag-bescheiden.component.html @@ -14,7 +14,7 @@ value="false" data-test-id="button-abgelehnt" variant="bescheid_abgelehnt" - ><ods-close-icon size="medium" class="fill-abgelehnt"></ods-close-icon> + ><ods-close-icon size="large" class="fill-abgelehnt"></ods-close-icon> </ods-radio-button-card> </div> <div class="flex w-full">