DEV Community

Cover image for Implementing a contextual menu
Gaetan Gasoline
Gaetan Gasoline

Posted on

Implementing a contextual menu

Generate powerful shortcuts and interactions with your graphics by creating a context menu. This article proposes a four steps implementation of a context menu system starting from scratch in your ScheduleJS graphics.

Step 1: Define your context menu structure using HTML

The first step in this tutorial is to declare your context menu in the HTML part of the application. The best way to keep your code clean is to create a dedicated component to render the context menu and define your interactions.

<!-- The ngStyle input will handle positionning -->
<div class="demo-planning-board-context-menu"
     [class.display]="!gantt.contextMenuOverlay.isHidden"
     [ngStyle]="gantt.contextMenuOverlay.position"
     (click)="gantt.contextMenuOverlay.onHide()">

  <!-- (1) Set started action -->
  <div class="demo-planning-board-context-menu-action"
       (click)="gantt.contextMenuOverlay.onSetTaskStarted()">
      Set started
  </div>

  <!-- (2) Set completed action -->
  <div class="demo-planning-board-context-menu-action"
       (click)="gantt.contextMenuOverlay.onSetActivityCompleted()">
    Set completed
  </div>

  <!-- (3) Set late action -->
  <div class="demo-planning-board-context-menu-action"
       (click)="gantt.contextMenuOverlay.onSetActivityLate()">
    Set late
  </div>

  <!-- (4) Set priority action -->
  <div class="demo-planning-board-context-menu-action"
       (click)="gantt.contextMenuOverlay.onSetActivityPriority()">
    {{ gantt.contextMenuOverlay.activity?.isHighPriority
         ? "Remove high priority" : "Set high priority" }}
  </div>

</div>
Enter fullscreen mode Exit fullscreen mode

The display child class will be used to hide and show the context menu, while we will update its position using CSS and pass it through the Angular [ngStyle] input property.
Here, we created a simple contextual layout with four actions:

  • Set started: Change the activity state and set it as started
  • Set completed: Set the activity as completed
  • Set late: Set the activity sequence as late, starting from a specific activity
  • Set priority: Set the priority as High or remove this setting Now let’s do a little CSS to make it pretty. We recommend using SCSS to create a scope for the style classes like the following:
.demo-planning-board-context-menu {

    display: none;
    position: absolute;
    z-index: 1;
    background: #555555dd;
    border-radius: 5px;
    padding: 3px 0;

    &.display {
      display: flex;
      flex-direction: column;
    }

    .demo-planning-board-context-menu-action {

      color: white;
      font: 13px $demo-planning-board-font;
      padding: 0 10px;
      margin: 3px 0;

      &:hover {
        color: black;   
        filter: brightness(0.9);
        background: rgba(255, 255, 255, 0.4);
      }

      &:active {
        filter: brightness(0.8);
      }

    }

  }
Enter fullscreen mode Exit fullscreen mode

Once done, we can start playing with Angular to create the logic for this element.

Step 2: Create an overlay abstraction

Using an object-oriented approach, we can define an abstraction that will be the starting point for all our overlays, so we can reuse it to create tooltips, modals, and such.

export abstract class PlanningBoardAbstractOverlay {

  // Attributes

  isHidden: boolean = true;
  activity: PlanningBoardActivity | undefined = undefined;
  position: PlanningBoardOverlayPosition = {};

  // Constructor

  constructor(public gantt: PlanningBoardGanttChart) { }

  // Methods

  abstract onShow(pointerEvent: PointerEvent, activity: PlanningBoardActivity | undefined): void;

  onHide(): void {
    this.isHidden = true;
  }

  setOverlayElementPosition(pointerEvent: PointerEvent): void {
    const isRight = pointerEvent.x > window.innerWidth / 2;
    const isBottom = pointerEvent.y > window.innerHeight / 2;
    const marginPx = 10;
    this.position.top = isBottom ? "auto" : pointerEvent.y + marginPx + "px";
    this.position.right = isRight ? window.innerWidth - pointerEvent.x + marginPx + "px" : "auto";
    this.position.bottom = isBottom ? window.innerHeight - pointerEvent.y + marginPx + "px" : "auto";
    this.position.left = isRight ? "auto" : pointerEvent.x + marginPx + "px";
  }

}

export interface PlanningBoardOverlayPosition {
  top?: string;
  right?: string;
  bottom?: string;
  left?: string;
}

Enter fullscreen mode Exit fullscreen mode

Let’s store a few properties that will hold the state of our current overlay abstraction:

  • The isHidden property will be used to hide and show the overlay.
  • The activity property links our overlay to a specific activity.
  • The position property will define where the overlay should render using our PointerEvent. Exposing the GanttChart instance in the overlay will help us to create actions and interact with our graphics.

