All files / components dialog.ts

94.28% Statements 33/35
97.5% Branches 39/40
90.9% Functions 10/11
100% Lines 27/27

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130                                                                                                                    33x 33x                                               33x 165x           33x           33x 20x 5x   35x 33x       33x 33x 33x 33x 33x 33x 33x 14x 13x 13x 13x 11x 11x         33x 32x 32x 32x        
import type {
  ChangeDetails,
  ControllableValueOptions,
  RuntimeController,
  RuntimeEventSource,
} from '../core/types';
import { createSnapshotProjection } from '../core/projection';
import {
  createOpenController,
  type OpenChangeReason,
  type OpenLifecycleEvents,
  type OpenSnapshot,
  type OverlayElements,
} from './openable';
 
/** Immutable Dialog snapshot including modal and backdrop semantics. @public */
export interface DialogSnapshot extends OpenSnapshot {
  /** Whether the dialog is modal. */
  readonly modal: boolean;
  /** Value for consumer-rendered `aria-modal`. */
  readonly ariaModal: true | null;
  /** Metadata indicating whether a backdrop belongs to this dialog. */
  readonly hasBackdrop: boolean;
}
 
/** Dialog options for state ownership, focus, dismissal, and semantics. @public */
export interface DialogOptions extends Partial<
  ControllableValueOptions<boolean, OpenChangeReason>
> {
  /** Deterministic content ID. */
  readonly id?: string;
  /** Enables focus containment, sibling inerting, and scroll lock. @defaultValue `true` */
  readonly modal?: boolean;
  /** Disables outside-pointer dismissal when false. @defaultValue `true` */
  readonly closeOnOutsidePointer?: boolean;
  /** Disables topmost Escape dismissal when false. @defaultValue `true` */
  readonly closeOnEscape?: boolean;
  /** Resolves the preferred initial focus target after content binding. */
  readonly initialFocus?: () => HTMLElement | null;
  /** Restores focus to a connected trigger or pre-open target. @defaultValue `true` */
  readonly restoreFocus?: boolean;
}
 
/** Headless Dialog controller. @public */
export interface DialogController
  extends RuntimeController<DialogSnapshot>, RuntimeEventSource<OpenLifecycleEvents> {
  /** Opens programmatically unless the cancellable lifecycle vetoes it. */
  open(details?: ChangeDetails<OpenChangeReason>): void;
  /** Closes programmatically unless the cancellable lifecycle vetoes it. */
  close(details?: ChangeDetails<OpenChangeReason>): void;
  /** Requests the opposite open state. */
  toggle(details?: ChangeDetails<OpenChangeReason>): void;
  /** Binds consumer-rendered DOM and activates focus/dismissal resources only while open. */
  bind(elements: OverlayElements): () => void;
}
 
/** Creates a modal-by-default Dialog with topmost Escape handling and shared focus management. @public */
export function createDialog(options: DialogOptions = {}): DialogController {
  const modal = options.modal ?? true;
  const base = createOpenController({
    role: 'dialog',
    modal,
    trapFocus: modal,
    focusOnOpen: modal || options.initialFocus !== undefined,
    restoreFocusOnOutside: true,
    closeOnFocusOutside: false,
    ...(options.id ? { id: options.id } : {}),
    ...(options.defaultValue !== undefined ? { defaultValue: options.defaultValue } : {}),
    ...(options.getValue ? { getValue: options.getValue } : {}),
    ...(options.onValueChange ? { onValueChange: options.onValueChange } : {}),
    ...(options.subscribeValue ? { subscribeValue: options.subscribeValue } : {}),
    ...(options.closeOnOutsidePointer !== undefined
      ? { closeOnOutsidePointer: options.closeOnOutsidePointer }
      : {}),
    ...(options.closeOnEscape !== undefined ? { closeOnEscape: options.closeOnEscape } : {}),
    ...(options.initialFocus ? { initialFocus: options.initialFocus } : {}),
    ...(options.restoreFocus !== undefined ? { restoreFocus: options.restoreFocus } : {}),
  });
  interface DialogBinding {
    readonly elements: OverlayElements;
    readonly token: symbol;
  }
  let binding: DialogBinding | undefined;
  let destroyed = false;
  const projection = createSnapshotProjection(base, (snapshot: OpenSnapshot): DialogSnapshot => ({
    ...snapshot,
    modal,
    ariaModal: modal ? true : null,
    hasBackdrop: binding?.elements.backdrop !== undefined,
  }));
  return {
    getSnapshot: projection.getSnapshot,
    subscribe: projection.subscribe,
    on: base.on,
    off: base.off,
    once: base.once,
    open: (changeDetails = { reason: 'programmatic' }) => base.open(changeDetails),
    close: (changeDetails = { reason: 'programmatic' }) => base.close(changeDetails),
    toggle: (changeDetails = { reason: 'programmatic' }) => base.toggle(changeDetails),
    bind(elements) {
      if (destroyed) return () => undefined;
      const registration: DialogBinding = {
        elements,
        token: Symbol('dialog-binding'),
      };
      binding = registration;
      const isCurrentBinding = (): boolean => !destroyed && binding === registration;
      projection.refresh();
      Iif (!isCurrentBinding()) return () => undefined;
      const unbind = base.bind(elements);
      let active = true;
      return () => {
        if (!active) return;
        active = false;
        unbind();
        if (binding === registration) {
          binding = undefined;
          projection.refresh();
        }
      };
    },
    destroy() {
      if (destroyed) return;
      destroyed = true;
      binding = undefined;
      projection.destroy();
    },
  };
}