spartan icon indicating copy to clipboard operation
spartan copied to clipboard

Dynamic component support for Sheet

Open hillin opened this issue 1 year ago • 1 comments

Which scope/s are relevant/related to the feature request?

sheet

Information

It would be great if we can use dynamic component in the sheet component, just like we can do in the dialog component - after all, sheet is just another form of dialog!

Describe any alternatives/workarounds you're currently using

No response

I would be willing to submit a PR to fix this issue

  • [x] Yes
  • [ ] No

hillin avatar Dec 21 '24 01:12 hillin

I've got a working implementation. Since I suppose there should be some magnitude of rework/refactor to the sheet component to be done in order to get this properly implemented, I'm posting the code here rather than creating a PR for now. Please feel free to grab my code, and let me know if I can help to put a PR together.

hlm-sheet-dynamic-content.component.ts

Ideally we should reuse the HlmSheetContentComponent, but it requires injection of ExposesStateProvider and ExposesSideProvider. Some refactors have to be done before we can reuse it. For now we just create a copy without the injections for dynamic use.

import { Component, ElementRef, Renderer2, computed, effect, inject, input } from '@angular/core';
import { lucideX } from '@ng-icons/lucide';
import { BrnSheetCloseDirective } from '@spartan-ng/brain/sheet';
import { hlm } from '@spartan-ng/ui-core';
import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm';
import type { ClassValue } from 'clsx';
import { HlmSheetCloseDirective } from './hlm-sheet-close.directive';
import { BrnDialogRef, injectBrnDialogContext } from '@spartan-ng/brain/dialog';
import { sheetVariants } from './hlm-sheet-content.component';
import { NgComponentOutlet } from '@angular/common';

@Component({
	selector: 'hlm-sheet-dynamic-content',
	standalone: true,
	imports: [NgComponentOutlet, HlmSheetCloseDirective, BrnSheetCloseDirective, HlmIconComponent],
	providers: [provideIcons({ lucideX })],
	host: {
		'[class]': '_computedClass()',
		'[attr.data-state]': 'state()',
	},
	template: `
		@if (component) {
			<ng-container [ngComponentOutlet]="component" />
		} @else {
			<ng-content />
		}
		<button brnSheetClose hlm>
			<span class="sr-only">Close</span>
			<hlm-icon class="flex h-4 w-4" size="100%" name="lucideX" />
		</button>
	`,
})
export class HlmSheetDynamicContentComponent {
	private readonly _dialogRef = inject(BrnDialogRef);
	private readonly _dialogContext = injectBrnDialogContext({ optional: true });
	public readonly component = this._dialogContext?.$component;

	public readonly state = computed(() => this._dialogRef?.state() ?? 'closed');
	private readonly _renderer = inject(Renderer2);
	private readonly _element = inject(ElementRef);

	constructor() {
		effect(() => {
			this._renderer.setAttribute(this._element.nativeElement, 'data-state', this.state());
		});
	}

	public readonly userClass = input<ClassValue>('', { alias: 'class' });

	// dirty: need to figure out how to pass the side option here
	protected _computedClass = computed(() => hlm(sheetVariants({ side: (this._dialogRef as any)._options.side }), this.userClass()));

}
hlm-sheet.service.ts
import type { ComponentType } from '@angular/cdk/portal';
import { Injectable, type TemplateRef, inject } from '@angular/core';
import {
	type BrnDialogOptions,
	BrnDialogService,
	DEFAULT_BRN_DIALOG_OPTIONS,
	cssClassesToArray,
} from '@spartan-ng/brain/dialog';
import { HlmSheetContentComponent } from './hlm-sheet-content.component';
import { hlmSheetOverlayClass } from './hlm-sheet-overlay.directive';
import { HlmDialogOptions } from '@spartan-ng/ui-dialog-helm';
import { OverlayPositionBuilder } from '@angular/cdk/overlay';
import { HlmSheetDynamicContentComponent } from './hlm-sheet-dynamic-content.component';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HlmSheetOptions<DialogContext = any> = HlmDialogOptions<DialogContext> & {
	side: 'top' | 'bottom' | 'left' | 'right';
};

@Injectable({
	providedIn: 'root',
})
export class HlmSheetService {
	private readonly _brnDialogService = inject(BrnDialogService);
	private readonly _positionBuilder = inject(OverlayPositionBuilder);

	private getPositionStrategy(side?: 'top' | 'bottom' | 'left' | 'right') {
		switch (side) {
			case 'top':
				return this._positionBuilder.global().top();
			case 'bottom':
				return this._positionBuilder.global().bottom();
			case 'left':
				return this._positionBuilder.global().left();
			case 'right':
			default:
				return this._positionBuilder.global().right();
		}
	}

	public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmSheetOptions>) {
		const mergedOptions = {
			...DEFAULT_BRN_DIALOG_OPTIONS,
			closeDelay: 100,
			positionStrategy: this.getPositionStrategy(options?.side),

			...(options ?? {}),
			backdropClass: cssClassesToArray(`${hlmSheetOverlayClass} ${options?.backdropClass ?? ''}`),
			context: { ...options?.context, $component: component, $dynamicComponentClass: options?.contentClass },
		};

		return this._brnDialogService.open(HlmSheetDynamicContentComponent, undefined, mergedOptions.context, mergedOptions);
	}
}

Usage:

  private readonly _sheets = inject(HlmSheetService);

  const sheetRef = this._sheets.open(MySheetComponent, {
    contentClass: "!w-[540px]",
    side: 'left'
  });

  sheetRef.closed$.subscribe((result) => {
    // ...
  });

hillin avatar Dec 21 '24 09:12 hillin