The overlay will also expose three methods:

  • The onShow method is used to define the display strategy.
  • The onHide method.
  • The setOverlayElementPosition will update the position property.

Step 3: Build the context menu logic

Using our PlanningBoardAbstractOverlay abstract class, we can now create a new PlanningBoardContextMenuOverlay class that will hold the logic for our context menu.

export class PlanningBoardContextMenuOverlay extends PlanningBoardAbstractOverlay {

  // Methods

  onShow(pointerEvent: PointerEvent, activity: PlanningBoardActivity): void {
    if (activity) {
      this.isHidden = false;
      this.activity = activity;
      this.setOverlayElementPosition(pointerEvent);
    } else {
      this.onHide();
    }
  }

  // Context menu actions

  onSetTaskStarted(): void {
    this.activity.progressRatio = 0.01;
    this.gantt.redraw();
  }

  onSetActivityCompleted(): void {
    this.activity.progressRatio = 1;
    this.gantt.redraw();
  }

  onSetActivityLate(): void {
    this.activity.deadline = 0;
    this.activity.successorFinishesAfterDeadline = true;
    this.gantt.redraw();
  }

  onSetActivityPriority(): void {
    this.activity.isHighPriority = !this.activity.isHighPriority;
    this.gantt.redraw();
  }

}
Enter fullscreen mode Exit fullscreen mode

Let’s design the onShow process:

  • When opening the menu with an activity, we will store this activity and display our contextual menu. Let’s use the setOverlayElementPosition we created in our abstract class and give it our PointerEvent.
  • If the context menu is opened without a contextual activity, we trigger the onHide method. Our four actions will update the activity data and trigger a redraw, letting our underlying ActivityRenderer update the graphics with this new information.

Step 4: Trigger the context menu

ScheduleJS proposes a large set of event methods that you can register in the main object: the GanttChart. An easy way to organize the code is to create a custom GanttChart class that extends the default GanttChart.

// Here we create our custom GanttChart class
export class PlanningBoardGanttChart extends GanttChart<PlanningBoardRow> {

  // Instantiate our PlanningBoardContextMenuOverlay class
  readonly contextMenuOverlay: PlanningBoardContextMenuOverlay = new PlanningBoardContextMenuOverlay(this);

  // The minimal GanttChart implementation requires the Angular injector
  constructor(injector: Injector) {
    super(undefined, injector);
  }

  // Event handlers [...] 

}
Enter fullscreen mode Exit fullscreen mode

As the GanttChart object is at the core of ScheduleJS, its class is a great place to register the default renderers, system layers, and events handlers. Note that the ScheduleJS API is accessible through the GanttChart instance with methods like gantt.getGraphics().

The GanttChart class proposes a set of overridable methods designed to handle user input on the graphics, for example:

  • onRowDrawingEnginePointerDown
  • onDatelinePointerMove
  • onToggleGrid

What we want to do here is to override the onRowDrawingEngineContextMenu method to trigger logic when opening our context menu. In a desktop environment, this method is called when the user right-clicks anywhere on the graphics.

/**
  * Trigger when right-clicking on the canvas
  */
onRowDrawingEngineContextMenu(pointerEvent: PointerEvent, row: PlanningBoardRow, index: number): void {
  super.onRowDrawingEngineContextMenu(pointerEvent, row, index);
  const hoveredActivity = this._getHoveredActivity();
  if (hoveredActivity) {
    this.tooltipOverlay.onHide();
    this.contextMenuOverlay.onShow(pointerEvent, hoveredActivity);
    this._changeDetectorRef.detectChanges();
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, when the user right-clicks on the graphics, this code will trigger with the underlying pointer event, current row, and row-index. We can extract the currently hoveredActivity using the graphics.getHoveredActivity API and pass it to the overlay onShow method.

The context menu we are building will add interactions with activities present on the canvas. On user right click, we will check if we are hovering over an activity, and if it’s the case, we hide our tooltip and trigger the contextMenuOverlay.onShow method.

For performance reasons, this method is run outside of the Angular zone by ScheduleJS. So we have to add a call to ChangeDetectorRef.detectChanges() to trigger change detection manually and update the DOM.

Conclusion

In conclusion, implementing a context menu in your ScheduleJS graphics involves four key steps. First, define your context menu structure using HTML by creating a dedicated component for rendering the menu and handling interactions. Second, create an overlay abstraction to manage positioning and display logic. Third, build the context menu logic by extending the overlay abstraction, and lastly, trigger the context menu through events in your custom GanttChart class.

To see the final result, please visit our blog. For more detailed information, you can explore the comprehensive documentation on ScheduleJS.

Top comments (0)