Skip to content

Commit

Permalink
Merge pull request #278 from thekhegay/feat-dropdown
Browse files Browse the repository at this point in the history
feat: wr-dropdown component
  • Loading branch information
thekhegay authored Feb 3, 2025
2 parents 9f2e081 + 8bb9811 commit 775cf58
Show file tree
Hide file tree
Showing 19 changed files with 930 additions and 0 deletions.
8 changes: 8 additions & 0 deletions projects/lib/dropdown/dropdown-menu-item.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<div class="wr-dropdown-item__content">
@if (icon()) {
<wr-icon class="wr-dropdown-item__icon" [name]="icon()!" />
}
<span class="wr-dropdown-item__label">
<ng-content></ng-content>
</span>
</div>
53 changes: 53 additions & 0 deletions projects/lib/dropdown/dropdown-menu-item.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
*/

import {
Component,
ChangeDetectionStrategy,
ViewEncapsulation,
input,
HostBinding,
HostListener,
booleanAttribute,
} from '@angular/core';

import { WrIconComponent, wrIconName } from 'ngwr/icon';

@Component({
selector: 'wr-dropdown-menu-item',
templateUrl: './dropdown-menu-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [WrIconComponent],
})
export class WrDropdownMenuItemComponent {
icon = input<wrIconName | null>(null);
disabled = input(false, { transform: booleanAttribute });

@HostBinding('class')
get hostClasses(): Record<string, boolean> {
return {
'wr-dropdown-item': true,
'wr-dropdown-item--disabled': this.disabled(),
};
}

@HostBinding('attr.role')
attrRole = 'menuitem';

@HostBinding('attr.tabindex')
tabindex = this.disabled() ? '' : '0';

@HostListener('click', ['$event'])
protected onClick(event: MouseEvent): void {
if (this.disabled()) {
event.preventDefault();
event.stopPropagation();
}
}
}
5 changes: 5 additions & 0 deletions projects/lib/dropdown/dropdown-menu.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<ng-template #contentTpl>
<div class="wr-dropdown-menu">
<ng-content />
</div>
</ng-template>
35 changes: 35 additions & 0 deletions projects/lib/dropdown/dropdown-menu.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
*/

import {
Component,
ChangeDetectionStrategy,
ViewEncapsulation,
ViewChild,
TemplateRef,
HostBinding,
} from '@angular/core';

@Component({
selector: 'wr-dropdown-menu',
templateUrl: './dropdown-menu.component.html',
exportAs: 'wrDropdownMenu',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
})
export class WrDropdownMenuComponent {
@HostBinding('style')
get hostClasses(): Record<string, string> {
return {
display: 'none',
};
}

@ViewChild('contentTpl')
contentTpl!: TemplateRef<void>;
}
196 changes: 196 additions & 0 deletions projects/lib/dropdown/dropdown.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
*/

