spartan-ng / spartan

Cutting-edge tools powering Angular full-stack development.
https://spartan.ng
MIT License
1.51k stars 159 forks source link

Customizable Placeholder for select #399

Open S-Mitterlehner opened 1 month ago

S-Mitterlehner commented 1 month ago

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

select

Information

We need to style the placeholder of the select differently then the options. For instance, the placeholder must be gray while the selected option should be black.

It would be nice to have a directive that represents the placeholder, so that it can be structured and styled accordingly.

Describe any alternatives/workarounds you're currently using

<brn-select
      [placeholder]="'Select Option'"
      [(ngModel)]="selected">
      <hlm-select-trigger>
        @if (selected()) {
          <hlm-select-value />
        } @else {
          <label
            hlmLabel
            [variant]="'placeholder'"
            >Select Option</label
          >
        }
      </hlm-select-trigger>
      <hlm-select-content>
        <hlm-option value="1">Option 1</hlm-option>
        <hlm-option value="2">Option 2</hlm-option>
        <hlm-option value="3">Option 3</hlm-option>
        <hlm-option value="4">Option 4</hlm-option>
        <hlm-option value="5">Option 5</hlm-option>
      </hlm-select-content>
    </brn-select>

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

CO97 commented 1 month ago

I previously had a play around with this, been using this as a temp solution for now

import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  computed,
  ContentChild,
  inject,
  Input,
  TemplateRef
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { BrnSelectService } from '@spartan-ng/ui-select-brain';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { NoSelectedTemplateDirective, SelectedTemplateDirective } from './selected-templates.directives';

@Component({
  selector: 'custom-hlm-select-value',
  standalone: true,
  template: `
    @if (!!value) {
      <ng-container
        *ngTemplateOutlet="selectedTemplate ?? defaultSelected; context: { selectedOptions: selectedOptionValues() }"></ng-container>
    } @else {
      <ng-container *ngTemplateOutlet="noSelectedTemplate ?? defaultNoSelected"></ng-container>
    }

    <ng-template #defaultNoSelected>
      {{ placeholder() }}
    </ng-template>

    <ng-template #defaultSelected>
      {{ value }}
    </ng-template>
  `,
  host: {
    '[id]': 'id()'
  },
  styles: [
    `
      :host {
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 1;
        white-space: nowrap;
        pointer-events: none;
      }
    `
  ],
  imports: [NgTemplateOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomHlmSelectValueComponent {
  private readonly _selectService = inject(BrnSelectService);

  public readonly id = computed(() => `${this._selectService.id()}--value`);
  public readonly placeholder = computed(() => this._selectService.placeholder());
  public readonly selectedOptionValues = computed(() => {
    const selectedOptions = this._selectService.selectedOptions();
    if (!selectedOptions) {
      return null;
    }
    return (selectedOptions ?? []).filter((val) => !!val).map(({ value }) => value);
  });

  value: string | null = null;

  @ContentChild(NoSelectedTemplateDirective, { read: TemplateRef }) noSelectedTemplate?: TemplateRef<void>;
  @ContentChild(SelectedTemplateDirective, { read: TemplateRef }) selectedTemplate?: TemplateRef<{
    selectedItems: unknown | null
  }>;

  @Input()
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  transformFn: (values: (string | undefined)[]) => any = (values) => (values ?? []).join(', ');

  constructor() {
    const cdr = inject(ChangeDetectorRef);

    // In certain cases (when using a computed signal for value) where the value of the select and the options are
    // changed dynamically, the template does not update until the next frame. To work around this we can use a simple
    // string variable in the template and manually trigger change detection when we update it.
    toObservable(this._selectService.selectedOptions)
      .pipe(takeUntilDestroyed())
      .subscribe((value) => {
        if (value.length === 0) {
          this.value = null;
          cdr.detectChanges();
          return;
        }
        const selectedLabels = value.map((selectedOption) => selectedOption?.getLabel());

        if (this._selectService.dir() === 'rtl') {
          selectedLabels.reverse();
        }
        const result = this.transformFn(selectedLabels);
        this.value = result;
        cdr.detectChanges();
      });
  }
}
// no-selected-template.directive.ts
import { Directive, TemplateRef } from '@angular/core';

@Directive({
  selector: '[appNoSelectedTemplate]',
  standalone: true
})
export class NoSelectedTemplateDirective {
  constructor(public templateRef: TemplateRef<any>) {
  }
}

@Directive({
  selector: '[appSelectedTemplate]',
  standalone: true,
  host: {
    '[style.overflow]': '"hidden"',
  }
})
export class SelectedTemplateDirective {
  constructor(public templateRef: TemplateRef<any>) {
  }
}

By no means is it perfect but could give an idea on the direction to go.

  1. Allow for custom Placeholder
  2. Allow for custom Selected items, example being when you want an icon beside a selected item.