Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions libs/design-system/search-list/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
1 change: 1 addition & 0 deletions libs/design-system/search-list/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/search-list.component';
30 changes: 30 additions & 0 deletions libs/design-system/search-list/src/lib/search-list.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
@if (showSearch()) {
<mat-form-field hraFeature="search" hraClickEvent class="search field" subscriptSizing="dynamic">
<mat-label class="search-label">Search</mat-label>
<mat-icon class="search-icon" matPrefix>search</mat-icon>
<input matInput type="text" [formControl]="searchControl" />
</mat-form-field>
}

<ng-scrollbar hraScrollOverflowFade class="filter-options">
<mat-selection-list [disableRipple]="disableRipple()" (selectionChange)="handleSelectionChange($event)">
@for (option of filteredOptions(); track option.id) {
<mat-list-option
togglePosition="before"
[hraFeature]="option.label | slugify"
[value]="option"
[selected]="selectedOptions().includes(option)"
>
<mat-label class="labels-count">
<span class="option-labels">
<div class="option-primary-label">{{ option.label }}</div>
@if (option.secondaryLabel) {
<div class="option-secondary-label">{{ option.secondaryLabel }}</div>
}
</span>
<span class="option-count">{{ option.count | number }}</span>
</mat-label>
</mat-list-option>
}
</mat-selection-list>
</ng-scrollbar>
69 changes: 69 additions & 0 deletions libs/design-system/search-list/src/lib/search-list.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
@use '@angular/material' as mat;

@use '../../../styles/utils';
@use '../../../styles/vars';

:host {
@include mat.list-overrides(
(
list-item-one-line-container-height: 2.5rem,
)
);

@include mat.form-field-overrides(
(
container-vertical-padding: 13.5px,
)
);

display: flex;
flex-direction: column;
width: 19.5rem;
max-height: 22.25rem;
background-color: vars.$surface-container-low;
border-radius: 0.5rem;
box-shadow: 0px 5px 4px 0px utils.with-alpha(vars.$on-background, 16%);

.search {
width: 100%;
padding: 1rem 1rem 0rem 1rem;

.search-label {
font: vars.$label-medium;
}
}

.filter-options {
margin: 0.5rem 0;
}

.labels-count {
display: flex;
justify-content: space-between;
align-items: center;
height: 2.5rem;
gap: 0.75rem;
font: vars.$label-medium;
}

.option-labels {
display: flex;
flex-direction: column;
width: 0;
flex-grow: 1;
}

.option-primary-label {
overflow: hidden;
text-overflow: ellipsis;
}

.option-secondary-label {
font: vars.$label-small;
}

.option-secondary-label,
.option-count {
color: vars.$primary;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { type Meta, type StoryObj } from '@storybook/angular';

import { SearchListComponent, FilterMenuOption } from './search-list.component';

const FILTER_OPTIONS = [
{ id: 'a', label: 'A', count: 9999 },
{ id: 'ab', label: 'AB', count: 4299 },
{ id: 'abc', label: 'ABC', count: 1799 },
{ id: 'abcd', label: 'ABCD', count: 899 },
{ id: 'abcde', label: 'ABCDE', count: 499 },
{ id: 'abcdef', label: 'ABCDEF', count: 299 },
{ id: 'abcdefg', label: 'ABCDEFG', count: 199 },
{ id: 'abcdefgh', label: 'BACDEFGH', count: 99 },
] as FilterMenuOption[];

const FILTER_OPTIONS_MULTI = [
{ id: 'a', label: 'A', secondaryLabel: 'short description', count: 9999 },
{ id: 'ab', label: 'AB', secondaryLabel: 'short description', count: 4299 },
{ id: 'abc', label: 'ABC', secondaryLabel: 'short description', count: 1799 },
{ id: 'abcd', label: 'ABCD', secondaryLabel: 'short description', count: 899 },
{ id: 'abcde', label: 'ABCDE', secondaryLabel: 'short description', count: 499 },
{ id: 'abcdef', label: 'ABCDEF', secondaryLabel: 'short description', count: 299 },
{ id: 'abcdefg', label: 'ABCDEFG', secondaryLabel: 'short description', count: 199 },
{ id: 'abcdefgh', label: 'BACDEFGH', secondaryLabel: 'short description', count: 99 },
] as FilterMenuOption[];

const meta: Meta<SearchListComponent> = {
component: SearchListComponent,
title: 'Design System / Search List',
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/design/gQEMLugLjweDvbsNNUVffD/HRA-Design-System-Repository?node-id=12492-44301&t=P7zcWFRIyuDoIlRW-4',
},
},
args: {
currentFilters: ['a', 'abc', 'abcde'],
showSearch: true,
disableRipple: false,
},
};

