File

src/app/core/common-components/entities-table/entities-table-selection.ts

Description

Input values required to calculate mouse-based row selection updates.

Index

Properties

Properties

lastSelectedRow
lastSelectedRow: TableRow<T> | null
Type : TableRow<T> | null
lastSelection
lastSelection: boolean | null
Type : boolean | null
row
row: TableRow<T>
Type : TableRow<T>
selectedRecords
selectedRecords: T[]
Type : T[]
selectedRows
selectedRows: TableRow<T>[]
Type : TableRow<T>[]
shiftKey
shiftKey: boolean
Type : boolean
import { computed, Injectable, signal } from "@angular/core";
import { Entity } from "../../entity/model/entity";
import { TableRow } from "./table-row";

/**
 * Pure selection helpers for entities-table row interaction
 * and checkbox state.
 * And EntitiesTableSelectionStore for holding selection state and coordinating updates.
 */

/**
 * Result container for a row selection interaction.
 */
export interface MouseSelectionUpdate<T extends Entity> {
  selectedRecords: T[];
  lastSelectedRow: TableRow<T> | null;
  lastSelection: boolean | null;
}

/**
 * Input values required to calculate mouse-based row selection updates.
 */
export interface MouseSelectionInput<T extends Entity> {
  selectedRecords: T[];
  selectedRows: TableRow<T>[];
  row: TableRow<T>;
  shiftKey: boolean;
  lastSelectedRow: TableRow<T> | null;
  lastSelection: boolean | null;
}

/**
 * Detects whether the click target is inside an element marked as `.clickable`.
 */
export function isClickableTarget(target: EventTarget | null): boolean {
  const element = target as {
    closest?: (selector: string) => Element | null;
  } | null;
  return !!element?.closest?.(".clickable");
}

/**
 * Detects whether the click originated from a checkbox input.
 */
export function isCheckboxTarget(target: EventTarget | null): boolean {
  return target instanceof HTMLInputElement && target.type === "checkbox";
}

/**
 * Determines whether row-click handling should be skipped for this interaction.
 */
export function shouldSkipRowInteraction<T extends Entity>(
  target: EventTarget | null,
  row: TableRow<T>,
): boolean {
  return isClickableTarget(target) || !!row.formGroup?.enabled;
}

/**
 * Adds or removes a single record from the selected records collection.
 */
export function toggleRecordSelection<T extends Entity>(
  selectedRecords: T[],
  record: T,
  checked: boolean,
): T[] {
  if (checked) {
    return selectedRecords.includes(record)
      ? selectedRecords
      : [...selectedRecords, record];
  }

  return selectedRecords.includes(record)
    ? selectedRecords.filter((current) => current !== record)
    : selectedRecords;
}

/**
 * Applies a contiguous shift-selection update over a row index range.
 */
export function applyRangeSelection<T extends Entity>(
  selectedRecords: T[],
  selectedRows: TableRow<T>[],
  range: { start: number; end: number },
  shouldCheck: boolean,
): T[] {
  const updatedSelection = [...selectedRecords];
  for (let index = range.start; index <= range.end; index++) {
    const row = selectedRows[index];
    const isSelected = updatedSelection.includes(row.record);

    if (shouldCheck && !isSelected) {
      updatedSelection.push(row.record);
    } else if (!shouldCheck && isSelected) {
      updatedSelection.splice(updatedSelection.indexOf(row.record), 1);
    }
  }
  return updatedSelection;
}

/**
 * Computes the next selection state for a row `mousedown`, including shift-range behavior.
 */
