mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
feat: add dropdown component with appearance options and styles
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
export * from './lib/checkbox/checkbox.component';
|
||||
export * from './lib/dropdown/dropdown.component';
|
||||
export * from './lib/dropdown/dropdown.types';
|
||||
export * from './lib/text-field/text-field.component';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<span>{{ viewLabel() }}</span>
|
||||
<ng-icon [name]="isOpenIcon()"></ng-icon>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="cdkOverlayOrigin"
|
||||
[cdkConnectedOverlayOpen]="isOpen()"
|
||||
[cdkConnectedOverlayOffsetY]="12"
|
||||
(detach)="isOpen.set(false)"
|
||||
>
|
||||
<ul [class]="['ui-dorpdown__options', optionAppearanceClass()]" role="listbox">
|
||||
<ng-content></ng-content>
|
||||
</ul>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,76 @@
|
||||
.ui-dropdown.ui-dropdown__button {
|
||||
display: inline-flex;
|
||||
height: 3rem;
|
||||
padding: 0rem 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
|
||||
border-radius: 3.125rem;
|
||||
@apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue;
|
||||
|
||||
ng-icon {
|
||||
@apply size-6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-100 border-isa-secondary-700;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@apply border-isa-accent-blue;
|
||||
}
|
||||
|
||||
&.open {
|
||||
@apply border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-dorpdown__options {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 1.25rem;
|
||||
background: var(--Neutral-White, #fff);
|
||||
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.ui-dropdown-option {
|
||||
display: flex;
|
||||
width: 10rem;
|
||||
height: 3rem;
|
||||
padding: 0rem 1.5rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
word-wrap: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
@apply isa-text-body-2-bold;
|
||||
|
||||
&.active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
@apply bg-isa-neutral-200;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
@apply text-isa-accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
&.ui-dropdown-option__options-text {
|
||||
.ui-dropdown-option {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
&.ui-dropdown-option__options-number {
|
||||
.ui-dropdown-option {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
207
libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts
Normal file
207
libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
contentChildren,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
|
||||
import { ControlValueAccessor } from '@angular/forms';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
|
||||
import { isEqual } from 'lodash';
|
||||
import { DropdownAppearance, DropdownOptionAppearance } from './dropdown.types';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-dropdown-option',
|
||||
template: '<ng-content></ng-content>',
|
||||
host: {
|
||||
'[class]': '["ui-dropdown-option", activeClass(), selectedClass()]',
|
||||
'role': 'option',
|
||||
'[attr.aria-selected]': 'selected()',
|
||||
'[attr.tabindex]': '-1',
|
||||
'(click)': 'select()',
|
||||
},
|
||||
})
|
||||
export class DropdownOptionComponent<T> implements Highlightable {
|
||||
private host = inject(DropdownButtonComponent<T>);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
active = signal(false);
|
||||
|
||||
activeClass = computed(() => (this.active() ? 'active' : ''));
|
||||
|
||||
setActiveStyles(): void {
|
||||
this.active.set(true);
|
||||
}
|
||||
|
||||
setInactiveStyles(): void {
|
||||
this.active.set(false);
|
||||
}
|
||||
|
||||
getLabel(): string {
|
||||
return this.elementRef.nativeElement.textContent.trim();
|
||||
}
|
||||
|
||||
selected = computed(() => {
|
||||
const hostValue = this.host.value();
|
||||
const value = this.value();
|
||||
return hostValue === value || isEqual(hostValue, value);
|
||||
});
|
||||
|
||||
selectedClass = computed(() => (this.selected() ? 'selected' : ''));
|
||||
|
||||
value = input.required<T>();
|
||||
|
||||
select() {
|
||||
this.host.select(this);
|
||||
this.host.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ui-dropdown',
|
||||
templateUrl: './dropdown.component.html',
|
||||
styleUrls: ['./dropdown.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
hostDirectives: [CdkOverlayOrigin],
|
||||
imports: [NgIconComponent, CdkConnectedOverlay],
|
||||
providers: [provideIcons({ isaActionChevronUp, isaActionChevronDown })],
|
||||
host: {
|
||||
'[class]': '["ui-dropdown", appearanceClass(), isOpenClass()]',
|
||||
'role': 'combobox',
|
||||
'aria-haspopup': 'listbox',
|
||||
'[attr.id]': 'id()',
|
||||
'[attr.tabindex]': 'disabled() ? -1 : tabIndex()',
|
||||
'aria-expanded': 'isOpen()',
|
||||
'(keydown)': 'keyManger?.onKeydown($event)',
|
||||
'(keydown.enter)': 'select(keyManger.activeItem); close()',
|
||||
'(keydown.escape)': 'close()',
|
||||
'(click)': 'isOpen() ? close() : open()',
|
||||
},
|
||||
})
|
||||
export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterViewInit {
|
||||
readonly init = signal(false);
|
||||
|
||||
appearance = input<DropdownAppearance>(DropdownAppearance.Button);
|
||||
|
||||
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
|
||||
|
||||
optionAppearance = input<DropdownOptionAppearance>(DropdownOptionAppearance.Text);
|
||||
|
||||
optionAppearanceClass = computed(() => `ui-dropdown-option__options-${this.optionAppearance()}`);
|
||||
|
||||
id = input<string>();
|
||||
|
||||
value = model<T>();
|
||||
|
||||
tabIndex = input<number>(0);
|
||||
|
||||
label = input<string>();
|
||||
|
||||
disabled = model<boolean>(false);
|
||||
|
||||
options = contentChildren(DropdownOptionComponent);
|
||||
|
||||
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
|
||||
|
||||
private selectionModel = new SelectionModel<DropdownOptionComponent<T>>(false);
|
||||
|
||||
selectionChanged = toSignal(this.selectionModel.changed);
|
||||
|
||||
selected = computed(() => {
|
||||
this.selectionChanged();
|
||||
return this.selectionModel.selected[0];
|
||||
});
|
||||
|
||||
private keyManger?: ActiveDescendantKeyManager<DropdownOptionComponent<T>>;
|
||||
|
||||
onChange?: (value: T) => void;
|
||||
|
||||
onTouched?: () => void;
|
||||
|
||||
isOpen = signal(false);
|
||||
|
||||
isOpenClass = computed(() => (this.isOpen() ? 'open' : ''));
|
||||
|
||||
isOpenIcon = computed(() => (this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown'));
|
||||
|
||||
viewLabel = computed(() => {
|
||||
if (!this.selected()) {
|
||||
return this.label();
|
||||
}
|
||||
|
||||
return this.selected().getLabel() ?? this.value();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.init()) {
|
||||
return;
|
||||
}
|
||||
this.keyManger?.destroy();
|
||||
this.keyManger = new ActiveDescendantKeyManager<DropdownOptionComponent<T>>(
|
||||
this.options(),
|
||||
).withWrap();
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
const selected = this.selected();
|
||||
if (selected) {
|
||||
this.keyManger?.setActiveItem(selected);
|
||||
} else {
|
||||
this.keyManger?.setFirstItemActive();
|
||||
}
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen.set(false);
|
||||
}
|
||||
|
||||
focusout() {
|
||||
// this.close();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.init.set(true);
|
||||
}
|
||||
|
||||
writeValue(obj: unknown): void {
|
||||
this.value.set(obj as T);
|
||||
}
|
||||
|
||||
registerOnChange(fn: unknown): void {
|
||||
this.onChange = fn as (value: T) => void;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: unknown): void {
|
||||
this.onTouched = fn as () => void;
|
||||
}
|
||||
|
||||
setDisabledState?(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
|
||||
select(option: DropdownOptionComponent<T>, options: { emit: boolean } = { emit: true }) {
|
||||
this.selectionModel.select(option);
|
||||
this.value.set(option.value());
|
||||
if (options.emit) {
|
||||
this.onChange?.(option.value());
|
||||
}
|
||||
this.onTouched?.();
|
||||
}
|
||||
}
|
||||
13
libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts
Normal file
13
libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const DropdownAppearance = {
|
||||
Button: 'button',
|
||||
} as const;
|
||||
|
||||
export type DropdownAppearance = (typeof DropdownAppearance)[keyof typeof DropdownAppearance];
|
||||
|
||||
export const DropdownOptionAppearance = {
|
||||
Text: 'text',
|
||||
Number: 'number',
|
||||
} as const;
|
||||
|
||||
export type DropdownOptionAppearance =
|
||||
(typeof DropdownOptionAppearance)[keyof typeof DropdownOptionAppearance];
|
||||
Reference in New Issue
Block a user