Modal

The <smart-modal> component provides a dialog overlay with title, description, an arbitrary projected body, and a list of action buttons. It follows the Base + Standard + Wrapper pattern with an InjectionToken-based extension mechanism. The abstract ModalBaseComponent defines the shared API — two-way open (ModelSignal<boolean>), optional title and description, actions (InputSignal<IModalAction[]>), optional IModalOptions, cssClass (alias class), actionClick and closed outputs, and invokeAction(actionId) / close() methods. ModalStandardComponent is a barebones placeholder concrete implementation. ModalComponent is the public wrapper that renders ModalStandardComponent by default and accepts a custom replacement via MODAL_STANDARD_COMPONENT_TOKEN.


Usage

<button type="button" class="docs-modal-trigger" (click)="open.set(true)">
  Deactivate account
</button>

<smart-modal
  [(open)]="open"
  title="Deactivate account"
  description="Once the account is deactivated all of its data will be permanently removed."
  [actions]="actions"
  [options]="options"
  (actionClick)="onActionClick($event)"
  (closed)="onClosed()"
/>

Components

Main wrapper component. Renders ModalStandardComponent by default. When MODAL_STANDARD_COMPONENT_TOKEN is provided, renders the injected component via NgComponentOutlet.

Barebones placeholder concrete implementation. Renders a native <dialog [open] role="dialog" aria-modal="true"> containing an optional <h2 id="smart-modal-title">, an optional <p> description, an <ng-content> slot for arbitrary body content, an optional dismiss <button> (when options.withDismiss is true), and a <footer> with one <button data-variant> per action. It does not include Tailwind UI styling — it exists solely as the default structural placeholder until a custom implementation is registered through the token.

Styled variation that extends ModalBaseComponent and is a drop-in replacement for ModalStandardComponent. Register it via MODAL_STANDARD_COMPONENT_TOKEN to restyle every <smart-modal>, or use the <smart-modal-preset> selector directly. It renders a translated Preline overlay — a full-screen scrollable backdrop hosting a rounded-xl card with a header (title + optional dismiss ×), a body (description + projected content), and a footer of action buttons. The look is selected through options.variant (default 'centered'): centered/alert are vertically centered (alert is narrower), wide is a top-aligned large dialog, and left-aligned-buttons left-aligns the footer. options.footerStyle: 'gray' tints the footer, and action buttons are styled by their variant (primary/secondary/danger). All classes are smart:-prefixed Tailwind with explicit dark: variants. The class recipes live in preset/preset-classes.util.ts.

Preline JS is NOT used. Open/close is driven entirely by the existing open model signal + @if (no HSOverlay, no data-hs-*). The component binds its own dismiss UX: clicking the backdrop and pressing Escape both call close() (which sets open to false and emits closed). The dialog ARIA contract is preserved (role="dialog", aria-modal="true", aria-labelledby when title is set, else aria-label from options.ariaLabel). The header dismiss × button is only shown when options.withDismiss is true.

Because ModalComponent renders injected components via NgComponentOutlet (which passes inputs by canonical name), ModalPresetComponent overrides cssClass as input<string>('') without the class alias (applied to the centering wrapper). Bind it as [cssClass] when using the <smart-modal-preset> selector directly, or just pass class on <smart-modal> (the wrapper forwards it).

Abstract base directive for extending custom modal implementations. Exposes open as a two-way ModelSignal<boolean>, title / description as optional InputSignal<string | undefined>, actions as InputSignal<IModalAction[]> (default []), options as InputSignal<IModalOptions | undefined>, cssClass as InputSignal<string> (with alias class), actionClick (payload IModalActionClick, exported from @smartsoft001/angular) and closed outputs, plus invokeAction(actionId) (emits actionClick) and close() (sets open to false and emits closed).

API

Inputs

InputTypeDefaultDescription
openModelSignal<boolean>falseVisibility state (two-way bindable)
titleInputSignal<string | undefined>-Dialog heading (also sets aria-labelledby)
descriptionInputSignal<string | undefined>-Optional secondary description text
actionsInputSignal<IModalAction[]>[]Action buttons rendered in the footer
optionsInputSignal<IModalOptions | undefined>-Optional configuration
classInputSignal<string>''External CSS classes (alias for cssClass)

Outputs

OutputPayloadDescription
actionClickIModalActionClick ({ actionId: string })Fired when a footer action is clicked
closedvoidFired when the dialog is dismissed/closed

IModalOptions