export function updateSelectionFromMouseDown<T extends Entity>(
  input: MouseSelectionInput<T>,
): MouseSelectionUpdate<T> {
  const {
    selectedRecords,
    selectedRows,
    row,
    shiftKey,
    lastSelectedRow,
    lastSelection,
  } = input;
  const currentIndex = selectedRows.indexOf(row);
  const anchorIndex = lastSelectedRow
    ? selectedRows.indexOf(lastSelectedRow)
    : -1;
  const canRangeSelect =
    shiftKey && !!lastSelectedRow && anchorIndex !== -1 && currentIndex !== -1;

  if (canRangeSelect) {
    const start = Math.min(anchorIndex, currentIndex);
    const end = Math.max(anchorIndex, currentIndex);
    const shouldCheck =
      lastSelection !== null
        ? !lastSelection
        : !selectedRecords.includes(row.record);

    return {
      selectedRecords: applyRangeSelection(
        selectedRecords,
        selectedRows,
        { start, end },
        shouldCheck,
      ),
      lastSelectedRow: row,
      lastSelection,
    };
  }

  const wasSelected = selectedRecords.includes(row.record);
  return {
    selectedRecords: toggleRecordSelection(
      selectedRecords,
      row.record,
      !wasSelected,
    ),
    lastSelectedRow: currentIndex !== -1 ? row : null,
    lastSelection: wasSelected,
  };
}

// ---------------------------------------------------------------------------
// Selection Store
// ---------------------------------------------------------------------------

type ReadSignal<T> = () => T;
type ModelSignal<T> = ReadSignal<T> & { set(value: T): void };

/**
 * Input signals consumed by `EntitiesTableSelectionStore`.
 */
export interface EntitiesTableSelectionContext<T extends Entity> {
  selectedRecords: ModelSignal<T[]>;
  sortedRows: ReadSignal<TableRow<T>[]>;
  getCurrentPageRows: () => TableRow<T>[];
}

/**
 * Component-scoped signal store for entities-table selection state and interaction logic.
 *
 * Uses the pure helpers above for all stateless computations;
 * this store is responsible only for holding the mutable state
 * (lastSelectedRow, lastSelection) and coordinating updates.
 */
@Injectable()
export class EntitiesTableSelectionStore<T extends Entity> {
  private context: EntitiesTableSelectionContext<T>;
  private readonly lastSelectedRow = signal<TableRow<T> | null>(null);
  private readonly lastSelection = signal<boolean | null>(null);

  readonly allRowsSelected = computed(() => {
    const selected = this.context?.selectedRecords() ?? [];
    const total = this.context?.sortedRows().length ?? 0;
    return selected.length > 0 && selected.length === total;
  });

  readonly selectionIndeterminate = computed(() => {
    const selected = this.context?.selectedRecords() ?? [];
    const total = this.context?.sortedRows().length ?? 0;
    return selected.length > 0 && selected.length < total;
  });

  /** Connects component input/model signals to this store. Must be called once from component constructor. */
  connect(context: EntitiesTableSelectionContext<T>) {
    this.context = context;
  }

  /** Selects or unselects a single row record. */
  selectRow(row: TableRow<T>, checked: boolean) {
    this.context.selectedRecords.set(
      toggleRecordSelection(
        this.context.selectedRecords(),
        row.record,
        checked,
      ),
    );
  }

  /** Selects or unselects all currently sorted rows. */
  selectAllRows(checked: boolean) {
    this.context.selectedRecords.set(
      checked ? this.context.sortedRows().map((r) => r.record) : [],
    );
  }

  /**
   * Applies row selection interaction for mouse-down in selectable mode.
   * Returns whether the event came from a checkbox target.
   */
  handleSelectableRowMouseDown(event: MouseEvent, row: TableRow<T>): boolean {
    const selectedRows = this.context.getCurrentPageRows();
    const nextState = updateSelectionFromMouseDown({
      selectedRecords: this.context.selectedRecords(),
      selectedRows,
      row,
      shiftKey: event.shiftKey,
      lastSelectedRow: this.lastSelectedRow(),
      lastSelection: this.lastSelection(),
    });
    this.context.selectedRecords.set(nextState.selectedRecords);
    this.lastSelectedRow.set(nextState.lastSelectedRow);
    this.lastSelection.set(nextState.lastSelection);
    return isCheckboxTarget(event.target);
  }
}

results matching ""

    No results matching ""