Refactor menu components and styles for improved organization.

- 🛠️ **Refactor**: Removed old menu-button component and styles
-  **Feature**: Added new input-menu and filter-menu components
- 🎨 **Style**: Updated styles for input-menu and filter-menu components
- 🗑️ **Chore**: Cleaned up unused input-button component files
This commit is contained in:
Lorenz Hilpert
2025-04-02 21:06:11 +02:00
parent eb0a0d3dc3
commit 0dee30062f
37 changed files with 557 additions and 493 deletions

View File

@@ -21,10 +21,7 @@
<div class="flex flex-row gap-4">
@for (filterInput of filterInputs(); track filterInput.key) {
<filter-input-button
[appearance]="'main'"
[filterInput]="filterInput"
(applyFilter)="onSearch()"
></filter-input-button>
<filter-input-menu-button [filterInput]="filterInput" (applied)="onSearch()">
</filter-input-menu-button>
}
</div>

View File

@@ -1,19 +1,11 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
ReturnSearchStatus,
ReturnSearchStore,
} from '@feature/return/services';
import { ReturnSearchStatus, ReturnSearchStore } from '@feature/return/services';
import { injectActivatedProcessId } from '@isa/core/process';
import {
FilterService,
SearchBarInputComponent,
InputButtonComponent,
FilterInputMenuButtonComponent,
} from '@isa/shared/filter';
import { IconButtonComponent } from '@isa/ui/buttons';
@@ -23,7 +15,7 @@ import { IconButtonComponent } from '@isa/ui/buttons';
styleUrls: ['./main-page.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SearchBarInputComponent, IconButtonComponent, InputButtonComponent],
imports: [SearchBarInputComponent, IconButtonComponent, FilterInputMenuButtonComponent],
})
export class MainPageComponent {
#route = inject(ActivatedRoute);

View File

@@ -7,15 +7,13 @@
></filter-search-bar-input>
<div class="flex flex-row gap-4 items-center">
<filter-menu-button
[appearance]="'results'"
(applyFilter)="onSearch()"
></filter-menu-button>
<filter-filter-menu-button
(applied)="onSearch()"
[rollbackOnClose]="true"
></filter-filter-menu-button>
@if (isMobileDevice()) {
<ui-icon-button
(click)="toggleOrderByToolbar.set(!toggleOrderByToolbar())"
>
<ui-icon-button (click)="toggleOrderByToolbar.set(!toggleOrderByToolbar())">
<ng-icon name="isaActionSort"></ng-icon>
</ui-icon-button>
} @else {
@@ -38,18 +36,12 @@
@for (item of items; track item.id) {
@defer (on viewport) {
<a [routerLink]="['../', 'receipt', item.id]" class="w-full">
<lib-return-results-item-list
#listElement
[item]="item"
></lib-return-results-item-list>
<lib-return-results-item-list #listElement [item]="item"></lib-return-results-item-list>
</a>
} @placeholder {
<!-- TODO: Den Spinner durch Skeleton Loader Kacheln ersetzen -->
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
></ui-icon-button>
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
</div>
}
}
@@ -59,16 +51,11 @@
</div>
}
</div>
} @else if (
items.length === 0 && entityStatus() === ReturnSearchStatus.Pending
) {
} @else if (items.length === 0 && entityStatus() === ReturnSearchStatus.Pending) {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
</div>
} @else if (entityStatus() !== ReturnSearchStatus.Idle) {
<ui-empty-state
[title]="emptyState().title"
[description]="emptyState().description"
>
<ui-empty-state [title]="emptyState().title" [description]="emptyState().description">
</ui-empty-state>
}

View File

