Skandha: generic interacting behaviours
May 2020, by Maarten Nieber
Installing callback functions

The Selection facet API

We will now examine the Selection facet API more closely, and explain how callbacks are installed that assist in implementing the facet operations.

facets/Selection.ts
import { DefineCbs, withCbs } from 'aspiration';
import { stub } from 'skandha';

export class Selection<T = any> {
  static className = () => 'Selection';

  callbackMap_ = {} as DefineCbs<{
    selectItem: {
      selectItem: () => void;
    };
  }>;

  @input selectableIds: Array<string> = stub;
  @data ids: Array<string> = [];
  @data anchorId?: string;
  @output items: Array<T> = stub;

  @operation select(args: {
    itemId: string | undefined;
    isShift?: boolean;
    isCtrl?: boolean;
  }) {
    return withCbs(this.callbackMap, 'selectItem', args, (cbs) => {
      cbs.selectItem();
    });
  }
};

This Selection facet is part of the skandha-facets library that you can install from npm. However, you can also write your own facet classes.

As you can see, the Selection class has almost no logic. It describes how a selection is stored, and shows the signature for selecting items, but doesn't specify how selection is implemented. This is by design, so that we can reuse Selection in different scenarios that require different types of selection.

Of course, we do have to implement the select operation. This is achieved by implementing the callbacks that are described in the callbackMap member of the Selection type.

The callbacks object

In the body of the select operation, we use withCbs to obtain a so-called callbacks object. This object (cbs) contains:

  • all callbacks that the operation requires;
  • a copy of the input argument of the operation.

To be able to use the Selection.select operation, we must implement these callbacks. We'll do this in the initClips function, that installs all callbacks for all facets of clipsCtr.

clips/initClips.ts
import { Cbs } from 'aspiration';

export const initClips = (clipsCtr: any) => {
  const ctr = clipsCtr;

  ctr.addition.callbackMap = ...; {/* omitted for brevity */};
  ctr.deletion.callbackMap = ...; {/* omitted for brevity */};
  ctr.edit.callbackMap = ...; {/* omitted for brevity */};
  ctr.highlight.callbackMap = ...; {/* omitted for brevity */};
  ctr.insertion.callbackMap = ...; {/* omitted for brevity */};
  ctr.move.callbackMap = ...; {/* omitted for brevity */};
  ctr.selection.callbackMap = {
    select: {
      selectItem(this: Cbs<Selection['select']>) {
        handleSelectItem(ctr.selection, this.args);

        // Highlight follows selection
        if (!this.args.isCtrl && !this.args.isShift) {
          ctr.highlight.highlightItem({ id: this.args.itemId });
        }
      },
    },
  };
};

The example code shows how the selectItem callback is implemented. Remember that selectItem is called when we execute cbs.selectItem() in the select operation of the Selection facet. Inside of selectItem, the this variable points to the callbacks object (cbs), which contains a copy of the input arguments of the operation. That is why we can use this.args inside selectItem to access the selection arguments.

Our callback also takes care of highlighting the selected element. This demonstrates how we can use a callback to add interactions between behaviours. To get a better impression, I recommend to look at the sample code mentioned at the beginning of this article.

Using MobX

I'm a big fan of using MobX for making the UI respond to changes in the UI state. However, I didn't want a tight coupling between Skandha and MobX. Therefore, there is a separate library called skandha-mobx that makes a Skandha container observable with MobX. It offers a registerCtr function that applies observable to all facet data fields, and computed to all facet operations.

clips/initClips.ts
import { registerCtr } from 'skandha-mobx';

registerCtr({
  ctr: clipsCtr,
  options: { name: 'Clips' },
  initCtr: () => {
    initClips(this, props);
    mapClipsData(props);
  }
});

Connection to Adobe Aria

So far, we haven't discussed how user interactions such as mouse clicks and key-presses are handled. This is the responsibility of the UI components. When the component receives an event, it should call the correct facet operation. For example, when the user clicks on an item in a ListBox component, then the component must call the selection.select() operation. I have written generic (reusable) code to make this happen, but I see an opportunity to use a headless UI such as Adobe Aria:

  • the useListBox() hook function from Aria creates the event handlers for the ListBox component;
  • we listen to state changes in the Aria listbox selection, and call the related facet operation (i.e. selection.select()) to update the shared UI state;
  • when the shared UI state (i.e. selection.ids) changes then we update the local Aria state.

In other words: we can synchronize the shared UI state with the local Aria state. This allows us to get the benefits that Adobe Aria provides.