Implementing a contextual menu
Published: 2024-06-19 08:57:23

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>
```
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);
      }

    }

  }
```
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;
}

```
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();
  }

}
```
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 [...] 
    
}
```

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();
  }
}
```
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](https://schedulejs.com/en/context-menu/). For more detailed information, you can explore the comprehensive documentation on [ScheduleJS](https://schedulejs.com/).