export default meta;
type Story = StoryObj<SearchListComponent>;

export const Default: Story = {
args: {
filterOptions: FILTER_OPTIONS,
},
};

export const MultiLine: Story = {
args: {
filterOptions: FILTER_OPTIONS_MULTI,
},
};
108 changes: 108 additions & 0 deletions libs/design-system/search-list/src/lib/search-list.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatAutocompleteModule } from '@angular/material/autocomplete';

import { HraCommonModule } from '@hra-ui/common';
import { ButtonsModule } from '@hra-ui/design-system/buttons';
import { IconsModule } from '@hra-ui/design-system/icons';
import { ScrollingModule, ScrollOverflowFadeDirective } from '@hra-ui/design-system/scrolling';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatListModule, MatSelectionListChange } from '@angular/material/list';

/** Filter menu option interface */
export interface FilterMenuOption {
/** Option id */
id: string;
/** Option label */
label: string;
/** Secondary label */
secondaryLabel?: string;
/** Number of results for the filter option in the data */
count: number;
}

/**
* Keyboard-accessible filter list flyout menu with an optional search text field with autocomplete
*/
@Component({
selector: 'hra-search-list',
imports: [
HraCommonModule,
IconsModule,
ButtonsModule,
ScrollingModule,
ScrollOverflowFadeDirective,
MatAutocompleteModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
MatListModule,
],
templateUrl: './search-list.component.html',
styleUrl: './search-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchListComponent {
/** Filter search form control */
readonly searchControl = new FormControl();

/** Whether to show the autocomplete search bar */
readonly showSearch = input<boolean>(true);

/** Whether to disable the ripple effect for list items */
readonly disableRipple = input<boolean>(false);

/** All filter options */
readonly filterOptions = input.required<FilterMenuOption[]>();

/** Currently selected filter IDs */
readonly currentFilters = input<string[] | undefined>();

/** Currently selected options */
readonly selectedOptions = signal<FilterMenuOption[]>([]);

/** Current search bar value */
readonly searchValue = signal<string>('');

/** Filtered options (after typing in search bar) */
readonly filteredOptions = computed(() => {
if (this.searchValue() !== '') {
return (
this.filterOptions()?.filter((option) =>
option.label.toLowerCase().includes(this.searchValue().toLowerCase()),
) || []
);
}
return this.filterOptions();
});

/** Emits currently selected filter options on change */
readonly filtersChanged = output<FilterMenuOption[]>();

/**
* Sets options in the filter menu and subscribes to searchbar inputs
*/
constructor() {
effect(() => {
const selectedFilterOptions =
this.filterOptions()?.filter((option) => this.currentFilters()?.includes(option.id)) || []; //filter options remaining with current filters applied
this.selectedOptions.set(selectedFilterOptions); //set the currently selected options to the filtered options
});

this.searchControl.valueChanges.subscribe(() => {
this.searchValue.set(this.searchControl.value);
});
}

/**
* Handles selection changes in the filter menu
* @param event Selection change event
*/
handleSelectionChange(event: MatSelectionListChange): void {
const selectedOptions = event.source.selectedOptions.selected.map((option) => option.value);
this.selectedOptions.set(selectedOptions);
this.filtersChanged.emit(this.selectedOptions());
}
}