From b42194069b0a8f1ba80d96b8e165e21841b98527 Mon Sep 17 00:00:00 2001 From: Andreas Tennert Date: Fri, 9 Aug 2024 10:39:31 +0200 Subject: [PATCH] feat: support overlay containers in dialogs --- README.md | 13 ++++ apps/demo-app/src/app/app.config.ts | 2 + .../dialog-sample.component.html | 26 +++++++ .../dialog-sample/dialog-sample.component.ts | 11 ++- libs/sketch/src/index.ts | 2 + .../components/dialog/dialog.component.html | 1 + .../dialog/dialog.component.stories.ts | 7 +- .../lib/components/dialog/dialog.component.ts | 14 +++- .../components/overlay/overlay-container.ts | 20 ++++++ .../overlay.directive.ts | 71 ++++++++++++------- .../sketch/src/lib/components/select/index.ts | 1 - .../lib/components/select/select.component.ts | 5 +- libs/sketch/src/lib/sketch.module.ts | 13 ++++ 13 files changed, 145 insertions(+), 41 deletions(-) create mode 100644 libs/sketch/src/lib/components/overlay/overlay-container.ts rename libs/sketch/src/lib/components/{select/directives => overlay}/overlay.directive.ts (61%) create mode 100644 libs/sketch/src/lib/sketch.module.ts diff --git a/README.md b/README.md index ae34f42..2d97b98 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,19 @@ ngSketch is an open-source headless library for Angular 18+. It peppers your app Get inspired by opinionated demos and use the theme that fits your application. +## Use ngSketch + +In order for all features to work, you need to add the `SketchModule` to the `appConfig` of you application. + +```ts +export const appConfig: ApplicationConfig = { + providers: [ + // ... + importProvidersFrom(SketchModule), + ], +}; +``` + ## Storybook Deployed from main: https://661d194a3f6d14d86c328554-yfvvdffiwi.chromatic.com/ diff --git a/apps/demo-app/src/app/app.config.ts b/apps/demo-app/src/app/app.config.ts index f95c7ed..fc7be79 100644 --- a/apps/demo-app/src/app/app.config.ts +++ b/apps/demo-app/src/app/app.config.ts @@ -7,6 +7,7 @@ import { } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { SketchModule } from '@qupaya/sketch'; export const appConfig: ApplicationConfig = { providers: [ @@ -17,5 +18,6 @@ export const appConfig: ApplicationConfig = { ), provideAnimations(), importProvidersFrom(FontAwesomeModule), + importProvidersFrom(SketchModule), ], }; diff --git a/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.html b/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.html index 3341e1f..7604518 100644 --- a/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.html +++ b/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.html @@ -429,4 +429,30 @@ + +
+ + + +
+ Here is some Content. + + + Placeholder + Selected Item + + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + +
+
+
diff --git a/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.ts b/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.ts index 8b51fc6..7620038 100644 --- a/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.ts +++ b/apps/demo-app/src/app/pages/dialog-sample/dialog-sample.component.ts @@ -1,11 +1,15 @@ import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CloseButtonPosition, DialogComponent } from '@qupaya/sketch'; +import { + CloseButtonPosition, + DialogComponent, + SelectComponent, + SelectOptionComponent, +} from '@qupaya/sketch'; @Component({ selector: 'app-dialog-sample', standalone: true, - imports: [CommonModule, DialogComponent], + imports: [DialogComponent, SelectComponent, SelectOptionComponent], templateUrl: './dialog-sample.component.html', styleUrl: './dialog-sample.component.css', }) @@ -24,6 +28,7 @@ export class DialogSampleComponent { isDialogWithNestedDialogOpen = false; isNestedDialogOpen = false; isDefaultContentShadowOpen = false; + isDialogWithSelectOpen = false; readonly CloseButtonPosition = CloseButtonPosition; } diff --git a/libs/sketch/src/index.ts b/libs/sketch/src/index.ts index eff0b5c..d38e028 100644 --- a/libs/sketch/src/index.ts +++ b/libs/sketch/src/index.ts @@ -2,3 +2,5 @@ export * from './lib/components/sketch/sketch.component'; export * from './lib/components/select'; export * from './lib/components/list'; export * from './lib/components/dialog/dialog.component'; +export * from './lib/components/overlay/overlay.directive'; +export * from './lib/sketch.module'; diff --git a/libs/sketch/src/lib/components/dialog/dialog.component.html b/libs/sketch/src/lib/components/dialog/dialog.component.html index 5a6a1e6..2ecd968 100644 --- a/libs/sketch/src/lib/components/dialog/dialog.component.html +++ b/libs/sketch/src/lib/components/dialog/dialog.component.html @@ -34,4 +34,5 @@ +
diff --git a/libs/sketch/src/lib/components/dialog/dialog.component.stories.ts b/libs/sketch/src/lib/components/dialog/dialog.component.stories.ts index 3dcb058..c989711 100644 --- a/libs/sketch/src/lib/components/dialog/dialog.component.stories.ts +++ b/libs/sketch/src/lib/components/dialog/dialog.component.stories.ts @@ -1,5 +1,4 @@ import { ArgTypes, Meta, moduleMetadata, StoryObj } from '@storybook/angular'; -import { CommonModule } from '@angular/common'; import { CloseButtonPosition, DialogComponent } from './dialog.component'; const meta: Meta = { @@ -7,11 +6,7 @@ const meta: Meta = { tags: ['autodocs'], parameters: {}, title: 'DialogComponent', - decorators: [ - moduleMetadata({ - imports: [CommonModule], - }), - ], + decorators: [moduleMetadata({})], }; export default meta; type Story = StoryObj; diff --git a/libs/sketch/src/lib/components/dialog/dialog.component.ts b/libs/sketch/src/lib/components/dialog/dialog.component.ts index cfb7819..c676ce0 100644 --- a/libs/sketch/src/lib/components/dialog/dialog.component.ts +++ b/libs/sketch/src/lib/components/dialog/dialog.component.ts @@ -1,15 +1,17 @@ import { Component, - effect, input, output, viewChild, model, - untracked, ElementRef, + effect, + untracked, + inject, } from '@angular/core'; import { ClickBackdropDirective } from './directive/click-backdrop.directive'; import { NgClass } from '@angular/common'; +import { SkOverlayContainer } from '../overlay/overlay-container'; export enum CloseButtonPosition { Left = 'left', @@ -24,6 +26,11 @@ export enum CloseButtonPosition { styleUrl: './dialog.component.css', }) export class DialogComponent { + private readonly overlayContainer = inject(SkOverlayContainer); + private readonly dialogOverlayContainerRef = viewChild.required< + ElementRef + >('dialogOverlayContainer'); + readonly open = model(false); readonly showCloseButton = input(false); @@ -48,11 +55,14 @@ export class DialogComponent { protected readonly openEvents = effect( () => { const dialog = untracked(this.dialogElement); + const containerRef = untracked(this.dialogOverlayContainerRef); if (this.open()) { dialog.nativeElement.showModal(); + this.overlayContainer.addContainer(containerRef.nativeElement); } else { dialog.nativeElement.close(); + this.overlayContainer.removeContainer(); } }, { allowSignalWrites: true } diff --git a/libs/sketch/src/lib/components/overlay/overlay-container.ts b/libs/sketch/src/lib/components/overlay/overlay-container.ts new file mode 100644 index 0000000..2748ce4 --- /dev/null +++ b/libs/sketch/src/lib/components/overlay/overlay-container.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { OverlayContainer } from '@angular/cdk/overlay'; + +@Injectable({ + providedIn: 'root', +}) +export class SkOverlayContainer extends OverlayContainer { + private readonly containerStack: HTMLElement[] = []; + + addContainer(container: HTMLElement): void { + if (this._containerElement) { + this.containerStack.push(this._containerElement); + } + this._containerElement = container; + } + + removeContainer(): void { + this._containerElement = this.containerStack.pop() as HTMLElement; + } +} diff --git a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts b/libs/sketch/src/lib/components/overlay/overlay.directive.ts similarity index 61% rename from libs/sketch/src/lib/components/select/directives/overlay.directive.ts rename to libs/sketch/src/lib/components/overlay/overlay.directive.ts index 8db97e3..36fee86 100644 --- a/libs/sketch/src/lib/components/select/directives/overlay.directive.ts +++ b/libs/sketch/src/lib/components/overlay/overlay.directive.ts @@ -1,4 +1,8 @@ +import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { CdkPortal } from '@angular/cdk/portal'; +import { DOCUMENT } from '@angular/common'; import { + DestroyRef, Directive, effect, ElementRef, @@ -6,11 +10,8 @@ import { input, output, } from '@angular/core'; -import { CdkPortal } from '@angular/cdk/portal'; -import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay'; -import { DOCUMENT } from '@angular/common'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { debounceTime, fromEvent, merge } from 'rxjs'; -import { toSignal } from '@angular/core/rxjs-interop'; export const DEFAULT_POSITIONS: ConnectedPosition[] = [ { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, @@ -23,48 +24,54 @@ export const DEFAULT_POSITIONS: ConnectedPosition[] = [ export class CdkOverlayDirective { private readonly overlay = inject(Overlay); private readonly elementRef = inject(ElementRef); + private readonly destroyRef = inject(DestroyRef); private readonly window = inject(DOCUMENT)?.defaultView; private readonly windowResize = this.window ? toSignal(fromEvent(this.window, 'resize').pipe(debounceTime(500))) : undefined; - portal = input(undefined, { alias: 'skCdkOverlay' }); - showOverlay = input(false, { alias: 'skCdkOverlayShow' }); - connectedPositions = input(DEFAULT_POSITIONS, { + readonly portal = input(undefined, { + alias: 'skCdkOverlay', + }); + readonly showOverlay = input(false, { alias: 'skCdkOverlayShow' }); + readonly connectedPositions = input(DEFAULT_POSITIONS, { alias: 'skCdkOverlayPositions', }); - disposeDelay = input(0, { alias: 'skCdkOverlayDisposeDelay' }); - relativeTo = input(undefined, { + readonly disposeDelay = input(0, { alias: 'skCdkOverlayDisposeDelay' }); + readonly relativeTo = input(undefined, { alias: 'skCdkOverlayRelativeTo', }); - backdropClass = input('cdk-overlay-transparent-backdrop', { + readonly backdropClass = input('cdk-overlay-transparent-backdrop', { alias: 'skCdkOverlayBackdropClass', }); - panelClass = input('cdk-overlay-panel', { + readonly panelClass = input('cdk-overlay-panel', { alias: 'skCdkOverlayPanelClass', }); - offsetX = input(0, { + readonly offsetX = input(0, { alias: 'skCdkOverlayOffsetX', }); - offsetY = input(0, { + readonly offsetY = input(0, { alias: 'skCdkOverlayOffsetY', }); - visible = output({ alias: 'skCdkOverlayVisible' }); + readonly visible = output({ alias: 'skCdkOverlayVisible' }); private _overlayRef?: OverlayRef; private _relatedElement?: HTMLElement = this.relativeTo() || this.elementRef.nativeElement; - protected readonly detectVisibleChange = effect(() => { - if (this._relatedElement) { - if (this.showOverlay()) { - this.createOverlay(); - } else { - this.hide(); + protected readonly detectVisibleChange = effect( + () => { + if (this._relatedElement) { + if (this.showOverlay()) { + this.createOverlay(); + } else { + this.hide(); + } } - } - }); + }, + { allowSignalWrites: true } + ); protected readonly updateOverlayPortal = effect(() => { if (this.windowResize && this.windowResize()) { @@ -77,6 +84,10 @@ export class CdkOverlayDirective { return; } + if (this._overlayRef?.hasAttached()) { + return; + } + const positionStrategy = this.overlay .position() .flexibleConnectedTo(this._relatedElement) @@ -86,20 +97,28 @@ export class CdkOverlayDirective { .withDefaultOffsetY(this.offsetY()) .withFlexibleDimensions(false); + const scrollStrategy = this.overlay.scrollStrategies.reposition(); + this._overlayRef = this.overlay.create({ hasBackdrop: true, backdropClass: this.backdropClass(), panelClass: this.panelClass(), positionStrategy, + scrollStrategy, }); this.syncOverlayWidth(); + this._overlayRef + .attachments() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.visible.emit(true)); + + merge(this._overlayRef.backdropClick(), this._overlayRef.detachments()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.hide()); + this._overlayRef.attach(this.portal()); - merge( - this._overlayRef.backdropClick(), - this._overlayRef.detachments() - ).subscribe(() => this.hide()); } private hide(): void { diff --git a/libs/sketch/src/lib/components/select/index.ts b/libs/sketch/src/lib/components/select/index.ts index ad1b0c7..6b06198 100644 --- a/libs/sketch/src/lib/components/select/index.ts +++ b/libs/sketch/src/lib/components/select/index.ts @@ -1,3 +1,2 @@ export * from './components/select-option/select-option.component'; export * from './select.component'; -export * from './directives/overlay.directive'; diff --git a/libs/sketch/src/lib/components/select/select.component.ts b/libs/sketch/src/lib/components/select/select.component.ts index 9d2308f..78045c9 100644 --- a/libs/sketch/src/lib/components/select/select.component.ts +++ b/libs/sketch/src/lib/components/select/select.component.ts @@ -13,16 +13,15 @@ import { untracked, ViewEncapsulation, } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { CdkOverlayDirective } from './directives/overlay.directive'; import { CdkPortal } from '@angular/cdk/portal'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { CdkTrapFocus } from '@angular/cdk/a11y'; +import { CdkOverlayDirective } from '../overlay/overlay.directive'; @Component({ selector: 'sk-select', standalone: true, - imports: [CommonModule, CdkOverlayDirective, CdkPortal, CdkTrapFocus], + imports: [CdkOverlayDirective, CdkPortal, CdkTrapFocus], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/libs/sketch/src/lib/sketch.module.ts b/libs/sketch/src/lib/sketch.module.ts new file mode 100644 index 0000000..51886f7 --- /dev/null +++ b/libs/sketch/src/lib/sketch.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { SkOverlayContainer } from './components/overlay/overlay-container'; + +@NgModule({ + providers: [ + { + provide: OverlayContainer, + useExisting: SkOverlayContainer, + }, + ], +}) +export class SketchModule {}