The default ModalStandardComponent consumes withDismiss (renders a dismiss × button) and ariaLabel (used as aria-label when title is not provided). The variant and footerStyle selectors are reserved for custom implementations registered through MODAL_STANDARD_COMPONENT_TOKEN.

InjectionToken that allows replacing the default ModalStandardComponent with a custom implementation. Provide a Type<ModalBaseComponent> to override.

Content projection

ModalStandardComponent accepts arbitrary body content via <ng-content> and the wrapper forwards its own <ng-content> into the standard. However, NgComponentOutlet does not propagate projected content — when a custom component is registered via the token, the wrapper renders the custom component as a sibling and the consumer's <ng-content> is dropped. Custom implementations should rely on inputs (title, description, structured data extension) or expose their own template inputs rather than <ng-content>.

Extending the base class

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  input,
  ViewEncapsulation,
} from '@angular/core';

import {
  IModalAction,
  IModalOptions,
  MODAL_STANDARD_COMPONENT_TOKEN,
  ModalBaseComponent,
  ModalComponent,
} from '@smartsoft001/angular';

/**
 * A custom modal built on `ModalBaseComponent`.
 *
 * The base contributes the `open` model plus the `invokeAction(id)` and
 * `close()` helpers, which already emit `actionClick` and `closed`. The base
 * binds no keyboard or backdrop listeners, so the implementation wires the
 * backdrop click itself.
 */
@Component({
  selector: 'docs-custom-modal',
  template: `
    @if (open()) {
      <div class="docs-modal__backdrop" (click)="close()"></div>

      <div
        role="dialog"
        aria-modal="true"
        [attr.aria-label]="options()?.ariaLabel ?? title()"
        [class]="containerClasses()"
      >
        @if (options()?.withDismiss) {
          <button
            type="button"
            class="docs-modal__dismiss"
            aria-label="Close"
            (click)="close()"
          >
            &times;
          </button>
        }

        @if (title()) {
          <h2 class="docs-modal__title">{{ title() }}</h2>
        }
        @if (description()) {
          <p class="docs-modal__description">{{ description() }}</p>
        }

        <!--
          smart-modal renders a custom implementation through NgComponentOutlet,
          which does not forward projected content. Only open, title,
          description, actions, options and cssClass arrive here, so a custom
          modal owns its body instead of relying on ng-content.
        -->
        <footer [class]="footerClasses()">
          @for (action of actions(); track action.id) {
            <button
              type="button"
              class="docs-modal__action"
              [attr.data-variant]="action.variant ?? 'primary'"
              (click)="invokeAction(action.id)"
            >
              {{ action.label }}
            </button>
          }
        </footer>
      </div>
    }
  `,
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomModalComponent extends ModalBaseComponent {
  // NgComponentOutlet passes 'cssClass' by canonical name, not the 'class' alias.
  override cssClass = input<string>('');

  containerClasses = computed(() => {
    const classes = ['docs-modal'];
    const variant = this.options()?.variant;
    if (variant) classes.push(`docs-modal--${variant}`);
    const extra = this.cssClass();
    if (extra) classes.push(extra);
    return classes.join(' ');
  });

  footerClasses = computed(() =>
    [
      'docs-modal__footer',
      `docs-modal__footer--${this.options()?.footerStyle ?? 'default'}`,
    ].join(' '),
  );
}

/**
 * Registering the implementation against `MODAL_STANDARD_COMPONENT_TOKEN`
 * makes every `<smart-modal>` in this injector render it instead of the
 * standard variation.
 */
@Component({
  selector: 'docs-modal-custom-example',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ModalComponent],
  providers: [
    { provide: MODAL_STANDARD_COMPONENT_TOKEN, useValue: CustomModalComponent },
  ],
  template: `
    <smart-modal
      [open]="true"
      title="Deactivate account"
      description="Once the account is deactivated all of its data will be permanently removed."
      [actions]="actions"
      [options]="options"
    />
  `,
})
export class ModalCustomExampleComponent {
  actions: IModalAction[] = [
    { id: 'cancel', label: 'Cancel', variant: 'secondary' },
    { id: 'deactivate', label: 'Deactivate', variant: 'danger' },
  ];

  options: IModalOptions = {
    variant: 'centered',
    footerStyle: 'gray',
    withDismiss: true,
  };
}

Source

The component lives in packages/shared/angular/src/lib/components/modal and is documented for Claude Code by the angular-components-modal skill.