@@ -97,8 +97,7 @@ export class ResultsPageComponent {
};
});
listElements =
viewChildren<QueryList<ReturnResultsItemListComponent>>('listElement');
listElements = viewChildren<QueryList<ReturnResultsItemListComponent>>('listElement');
isMobileDevice = signal(this.#platform.ANDROID || this.#platform.IOS);
toggleOrderByToolbar = signal(false);
@@ -112,8 +111,7 @@ export class ResultsPageComponent {
if (processId) {
const entity = this._entity();
if (entity) {
const isPending =
this.entityStatus() === ReturnSearchStatus.Pending;
const isPending = this.entityStatus() === ReturnSearchStatus.Pending;
// Trigger reload search if no search request is already pending and
// 1. List scrolled to bottom
// 2. After Process change AND no items in entity

View File

@@ -2,5 +2,5 @@ export * from './lib/core';
export * from './lib/inputs';
export * from './lib/types';
export * from './lib/actions';
export * from './lib/input-button';
export * from './lib/menu-button';
export * from './lib/menus/filter-menu';
export * from './lib/menus/input-menu';

View File

@@ -0,0 +1,81 @@
import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest';
import { FilterActionsComponent } from './filter-actions.component';
import { FilterService } from '../core';
describe('FilterActionsComponent', () => {
let spectator: Spectator<FilterActionsComponent>;
const createComponent = createComponentFactory({
component: FilterActionsComponent,
providers: [
mockProvider(FilterService, {
inputs: jest.fn().mockReturnValue([
{ group: 'filter', key: 'key1' },
{ group: 'other', key: 'key2' },
]),
commit: jest.fn(),
commitInput: jest.fn(),
reset: jest.fn(),
resetInput: jest.fn(),
}),
],
});
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should filter inputs by group "filter"', () => {
const filteredInputs = spectator.component.filterInputs();
expect(filteredInputs).toEqual([{ group: 'filter', key: 'key1' }]);
});
it('should call commit and emit applied when onApply is called without inputKey', () => {
const filterService = spectator.inject(FilterService);
const appliedSpy = jest.spyOn(spectator.component.applied, 'emit');
spectator.setInput('inputKey', null);
spectator.component.onApply();
expect(filterService.commit).toHaveBeenCalled();
expect(appliedSpy).toHaveBeenCalled();
});
it('should call commitInput and emit applied when onApply is called with inputKey', () => {
const filterService = spectator.inject(FilterService);
const appliedSpy = jest.spyOn(spectator.component.applied, 'emit');
spectator.setInput('inputKey', 'key1');
spectator.component.onApply();
expect(filterService.commitInput).toHaveBeenCalledWith('key1');
expect(appliedSpy).toHaveBeenCalled();
});
it('should call reset, commit, and emit reseted when onReset is called without inputKey', () => {
const filterService = spectator.inject(FilterService);
const resetedSpy = jest.spyOn(spectator.component.reseted, 'emit');
spectator.setInput('inputKey', null);
spectator.component.onReset();
expect(filterService.reset).toHaveBeenCalled();
expect(filterService.commit).toHaveBeenCalled();
expect(resetedSpy).toHaveBeenCalled();
});
it('should call resetInput, commit, and emit reseted when onReset is called with inputKey', () => {
const filterService = spectator.inject(FilterService);
const resetedSpy = jest.spyOn(spectator.component.reseted, 'emit');
spectator.setInput('inputKey', 'key1');
spectator.component.onReset();
expect(filterService.resetInput).toHaveBeenCalledWith('key1');
expect(filterService.commit).toHaveBeenCalled();
expect(resetedSpy).toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@ import {
output,
computed,
ViewEncapsulation,
input,
} from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { FilterService } from '../core';
@@ -24,22 +25,38 @@ import { FilterService } from '../core';
export class FilterActionsComponent {
readonly filterService = inject(FilterService);
inputKey = input<string>();
filterInputs = computed(() =>
this.filterService.inputs().filter((input) => input.group === 'filter'),
);
applyFilter = output<void>();
applied = output<void>();
reseted = output<void>();
onApply() {
this.filterService.commit();
this.applyFilter.emit();
const inputKey = this.inputKey();
if (!inputKey) {
this.filterService.commit();
} else {
this.filterService.commitInput(inputKey);
}
this.applied.emit();
}
onReset() {
for (const input of this.filterInputs()) {
this.filterService.resetInput(input.key, { commit: true });
const inputKey = this.inputKey();
if (!inputKey) {
this.filterService.reset();
} else {
this.filterService.resetInput(inputKey);
}
// TODO: Falls man auch die Suche zurücksetzen möchte
// this.filterService.reset({ commit: true });
this.filterService.commit();
this.reseted.emit();
}
}

View File

@@ -3,13 +3,9 @@ import { InputType, QuerySettingsDTO } from '../types';
import { getState, patchState, signalState } from '@ngrx/signals';
import { mapToFilter } from './mappings';
export const QUERY_SETTINGS = new InjectionToken<QuerySettingsDTO>(
'QuerySettings',
);
export const QUERY_SETTINGS = new InjectionToken<QuerySettingsDTO>('QuerySettings');
export function provideQuerySettings(
factory: () => QuerySettingsDTO,
): Provider[] {
export function provideQuerySettings(factory: () => QuerySettingsDTO): Provider[] {
return [{ provide: QUERY_SETTINGS, useFactory: factory }, FilterService];
}
@@ -25,11 +21,7 @@ export class FilterService {
inputs = this.#state.inputs;
setInputTextValue(
key: string,
value: string | undefined,
options?: { commit: boolean },
): void {
setInputTextValue(key: string, value: string | undefined, options?: { commit: boolean }): void {
const inputs = this.#state.inputs().map((input) => {
if (input.key !== key) {
return input;
@@ -51,11 +43,7 @@ export class FilterService {
}
}
setInputCheckboxValue(
key: string,
selected: string[],
options?: { commit: boolean },
): void {
setInputCheckboxValue(key: string, selected: string[], options?: { commit: boolean }): void {
const inputs = this.#state.inputs().map((input) => {
if (input.key !== key) {
return input;
@@ -131,9 +119,7 @@ export class FilterService {
return;
}
const inputIndex = this.#commitdState.inputs.findIndex(
(i) => i.key === key,
);
const inputIndex = this.#commitdState.inputs.findIndex((i) => i.key === key);
if (inputIndex === -1) {
console.warn(`No committed input found with key: ${key}`);
@@ -148,6 +134,26 @@ export class FilterService {
};
}
clear(options?: { commit: boolean }) {
const inputs = this.#state.inputs().map((input) => {
if (input.type === InputType.Text) {
return { ...input, value: undefined };
}
if (input.type === InputType.Checkbox) {
return { ...input, selected: [] };
}
return input;
});
patchState(this.#state, { inputs });
if (options?.commit) {
this.commit();
}
}
/**
* Resets the filter state to its default values based on the current settings.
*
@@ -226,10 +232,7 @@ export class FilterService {
return params;
}
parseParams(
params: Record<string, string>,
options?: { commit: boolean },
): void {
parseParams(params: Record<string, string>, options?: { commit: boolean }): void {
// Mögliche Alternative zum reset
// const inputKeys = this.inputs().map((i) => i.key);

View File

@@ -1 +0,0 @@
export * from './input-button.component';

View File

@@ -1,42 +0,0 @@
<button
class="filter-input-button__filter-button"
[class.open]="!!activeInput()"
(click)="toggleActiveInput()"
type="button"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
>
<span class="filter-input-button__filter-button-label">{{
filterInput().label
}}</span>
<ng-icon
class="filter-input-button__filter-button-icon"
[name]="!!activeInput() ? 'isaActionChevronUp' : 'isaActionChevronDown'"
size="1.5rem"
>
</ng-icon>
</button>
@if (appearance() === 'main') {
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="!!activeInput()"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
(backdropClick)="toggleActiveInput()"
>
<div class="relative flex flex-col items-center">
<filter-input-renderer
[filterInput]="activeInput()"
></filter-input-renderer>
<filter-actions
class="absolute bottom-0 w-full"
(applyFilter)="applyFilter.emit(); toggleActiveInput()"
>
</filter-actions>
</div>
</ng-template>
}

View File

@@ -1,31 +0,0 @@
.filter-input-button__main {
.filter-input-button__filter-button {
@apply flex flex-row gap-2 items-center justify-center px-6 h-12 bg-isa-neutral-400 rounded-[3.125rem] border border-solid border-transparent;
.filter-input-button__filter-button-label {
@apply text-isa-neutral-600 isa-text-body-2-bold;
}
.filter-input-button__filter-button-icon {
@apply text-isa-accent-blue;
}
&.open {
@apply bg-transparent border border-solid border-isa-neutral-900;
.filter-input-button__filter-button-label {
@apply text-isa-neutral-900;
}
.filter-input-button__filter-button-icon {
@apply text-isa-neutral-900;
}
}
}
}
.filter-input-button__results {
.filter-input-button__filter-button {
@apply flex flex-row;
}
}

View File

@@ -1,63 +0,0 @@
import { OverlayModule } from '@angular/cdk/overlay';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
model,
inject,
ViewEncapsulation,
output,
} from '@angular/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { FilterInput, FilterService } from '../core';
import { FilterActionsComponent } from '../actions';
import { InputRendererComponent } from '../inputs';
@Component({
selector: 'filter-input-button',
templateUrl: 'input-button.component.html',
styleUrls: ['./input-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
OverlayModule,
NgIconComponent,
InputRendererComponent,
FilterActionsComponent,
],
host: {
'[class]': "['filter-input-button', appearanceClass()]",
},
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class InputButtonComponent {
appearance = input<'main' | 'results'>('main');
appearanceClass = computed(() => `filter-input-button__${this.appearance()}`);
filterInput = input.required<FilterInput>();
activeInput = model<FilterInput | undefined>(undefined);
applyFilter = output<void>();
#filterService = inject(FilterService);
toggleActiveInput() {
const activeInput = this.activeInput();
if (activeInput) {
this.rollbackInputAfterClose(activeInput);
this.activeInput.set(undefined);
} else {
this.activeInput.set(this.filterInput());
}
}
rollbackInputAfterClose(activeInput: FilterInput) {
const key = activeInput.key;
if (key) {
this.#filterService.rollbackInput(key);
}
}
}

View File

@@ -1,39 +1,29 @@
@let inp = input();
@let options = input().options;
@if (inp && options) {
<div [formGroup]="checkboxes" class="filter-checkbox-input__container">
<div class="filter-checkbox-input__checkbox-container">
<ui-checkbox [appearance]="appearance()">
<input
(click)="toggleSelection()"
id="selection"
[checked]="allChecked"
type="checkbox"
/>
<div [formGroup]="checkboxes" class="flex flex-col items-center justify-start gap-5">
<label
class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-regular has-[:checked]:isa-text-body-2-bold"
>
<ui-checkbox>
<input (click)="toggleSelection()" [checked]="allChecked" type="checkbox" />
</ui-checkbox>
<label class="filter-checkbox-input__label checked" for="selection">
Alle aus/abwählen</label
>
</div>
<span> Alle aus/abwählen</span>
</label>
@for (checkbox of options; track checkbox.label; let i = $index) {
<div class="filter-checkbox-input__checkbox-container">
<ui-checkbox [appearance]="appearance()">
@for (option of options; track option.label; let i = $index) {
<label
class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-regular has-[:checked]:isa-text-body-2-bold"
>
<ui-checkbox>
<input
[attr.aria-label]="checkbox.label"
[id]="checkbox.value"
[formControlName]="checkbox.value"
[attr.aria-label]="option.label"
[formControlName]="option.value"
type="checkbox"
/>
</ui-checkbox>
<label
class="filter-checkbox-input__label"
[class.checked]="checkboxes.get(checkbox.value).value"
[for]="checkbox.value"
>
{{ checkbox.label }}</label
>
</div>
<span>{{ option.label }}</span>
</label>
}
</div>
}

View File

@@ -1,19 +1,3 @@
.filter-checkbox-input {
@apply h-[28.75rem] w-[15rem] flex flex-col items-center justify-start gap-4 px-6 pt-6 bg-isa-white rounded-[1.25rem] shadow-[0px_0px_16px_0px_rgba(0,0,0,0.15)] overflow-y-scroll;
}
.filter-checkbox-input__container {
@apply flex flex-col gap-5 w-full overflow-hidden overflow-y-scroll max-h-[21rem] pb-4;
}
.filter-checkbox-input__checkbox-container {
@apply flex flex-row items-center w-full;
}
.filter-checkbox-input__label {
@apply text-isa-neutral-900 isa-text-body-2-regular px-4 cursor-pointer;
&.checked {
@apply isa-text-body-2-bold;
}
@apply inline-block p-6 text-isa-neutral-900;
}

View File

@@ -11,13 +11,9 @@ import {
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { provideIcons } from '@ng-icons/core';
import { isaActionCheck } from '@isa/icons';
import {
FilterService,
CheckboxFilterInput,
CheckboxFilterInputOption,
} from '../../core';
import { FilterService, CheckboxFilterInput, CheckboxFilterInputOption } from '../../core';
import { InputType } from '../../types';
import { CheckboxAppearance, CheckboxComponent } from '@isa/ui/input-controls';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { OverlayModule } from '@angular/cdk/overlay';
import { toSignal } from '@angular/core/rxjs-interop';
import { sortBy, isEqual } from 'lodash';
@@ -31,7 +27,8 @@ import { sortBy, isEqual } from 'lodash';
standalone: true,
imports: [ReactiveFormsModule, CheckboxComponent, OverlayModule],
host: {
'[class]': "['filter-checkbox-input', appearanceClass()]",
'[class]': "['filter-checkbox-input']",
'[formGroup]': 'checkboxes',
},
providers: [provideIcons({ isaActionCheck })],
})
@@ -46,17 +43,11 @@ export class CheckboxInputComponent {
}
inputKey = input.required<string>();
appearance = input<CheckboxAppearance>(CheckboxAppearance.Checkbox);
appearanceClass = computed(
() => `filter-checkbox-input__${this.appearance()}`,
);
input = computed<CheckboxFilterInput>(() => {
const inputs = this.filterService.inputs();
const input = inputs.find(
(input) =>
input.key === this.inputKey() && input.type === InputType.Checkbox,
(input) => input.key === this.inputKey() && input.type === InputType.Checkbox,
) as CheckboxFilterInput;
if (!input) {
@@ -94,16 +85,10 @@ export class CheckboxInputComponent {
.filter(([, value]) => value === true)
.map(([key]) => key);
const controlEqualsInput = isEqual(
sortBy(this.input().selected),
sortBy(selectedKeys),
);
const controlEqualsInput = isEqual(sortBy(this.input().selected), sortBy(selectedKeys));
if (!controlEqualsInput) {
this.filterService.setInputCheckboxValue(
this.inputKey(),
selectedKeys,
);
this.filterService.setInputCheckboxValue(this.inputKey(), selectedKeys);
}
});
});

View File

@@ -1,10 +1,13 @@
@switch (filterInput().type) {
@case (InputType.Checkbox) {
<filter-checkbox-input [inputKey]="filterInput().key">
</filter-checkbox-input>
<filter-checkbox-input [inputKey]="filterInput().key"> </filter-checkbox-input>
}
@case (InputType.DateRange) {
<filter-datepicker-input [inputKey]="filterInput().key">
</filter-datepicker-input>
<filter-datepicker-input [inputKey]="filterInput().key"> </filter-datepicker-input>
}
@default {
<div class="text-isa-accent-red isa-text-body-1-bold">
Fehler: Kein Template für diesen Typ gefunden! {{ filterInput().type }}
</div>
}
}

View File

@@ -1 +0,0 @@
export * from './menu-button.component';

View File

@@ -1,25 +0,0 @@
<ui-icon-button
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
(click)="isOpen.set(!isOpen())"
>
<ng-icon name="isaActionFilter"></ng-icon>
</ui-icon-button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
(backdropClick)="closeOverlay()"
>
<filter-menu
[appearance]="appearance()"
(activeInputChange)="activeFilterInput.set($event)"
></filter-menu>
<filter-actions (applyFilter)="applyFilter.emit(); closeOverlay()">
</filter-actions>
</ng-template>

View File

@@ -1,67 +0,0 @@
import {
ChangeDetectionStrategy,
input,
signal,
computed,
Component,
ViewEncapsulation,
inject,
output,
} from '@angular/core';
import { FilterInput, FilterService } from '../core';
import { FilterActionsComponent } from '../actions';
import { IconButtonComponent } from '@isa/ui/buttons';
import { OverlayModule } from '@angular/cdk/overlay';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionFilter } from '@isa/icons';
import { FilterMenuComponent } from './menu/menu.component';
@Component({
selector: 'filter-menu-button',
templateUrl: 'menu-button.component.html',
styleUrls: ['./menu-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
FilterActionsComponent,
IconButtonComponent,
OverlayModule,
NgIconComponent,
FilterMenuComponent,
],
host: {
'[class]': "['filter-menu-button', appearanceClass()]",
},
providers: [provideIcons({ isaActionFilter })],
})
export class FilterMenuButtonComponent {
appearance = input<'main' | 'results'>('main');
appearanceClass = computed(() => `filter-menu-button__${this.appearance()}`);
#filterService = inject(FilterService);
filterInputs = computed(() =>
this.#filterService.inputs().filter((input) => input.group === 'filter'),
);
activeFilterInput = signal<FilterInput | undefined>(undefined);
applyFilter = output<void>();
isOpen = signal<boolean>(false);
rollbackInputAfterClose() {
const activeInput = this.activeFilterInput();
if (activeInput) {
const key = activeInput.key;
if (key) {
this.#filterService.rollbackInput(key);
this.activeFilterInput.set(undefined);
}
}
}
closeOverlay() {
this.rollbackInputAfterClose();
this.isOpen.set(false);
}
}

View File

@@ -1,37 +0,0 @@
@if (!activeInput()) {
<button (click)="rollbackAllFilterInputs()" type="button">
<span class="lib-return-filter__filter-button-label"> Alle abwählen </span>
</button>
<div class="flex flex-col">
@for (filterInput of filterInputs(); track filterInput.key) {
<filter-input-button
[appearance]="appearance()"
[filterInput]="filterInput"
(activeInputChange)="toggleActiveInput($event)"
></filter-input-button>
}
</div>
} @else {
<div class="flex flex-col">
<button
class="lib-return-filter__filter-button"
(click)="toggleActiveInput(undefined)"
type="button"
>
<ng-icon
class="lib-return-filter__filter-button-icon"
name="isaActionChevronUp"
size="1.5rem"
>
</ng-icon>
<span class="lib-return-filter__filter-button-label">{{
activeInput().label
}}</span>
</button>
<filter-input-renderer
[filterInput]="activeInput()"
></filter-input-renderer>
</div>
}

View File

@@ -1,70 +0,0 @@
import {
ChangeDetectionStrategy,
input,
computed,
Component,
ViewEncapsulation,
inject,
model,
} from '@angular/core';
import { FilterInput, FilterService } from '../../core';
import { OverlayModule } from '@angular/cdk/overlay';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronUp } from '@isa/icons';
import { InputButtonComponent } from '../../input-button';
import { InputRendererComponent } from '../../inputs';
@Component({
selector: 'filter-menu',
templateUrl: 'menu.component.html',
styleUrls: ['./menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
InputButtonComponent,
InputRendererComponent,
OverlayModule,
NgIconComponent,
],
host: {
'[class]': "['filter-menu', appearanceClass()]",
},
providers: [provideIcons({ isaActionChevronUp })],
})
export class FilterMenuComponent {
appearance = input<'main' | 'results'>('main');
appearanceClass = computed(() => `filter-menu__${this.appearance()}`);
#filterService = inject(FilterService);
activeInput = model<FilterInput | undefined>(undefined);
filterInputs = computed(() =>
this.#filterService.inputs().filter((input) => input.group === 'filter'),
);
toggleActiveInput(newActiveInput: FilterInput | undefined) {
if (newActiveInput) {
this.activeInput.set(newActiveInput);
} else {
this.rollbackInputAfterClose();
this.activeInput.set(undefined);
}
}
rollbackInputAfterClose() {
const activeInput = this.activeInput();
if (activeInput) {
const key = activeInput.key;
if (key) {
this.#filterService.rollbackInput(key);
}
}
}
rollbackAllFilterInputs() {
for (const input of this.filterInputs()) {
this.#filterService.rollbackInput(input.key);
}
}
}

View File

@@ -0,0 +1,16 @@
<ui-icon-button cdkOverlayOrigin #trigger="cdkOverlayOrigin" (click)="toggle()">
<ng-icon name="isaActionFilter"></ng-icon>
</ui-icon-button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
(backdropClick)="toggle()"
>
<filter-filter-menu (applied)="applied.emit()" (reseted)="reseted.emit()"></filter-filter-menu>
</ng-template>

View File

@@ -0,0 +1,87 @@
import { OverlayModule } from '@angular/cdk/overlay';
import { ChangeDetectionStrategy, Component, inject, input, model, output } from '@angular/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { FilterMenuComponent } from './filter-menu.component';
import { isaActionFilter } from '@isa/icons';
import { FilterService } from '../../core';
/**
* A button component that toggles the visibility of a filter menu.
* It emits events when the menu is opened, closed, reset, or applied.
*/
@Component({
selector: 'filter-filter-menu-button',
templateUrl: './filter-menu-button.component.html',
styleUrls: ['./filter-menu-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [IconButtonComponent, OverlayModule, NgIconComponent, FilterMenuComponent],
providers: [provideIcons({ isaActionFilter })],
})
export class FilterMenuButtonComponent {
#filter = inject(FilterService);
/**
* Tracks the open state of the filter menu.
*/
open = model<boolean>(false);
/**
* Emits an event when the filter menu is closed.
*/
closed = output<void>();
/**
* Emits an event when the filter menu is opened.
*/
opened = output<void>();
/**
* Emits an event when the filter menu is reset.
*/
reseted = output<void>();
/**
* Emits an event when the filter menu is applied.
*/
applied = output<void>();
/**
* Determines whether to roll back changes when the menu is closed.
*/
rollbackOnClose = input<boolean>(false);
/**
* Toggles the open state of the filter menu.
* Emits `opened` or `closed` events based on the new state.
*/
toggle() {
const open = this.open();
this.open.set(!open);
if (open) {
this.closed.emit();
} else {
this.opened.emit();
}
}
constructor() {
/**
* Subscribes to the `closed` event to roll back changes if `rollbackOnClose` is true.
*/
this.closed.subscribe(() => {
if (this.rollbackOnClose()) {
this.#filter.rollback();
}
});
/**
* Subscribes to the `applied` event to automatically close the menu.
*/
this.applied.subscribe(() => {
this.toggle();
});
}
}

View File

@@ -0,0 +1,38 @@
@if (!activeInput()) {
<button
class="rounded-t-[1.25rem] px-6 py-5 border-b border-isa-neutral-300"
(click)="filter.clear()"
type="button"
>
<span class="isa-text-body-2-bold text-isa-neutral-500"> Alle abwählen </span>
</button>
@for (filterInput of filterInputs(); track filterInput.key) {
<button
type="button"
class="px-6 py-5 border-b border-isa-neutral-300 inline-flex items-center gap-2 justify-between"
(click)="activeInput.set(filterInput)"
>
<span class="text-isa-neutral-900 isa-text-body-2-regular">
{{ filterInput.label }}
</span>
<ng-icon class="text-isa-neutral-900" name="isaActionChevronRight" size="1.5rem"></ng-icon>
</button>
}
} @else {
@let input = activeInput();
<button
class="rounded-t-[1.25rem] px-6 py-5 border-b border-isa-neutral-300"
(click)="filter.rollbackInput(input!.key); activeInput.set(undefined)"
type="button"
>
<span class="isa-text-body-2-bold text-isa-neutral-500"> {{ input!.label }} </span>
</button>
<filter-input-renderer class="overflow-scroll" [filterInput]="input!"></filter-input-renderer>
}
<filter-actions
[inputKey]="activeInput()?.key"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-actions>

View File

@@ -0,0 +1,7 @@
:host {
@apply grid grid-flow-row;
@apply bg-isa-white;
@apply rounded-[1.25rem];
@apply w-[14.3125rem] max-h-[33.5rem];
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
}

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, computed, Component, inject, model, output } from '@angular/core';
import { FilterInput, FilterService } from '../../core';
import { OverlayModule } from '@angular/cdk/overlay';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft, isaActionChevronRight } from '@isa/icons';
import { InputRendererComponent } from '../../inputs';
import { FilterActionsComponent } from '../../actions';
/**
* A component that renders a filter menu with input fields and actions.
* It allows users to reset or apply filters and manages the active filter input.
*/
@Component({
selector: 'filter-filter-menu',
templateUrl: 'filter-menu.component.html',
styleUrls: ['./filter-menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [InputRendererComponent, OverlayModule, NgIconComponent, FilterActionsComponent],
providers: [provideIcons({ isaActionChevronLeft, isaActionChevronRight })],
})
export class FilterMenuComponent {
/**
* The filter service used to manage filter inputs and their states.
*/
filter = inject(FilterService);
/**
* Tracks the currently active filter input in the menu.
*/
activeInput = model<FilterInput | undefined>(undefined);
/**
* A computed list of filter inputs belonging to the 'filter' group.
*/
filterInputs = computed(() => this.filter.inputs().filter((input) => input.group === 'filter'));
/**
* Emits an event when the filter inputs are reset.
*/
reseted = output<void>();
/**
* Emits an event when the filter inputs are applied.
*/
applied = output<void>();
}

View File

@@ -0,0 +1,2 @@
export * from './filter-menu-button.component';
export * from './filter-menu.component';

View File

@@ -0,0 +1,2 @@
export * from './input-menu-button.component';
export * from './input-menu.component';

View File

@@ -0,0 +1,34 @@
@let input = filterInput();
<button
class="filter-input-button__filter-button"
[class.open]="open()"
(click)="toggle()"
type="button"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
>
<span class="filter-input-button__filter-button-label">{{ input.label }}</span>
<ng-icon
class="filter-input-button__filter-button-icon"
[name]="open() ? 'isaActionChevronUp' : 'isaActionChevronDown'"
size="1.5rem"
>
</ng-icon>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
(backdropClick)="toggle()"
>
<filter-input-menu
[filterInput]="input"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-input-menu>
</ng-template>

View File

@@ -0,0 +1,23 @@
.filter-input-button__filter-button {
@apply flex flex-row gap-2 items-center justify-center px-6 h-12 bg-isa-neutral-400 rounded-[3.125rem] border border-solid border-transparent;
.filter-input-button__filter-button-label {
@apply text-isa-neutral-600 isa-text-body-2-bold;
}
.filter-input-button__filter-button-icon {
@apply text-isa-accent-blue;
}
&.open {
@apply bg-transparent border border-solid border-isa-neutral-900;
.filter-input-button__filter-button-label {
@apply text-isa-neutral-900;
}
.filter-input-button__filter-button-icon {
@apply text-isa-neutral-900;
}
}
}

View File

@@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core';
import { FilterInput } from '../../core';
import { OverlayModule } from '@angular/cdk/overlay';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import { FilterInputMenuComponent } from './input-menu.component';
/**
* A button component that toggles the visibility of an input menu for filtering.
* It emits events when the menu is opened, closed, reset, or applied.
*/
@Component({
selector: 'filter-input-menu-button',
templateUrl: './input-menu-button.component.html',
styleUrls: ['./input-menu-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [OverlayModule, NgIconComponent, FilterInputMenuComponent],
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class FilterInputMenuButtonComponent {
/**
* Tracks the open state of the input menu.
*/
open = model<boolean>(false);
/**
* The filter input configuration used to render the input menu.
*/
filterInput = input.required<FilterInput>();
/**
* Emits an event when the input menu is closed.
*/
closed = output<void>();
/**
* Emits an event when the input menu is opened.
*/
opened = output<void>();
/**
* Emits an event when the input menu is reset.
*/
reseted = output<void>();
/**
* Emits an event when the input menu is applied.
*/
applied = output<void>();
/**
* Subscribes to the `applied` event to automatically close the menu.
*/
constructor() {
this.applied.subscribe(() => {
this.toggle();
});
}
/**
* Toggles the open state of the input menu.
* Emits `opened` or `closed` events based on the new state.
*/
toggle() {
const open = this.open();
this.open.set(!open);
if (open) {
this.closed.emit();
} else {
this.opened.emit();
}
}
}

View File

@@ -0,0 +1,9 @@
<filter-input-renderer
class="overflow-scroll"
[filterInput]="filterInput()"
></filter-input-renderer>
<filter-actions
[inputKey]="filterInput().key"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-actions>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row bg-isa-white rounded-[1.25rem] shadow-[0px,0px,16px,0px,rgba(0,0,0,0.15)] max-h-[32.3rem];
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { FilterInput } from '../../core';
import { FilterActionsComponent } from '../../actions';
import { InputRendererComponent } from '../../inputs/input-renderer';
/**
* A component that renders a menu for filter input.
* It provides actions to reset or apply the filter.
*/
@Component({
selector: 'filter-input-menu',
templateUrl: './input-menu.component.html',
styleUrls: ['./input-menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [FilterActionsComponent, InputRendererComponent],
})
export class FilterInputMenuComponent {
/**
* The filter input configuration used to render the input menu.
*/
filterInput = input.required<FilterInput>();
/**
* Emits an event when the filter input is reset.
*/
reseted = output<void>();
/**
* Emits an event when the filter input is applied.
*/
applied = output<void>();
}

View File

@@ -86,7 +86,6 @@
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
"@swc/helpers": "~0.5.11",
"@types/jasmine": "~5.1.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.16",
"@types/node": "18.16.9",
@@ -98,20 +97,10 @@
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.2.3",
"husky": "^9.1.7",
"jasmine-core": "~5.4.0",
"jasmine-marbles": "^0.9.2",
"jasmine-spec-reporter": "~7.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0",
"jest-preset-angular": "~14.4.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "^2.2.1",
"karma-coverage-istanbul-reporter": "~3.0.3",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"karma-junit-reporter": "~2.0.1",
"ng-mocks": "^14.13.4",
"ng-swagger-gen": "^2.3.1",
"nx": "20.4.6",