import { Overlay, OverlayRef, ScrollStrategyOptions } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import {
Directive,
ElementRef,
inject,
input,
OnDestroy,
ViewContainerRef,
effect,
signal,
DestroyRef,
HostListener,
HostBinding,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

import { filter, fromEvent, merge } from 'rxjs';

import { WrDropdownMenuComponent } from './dropdown-menu.component';
import { WR_DROPDOWN_POSITIONS, WrDropdownTrigger } from './dropdown.types';

@Directive({
selector: '[wrDropdown]',
standalone: true,
})
export class WrDropdownDirective implements OnDestroy {
dropdownMenu = input<WrDropdownMenuComponent>();
trigger = input<WrDropdownTrigger>('click');
position = input<keyof typeof WR_DROPDOWN_POSITIONS>('bottomLeft');

private readonly isOpen = signal(false);
private overlayRef?: OverlayRef;
private clickTimeStamp = 0;

private readonly overlay = inject(Overlay);
private readonly elementRef = inject(ElementRef);
private readonly viewContainerRef = inject(ViewContainerRef);
private readonly scrollStrategyOptions = inject(ScrollStrategyOptions);
private readonly destroyRef = inject(DestroyRef);

@HostBinding('class')
get hostClasses(): Record<string, boolean> {
return {
'wr-dropdown-trigger': true,
};
}

constructor() {
const isOpen = this.isOpen;

effect(() => {
if (isOpen()) {
this.open();
} else {
this.close();
}
});
}

ngOnDestroy(): void {
this.destroyOverlay();
}

@HostListener('click', ['$event'])
protected onClick(event: MouseEvent): void {
if (this.trigger() !== 'click') {
return;
}

event.stopPropagation();

if (event.timeStamp === this.clickTimeStamp) {
return;
}

this.isOpen.update(value => !value);
}

@HostListener('mouseenter')
protected onMouseEnter(): void {
if (this.trigger() !== 'hover') {
return;
}

this.isOpen.set(true);
}

private open(): void {
if (this.overlayRef || !this.dropdownMenu()) {
return;
}

const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions(WR_DROPDOWN_POSITIONS[this.position()])
.withPush(true);

this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.scrollStrategyOptions.reposition(),
panelClass: ['wr-dropdown-overlay', `wr-dropdown--${this.position()}`],
});

const portal = new TemplatePortal(this.dropdownMenu()!.contentTpl, this.viewContainerRef);

this.overlayRef.attach(portal);

if (this.trigger() === 'hover') {
this.setupHoverEvents();
} else {
this.setupClickEvents();
}

this.overlayRef
.keydownEvents()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(event => {
if (event.key === 'Escape') {
this.clickTimeStamp = event.timeStamp;
this.isOpen.set(false);
}
});

setTimeout(() => {
this.overlayRef?.updatePosition();
});
}

private setupClickEvents(): void {
this.overlayRef!.outsidePointerEvents()
.pipe(
filter(event => !this.elementRef.nativeElement.contains(event.target)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(event => {
this.clickTimeStamp = event.timeStamp;
this.isOpen.set(false);
});
}

private setupHoverEvents(): void {
const overlayElement = this.overlayRef!.overlayElement;
const triggerElement = this.elementRef.nativeElement;

const triggerLeave$ = fromEvent<MouseEvent>(triggerElement, 'mouseleave').pipe(
filter(event => {
const relatedTarget = event.relatedTarget as Element;
return !overlayElement.contains(relatedTarget) && relatedTarget !== overlayElement;
})
);

const overlayLeave$ = fromEvent<MouseEvent>(overlayElement, 'mouseleave').pipe(
filter(event => {
const relatedTarget = event.relatedTarget as Element;
return !triggerElement.contains(relatedTarget) && relatedTarget !== triggerElement;
})
);

merge(triggerLeave$, overlayLeave$)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
if (!triggerElement.matches(':hover') && !overlayElement.matches(':hover')) {
this.isOpen.set(false);
}
});

fromEvent(overlayElement, 'mouseenter')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.isOpen.set(true);
});
}

private close(): void {
if (this.overlayRef) {
this.overlayRef.detach();
this.destroyOverlay();
}
}

private destroyOverlay(): void {
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = undefined;
}
}
}
77 changes: 77 additions & 0 deletions projects/lib/dropdown/dropdown.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
*/

import { ConnectedPosition } from '@angular/cdk/overlay';

export type WrDropdownTrigger = 'click' | 'hover';

export const WR_DROPDOWN_POSITIONS: Record<string, ConnectedPosition[]> = {
top: [
{
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
},
],
topLeft: [
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
},
],
topRight: [
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
},
],
bottom: [
{
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
},
],
bottomLeft: [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
},
],
bottomRight: [
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
},
],
left: [
{
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
},
],
right: [
{
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
},
],
};
8 changes: 8 additions & 0 deletions projects/lib/dropdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
*/

export * from './public-api';
5 changes: 5 additions & 0 deletions projects/lib/dropdown/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "public-api.ts"
}
}
Loading

0 comments on commit 775cf58

Please sign in to comment.