spandrel.js

/**
 * @copyright 2019 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam
 */

/* eslint-env browser */

/**
 * API Status: **Development**
 * @module bajaux/spandrel
 */
define([
  'log!bajaux.spandrel',
  'bajaux/Widget',
  'bajaux/lifecycle/WidgetManager',
  'jquery',
  'Promise',
  'underscore',
  'nmodule/js/rc/switchboard/switchboard',
  'bajaux/spandrel/buildConfig',
  'bajaux/spandrel/diff',
  'bajaux/spandrel/jsx',
  'bajaux/spandrel/RequestLayoutMixin',
  'bajaux/spandrel/SpandrelWidget',
  'bajaux/spandrel/util' ], function (
    log,
    Widget,
    WidgetManager,
    $,
    Promise,
    _,
    switchboard,
    buildConfig,
    diff,
    jsx,
    RequestLayoutMixin,
    SpandrelWidget,
    util) {

  'use strict';

  const TRACE = log.isLoggable('FINEST');
  const logFinest = log.finest.bind(log);

  let defaultManager = new WidgetManager();

  const { $DEPTH_SYMBOL, $IS_DYNAMIC_SYMBOL, $ROOT_SYMBOL } = buildConfig;
  const { diffBuildContexts } = diff;
  const { any, difference, extend, isArray, last, lastIndexOf } = _;
  const {
    getContainingSpandrelWidget, getInlineStyles, getPathToKid, kidsMatching,
    pathMatches, requiresRebuild, updateElement
  } = util;

  const PRIORITY_UPDATE = 1;
  const PRIORITY_REBUILD = 2;

  const PATH_TO_KID_CONTEXTS = [ 'config', 'kids', 'members' ];

  /**
   * The purpose of `spandrel` is to provide a reasonably pure-functional,
   * diffable method of defining a nested structure of bajaux Widgets and
   * supporting HTML. Rather than require Widget implementors to manually code
   * calls to `initialize()` or `buildFor()`, `spandrel` allows you to provide
   * your desired structure of HTML elements and their associated Widget
   * instances, and handle the work of updating the document as that structure
   * may change over time.
   *
   * See {@tutorial spandrel} for in-depth information.
   *
   * @alias module:bajaux/spandrel
   * @param {module:bajaux/spandrel~SpandrelArg} arg
   * @param {object} [params={}] params
   * @param {function(new:module:bajaux/Widget)} [params.extends] optionally specify a Widget superclass to extend
   * @param {module:bajaux/lifecycle/WidgetManager} [params.manager] optionally provide your own WidgetManager to manage Widget lifecycle
   * @returns {Function} a Widget constructor
   * @since Niagara 4.10
   *
   * @example
   * <caption>Generate a static widget</caption>
   * const StaticWidget = spandrel([
   *   '<label>Name: </label>',
   *   '<input type="text" value="{{ props.name }}">',
   *   {
   *     dom: '<span></span>',
   *     value: false,
   *     properties: 'inherit'
   *   }
   * ]);
   * return fe.buildFor({
   *   dom: $('#myStaticWidget'),
   *   type: StaticWidget,
   *   properties: { name: 'Logan', trueText: 'Good', falseText: 'Not So Good' }
   * });
   *
   * @example
   * <caption>Generate a dynamic widget with a field editor for each slot</caption>
   * const DynamicWidget = spandrel(comp => comp.getSlots().toArray().map(slot => ({
   *   dom: '<div class="componentSlot"/>',
   *   kids: [
   *     `<label>${ slot.getName() }: </label>`,
   *     { dom: '<span/>', complex: comp, slot: slot }
   *   ]
   * })));
   *
   * return fe.buildFor({
   *   dom: $('#myDynamicWidget'),
   *   type: DynamicWidget,
   *   value: myComponent
   * });
   *
   * @example
   * <caption>Subclass an existing dynamic spandrel widget, making changes
   * before rendering.</caption>
   *
   * // our superclass will render a <label> element, with a background
   * // determined by a widget property.
   * const LabelWidget = spandrel((value, { properties }) => {
   *   const label = document.createElement('label');
   *   label.innerText = value;
   *   label.style.background = properties.background || '';
   *   return label;
   * });
   *
   * const RedLabelWidget = spandrel((value, { renderSuper }) => {
   *
   *   // renderSuper will call back to the superclass, allowing your subclass
   *   // to edit the data before spandrel renders it to the page.
   *   //
   *   // you can optionally pass a function to renderSuper that will tweak the
   *   // widget state before the superclass renders its data. if no tweaking is
   *   // desired, just renderSuper() is fine.
   *   //
   *   return renderSuper((state) => {
   *     state.properties.background = 'lightpink';
   *
   *     // remember to return the new state.
   *     return state;
   *   })
   *     .then((label) => {
   *       // renderSuper will resolve the data exactly as rendered by the
   *       // superclass.
   *       label.style.color = 'red';
   *       return label;
   *     });
   * }, { extends: LabelWidget });
   */
  function spandrel(arg, params) {
    if (typeof arg === 'function') {
      //dynamically redefine the nested widget structure based on whatever
      //value is being loaded.
      return makeDynamic(arg, params);
    } else {
      // define a static nested widget structure.
      return makeStatic(arg, params);
    }
  }


  /**
   * Given spandrel input (potentially dynamically generated), spit out a build
   * context, where each member may potentially contain more nested data, that
   * will map to one or more fe.buildFor calls.
   *
   * @private
   * @param {module:bajaux/spandrel~SpandrelArg} arg
   * @param {module:bajaux/spandrel~WidgetState} widgetState configuration data derived
   * from the parent widget to contain all these spandrel-generated widgets
   * @returns {Promise.<module:bajaux/spandrel~BuildContext>}
   */
  spandrel.build = function (arg, widgetState) {
    return buildConfig(arg, widgetState);
  };

  /**
   * Use `spandrel.jsx` as your JSX pragma to convert your JSX into spandrel
   * config.
   *
   * @see module:bajaux/spandrel/jsx
   */
  spandrel.jsx = jsx.jsxToSpandrel;

  /**
   * @private
   * @param {module:bajaux/lifecycle/WidgetManager} manager
   */
  spandrel.$installDefaultWidgetManager = function (manager) {
    defaultManager = manager;
  };

  /**
   * @private
   * @returns {module:bajaux/lifecycle/WidgetManager}
   */
  spandrel.$getDefaultWidgetManager = function () {
    return defaultManager;
  };

  /**
   * @param {function(*): module:bajaux/spandrel~SpandrelArg} func
   * @param {Object} [params]
   * @param {Function} [params.extends]
   * @param {module:bajaux/lifecycle/WidgetManager} [params.manager]
   * @returns {function(new:module:bajaux/spandrel/SpandrelWidget)}
   */
  function makeDynamic(func, { extends: Super, manager } = {}) {
    class DynamicSpandrelWidget extends (Super || SpandrelWidget) {
      constructor(params) {
        super(...arguments);
        this.$manager = extractManager(params) || manager;
        switchboard(this, {
          render: { allow: 'oneAtATime', onRepeat: 'preempt' }
        });
        RequestLayoutMixin(this);
        this[$IS_DYNAMIC_SYMBOL] = true;
      }

      initialize(dom, params, layoutParams) {
        return super.initialize(dom, params, Object.assign({ quick: true }, layoutParams));
      }

      doLoad(value) {
        return this.render(value);
      }

      /**
       * @private
       * @param {*} value the value being loaded
       * @param {module:bajaux/spandrel~WidgetState} state the widget state
       * @returns {Promise.<*>} data produced by the widget, ready to be
       * rendered by spandrel
       */
      $render(value, state) {
        const { rootElement } = state;
        const oldClass = rootElement.getAttribute('class');
        const oldStyle = rootElement.getAttribute('style');
        const oldStyles = getInlineStyles(rootElement);

        // here, we make a copy because:
        // - we need to keep track of precisely what classes the widget adds to
        //   its classList, therefore we have to start with a blank classList.
        // - if we just set the classList to empty on the real live element,
        //   class-based selectors will stop working if the selection happens
        //   while the element is in the 'in-between' state while
        //   $renderSpandrelData is being called.
        const copyClassList = document.createElement(rootElement.tagName).classList;
        const origClassList = rootElement.classList;
        setClassList(rootElement, copyClassList);

        return this.$renderSpandrelData(value, state)
          .then((spandrelArg) => {
            const addedClasses = Array.prototype.slice.call(copyClassList);
            const addedStyles = getInlineStyles(rootElement);

            // record styles explicitly removed
            const oldProps = Object.keys(oldStyles);
            for (let i = 0, len = oldProps.length; i < len; i++) {
              const prop = oldProps[i];
              if (oldStyles[prop] && !addedStyles[prop]) {
                addedStyles[prop] = '';
              }
            }

            setClassList(rootElement, origClassList);
            attr(rootElement, 'class', oldClass);
            attr(rootElement, 'style', oldStyle);
            rootElement[$IS_DYNAMIC_SYMBOL] = true;
            return { spandrelArg, addedClasses, addedStyles };
          });
      }

      /**
       * Calls the render function that defines the class (the argument to
       * `spandrel` itself).
       * @param {*} value the value being loaded
       * @param {module:bajaux/spandrel~WidgetState} state the widget state
       * @returns {Promise}
       */
      $renderSpandrelData(value, state) {
        const widgetState = extend({}, state, {
          renderSuper: (func) => {
            if (typeof func === 'function') {
              return Promise.resolve(func(state))
                .then((newState) => super.$renderSpandrelData(value, newState));
            } else {
              return super.$renderSpandrelData(value, state);
            }
          }
        });
        return Promise.resolve(func.call(this, value, widgetState));
      }

      /**
       * Pass the given value back through the spandrel processing function,
       * regenerating the widget's structure as appropriate. This differs from
       * `load()` in that it changes the DOM structure only - it does not fire
       * load events or change the result of `this.value()`.
       *
       * @param {*} value
       * @param {module:bajaux/spandrel~WidgetState} [state] widget
       * state to use; if omitted the widget's current state will be used
       * @returns {Promise}
       */
      render(value, state) {
        return Promise.try(() => {
          return this.$render(value, state || this.state())
            .then((result) => this.$doBuild(result))
            .then(() => {
              this.$loadFinished = true;
              return this.layout({ quick: this !== this[$ROOT_SYMBOL] });
            });
        }).catch((err) => {
          if (!this.$manager) { throw err; }

          return this.$manager.error(err, this);
        });
      }

      // TODO: rerender() needs to be smart about not wiping modified editors.
      // if called in response to a MODIFY_EVENT, it rebuilds the editor you
      // were typing in and you lose focus. c.f. LayoutEditor: i should be able
      // to just [ MODIFY_EVENT, () => this.rerender() ]
      // TODO: rerender() needs to handle when read() resolves a type that is
      // not compatible with load().
      /**
       * Re-renders the widget based on the result of `this.read()`.
       *
       * @param {module:bajaux/spandrel~WidgetState} [state]
       * @returns {Promise}
       */
      rerender(state) {
        if (!this.isInitialized()) { return Promise.resolve(); }

        //TODO: switchboard onlyAfter
        return Promise.resolve(
          this.$loadFinished && this.read().then((v) => this.render(v, state)));
      }

      layout() {
        if (!this.$loadFinished) {
          return Promise.resolve();
        }
        return super.layout(...arguments);
      }

      doReadonly() {
        return this.rerender();
      }

      doEnabled() {
        return this.rerender();
      }

      //TODO: add lex support

      $doBuild({ spandrelArg, addedClasses, addedStyles }) {
        const dom = this.jq();
        const widgetState = this.state();
        const mgr = this.$manager || spandrel.$getDefaultWidgetManager();
        let prevClasses;
        let prevStyles;

        return spandrel.build(spandrelArg, widgetState)
          .then((curr) => {
            const prev = this.$spandrel;
            let differences;
            if (prev) {
              differences = diffBuildContexts(prev.spandrelData, curr);
              prevClasses = prev.addedClasses;
              prevStyles = prev.addedStyles;
            }

            this.$spandrel = {
              spandrelData: curr,
              addedClasses,
              addedStyles
            };

            if (!prev) {
              return buildWidgetsFromSpandrelData(this, curr, dom, mgr);
            }

            return differences &&
              applyDiffs(this, curr, prev.spandrelData, differences, this.jq()[0], mgr);
          })
          .then(() => {
            const { rootElement } = widgetState;
            const { classList, style } = rootElement;
            if (prevClasses && prevClasses.length) {
              classList.remove.apply(classList, difference(prevClasses, addedClasses));
            }
            if (prevStyles) {
              const removedStyles = difference(Object.keys(prevStyles),
                Object.keys(addedStyles));
              for (let i = 0, len = removedStyles.length; i < len; ++i) {
                style.removeProperty(removedStyles[i]);
              }
            }
            if (addedClasses.length) {
              classList.add.apply(classList, addedClasses);
            }
            Object.assign(style, addedStyles);
          });
      }
    }

    //we need SpandrelWidget functionality even if we didn't inherit from it
    if (Super) { SpandrelWidget.mixin(Super); }

    DynamicSpandrelWidget[$IS_DYNAMIC_SYMBOL] = true;

    return DynamicSpandrelWidget;
  }

  function makeStatic(spandrelArg, { manager = defaultManager } = {}) {
    return class extends SpandrelWidget {
      constructor() {
        super(...arguments);
        RequestLayoutMixin(this);
      }

      doInitialize(dom) {
        return spandrel.build(spandrelArg, this.state())
          .then((data) => buildWidgetsFromSpandrelData(this, data, dom, manager))
          .then(() => super.doInitialize(...arguments));
      }
    };
  }

  /**
   * @param {module:bajaux/Widget} widget
   * @param {module:bajaux/spandrel~BuildContext} buildConfig
   * @param {JQuery} dom
   * @param {module:bajaux/lifecycle/WidgetManager} manager
   * @returns {Promise}
   */
  function buildWidgetsFromSpandrelData(widget, buildConfig, dom, manager) {
    const { members, on } = buildConfig;
    if (on.length) { armHandlers(widget, dom, on); }
    return Promise.all(members.map((member) => {
      return doFeBuild(member, $(member.config.dom).appendTo(dom), manager,
        widget[$ROOT_SYMBOL], widget[$DEPTH_SYMBOL] + 1);
    }));
  }

  /**
   * @param {module:bajaux/Widget} owner
   * @param {module:bajaux/spandrel~BuildContext} curr current spandrel build context
   * @param {module:bajaux/spandrel~BuildContext} prev previous spandrel build context
   * @param {Array.<object>} differences deep-diff differences between the two
   * @param {HTMLElement} rootElement root element of the spandrel Widget
   * @param {module:bajaux/lifecycle/WidgetManager} manager
   * @returns {Promise} to be resolved when differences are applied
   */
  function applyDiffs(owner, curr, prev, differences, rootElement, manager) {
    const findNodePath = (path = []) => {
      let currMember = null,
        prevMember,
        currMembers = curr && curr.members,
        prevMembers = prev && prev.members;

      const keys = extractKeys(path)
        .map((key) => {
          currMember = currMembers[key] || {};
          prevMember = prevMembers[key] || {};
          currMembers = resolveObjectPath(currMember, PATH_TO_KID_CONTEXTS);
          prevMembers = resolveObjectPath(prevMember, PATH_TO_KID_CONTEXTS);
          return prevMember.key || key; //if the key changed, the old key is still in the dom
        });

      const node = keys.reduce(
        (node, key) => kidsMatching(node, key)[0], rootElement);

      return { node, keys, member: currMember };
    };

    const edits = {};
    const root = owner[$ROOT_SYMBOL];
    const depth = owner[$DEPTH_SYMBOL] + 1;
    const doEdit = (key, priority, doEdit) => {
      const existingEdit = edits[key];
      if (existingEdit && existingEdit.$priority === PRIORITY_REBUILD) {
        return existingEdit;
      } else {
        const promise = edits[key] = Promise.resolve(existingEdit).then(doEdit);
        promise.$priority = priority;
        return promise;
      }
    };

    /*
    TODO: do we need to optimize this?
    if we got a diff for the top-level widget and the event handler on the
    kid widget at the same time, spandrel was destroying the parent (which
    also destroys the kid) and re-arming the event handler on the (destroyed)
    kid at the same time.
    for now, we'll run the promises in serial since deep-diff should return the
    diffs in top-to-bottom order. but maybe we could eke out some more
    performance if we ran promises in distinct branches (never same branch) in
    parallel.
     */
    const setupEditFromDiff = (diff) => {
      let { kind, index, item, path } = diff;

      //additions/deletions only count if they're to lists of child widgets.
      //e.g. i might have added a new member to an array as in an OrdList.
      if (path && path.length && last(path) !== 'members') {
        kind = 'E';
      }

      switch (kind) {
        case 'E': {
          const { node, keys, member } = findNodePath(path);
          const editPath = keys.join('/');
          const changedProp = last(path);

          if (changedProp === 'on') {
            const widget = Widget.in(rootElement).queryWidget(keys.join('/'));
            // TODO: optimize this
            // the differing value could be just one member of `on` but we have
            // to wipe and re-arm all events at once
            const on = getValueFromBuildContext(curr, path, 'on');
            return armHandlers(widget, widget.jq(), on);
          }


          //special case for dom: don't do a full wipe and rebuild if dom is
          //reusable
          if (changedProp === 'dom') {
            const oldElement = node;
            const newElement = $(member.config.dom)[0];
            if (!requiresRebuild(oldElement, newElement)) {
              if (TRACE) {
                trace(owner, 'updating dom in-place at path {}', editPath);
              }
              return doEdit(editPath, PRIORITY_UPDATE,
                () => updateElement(oldElement, newElement, member.config.properties));
            } else if (TRACE) {
              trace(owner, 'rebuilding dom at path {} because it could not be diffed', editPath);
            }
          }

          if (changedProp === 'value') {
            const existing = Widget.in(node);
            const config = getValueFromBuildContext(curr, path, 'config');
            return Promise.resolve(manager.deriveConfiguredConstructor(config))
              .then((Ctor) => {
                if (Ctor && (existing instanceof Ctor)) {
                  if (TRACE) {
                    trace(owner, 'loading new value "{}" into widget {} at path {}',
                      member.config.value, existing.constructor.name, editPath);
                  }
                  return doEdit(editPath, PRIORITY_UPDATE, () => manager.load(existing, member.config));
                } else {
                  if (TRACE) {
                    trace(owner, 'rebuilding widget at {} because new value to load ' +
                      '"{}" is not compatible with existing widget {}', editPath,
                      member.config.value, existing && existing.constructor.name);
                  }
                  return doEdit(editPath, PRIORITY_REBUILD, () => rebuild($(node), member, manager, root, depth));
                }
              });
          }

          if (isUpdatableDiff(path)) {
            const widget = Widget.in($(node));
            if (widget instanceof SpandrelWidget && typeof widget.rerender === 'function') {
              if (TRACE) {
                trace(owner, 'updating "{}" widget property at path {}', changedProp, editPath);
              }
              return doEdit(editPath, PRIORITY_UPDATE, () => update(widget, member));
            } else if (TRACE) {
              trace(owner, 'could not update "{}" widget property at path {} ' +
                'because it was not a dynamic spandrel widget', changedProp, editPath);
            }
          }

          if (TRACE) {
            trace(owner, 'falling back to a full rebuild at path {} because ' +
              'widget property "{}" could not be updated in-place', editPath, changedProp);
          }
          return doEdit(editPath, PRIORITY_REBUILD, () => rebuild($(node), member, manager, root, depth));
        }
        case 'A':
          switch (item.kind) {
            case 'N': {
              const { keys, node } = findNodePath(path),
                addedObj = item.rhs;
              if (TRACE) {
                trace(owner, 'creating new widget at {}/{}', keys.join('/'), addedObj.key);
              }
              return doFeBuild(addedObj, $(addedObj.config.dom).appendTo(node), manager, root, depth);
            }
            case 'D': {
              const { keys, node } = findNodePath((path || []).concat(index));
              if (TRACE) {
                trace(owner, 'destroying {} at path {}', Widget.in(node).constructor.name, keys.join('/'));
              }
              return Widget.in(node).destroy().then(() => $(node).remove());
            }
          }
      }
    };

    return differences.reduce((prom, diff) => prom.then(() => setupEditFromDiff(diff)), Promise.resolve());
  }

  function isUpdatableDiff(path) {
    const lastInPath = last(path);
    return lastInPath === 'properties' ||
      lastInPath === 'readonly' ||
      lastInPath === 'enabled' ||
      lastInPath === 'writable';
  }

  /**
   * @param {JQuery} dom
   * @param {module:bajaux/spandrel~Member} member
   * @param {module:bajaux/lifecycle/WidgetManager} manager
   * @returns {Promise}
   */
  function rebuild(dom, member, manager, root, depth) {
    //TODO: support a RebuildStrategy
    const oldWidget = Widget.in(dom);

    if (oldWidget && !(oldWidget instanceof SpandrelWidget) && oldWidget.isModified()) {
      return Promise.resolve();
    }

    return Promise.resolve(oldWidget && oldWidget.destroy())
      .then(() => {
        const newDom = $(member.config.dom);
        dom.replaceWith(newDom);
        return doFeBuild(member, newDom, manager, root, depth);
      });
  }

  /**
   * @param {module:bajaux/spandrel/SpandrelWidget} widget
   * @param {module:bajaux/spandrel~Member} member
   * @returns {Promise}
   */
  function update(widget, member) {
    //TODO: support an UpdateStrategy
    return widget.applyParams(member.config)
      .then(() => widget.rerender());
  }

  /**
   * Given a config from a Spandrel member, determine what kind of widget
   * Spandrel should show for it.
   * @param {module:bajaux/lifecycle/WidgetManager~BuildParams} config the
   * `config` property from a Spandrel member
   * @param {module:bajaux/lifecycle/WidgetManager} manager
   * @returns {Promise.<Function>} promise to be resolved with the kind of
   * widget to show. If none is specified or it can't figure it out, default to
   * Widget which will do nothing but show some raw HTML.
   */
  function tryToDetermineType(config, manager) {
    const { kids } = config;
    if (kids) {
      const { members, on } = kids;
      return Promise.resolve(spandrel({ kids: members, on }, { manager }));
    }

    return manager.buildContext(extend({ formFactor: 'mini' }, config))
      .then((buildContext) => buildContext.widgetConstructor || Widget);
  }

  /**
   * Build a widget in this element, as configured.
   * @param {module:bajaux/spandrel~Member} member
   * @param {JQuery} dom
   * @param {module:bajaux/lifecycle/WidgetManager} manager
   * @returns {Promise.<module:bajaux/Widget>} the built widget
   */
  function doFeBuild({ key, config }, dom, manager, root, depth) {
    return tryToDetermineType(config, manager)
      .then((type) => {
        dom[0].spandrelKey = key;
        const data = extend({ manager }, config.data);
        const $quiet = !(config.properties && config.properties.$quiet === false);
        const params = extend({
          $constructorParams: { $quiet },
          layoutParams: { quick: true }
        }, config, { data, dom, type });
        return manager.makeFor(params)
          .then((ed) => {
            RequestLayoutMixin(ed, root || ed, depth, (err, widget) => manager.error(err, widget));
            return manager.buildFor(params, ed);
          });
      });
  }

  function armHandlers(widget, dom, on) {
    const prev = widget.$spandrelHandlers;

    if (prev) { prev.forEach(([ event, , handler ]) => dom.off(event, handler)); }

    //allow 1-dimensional array handler
    if (!isArray(on[0])) { on = [ on ]; }

    const spandrelHandlers = widget.$spandrelHandlers = [];

    on.forEach((arr) => {
      let [ event, selectorString, handler ] = arr;
      if (arr.length === 2) {
        handler = selectorString;
        selectorString = null;
      }

      const selectors = selectorString ? selectorString.split(',').map((s) => s.trim()) : null;

      const eventHandler = function (e) {
        const spandrelWidget = getContainingSpandrelWidget(e.target);
        let args = Array.prototype.slice.call(arguments, 1);

        if (spandrelWidget) {
          //Widget#trigger already passes itself as first argument - don't double up
          if (spandrelWidget === args[0]) { args = args.slice(1); }

          const path = getPathToKid(widget, spandrelWidget);
          if (!selectors || anyPathMatches(selectors, path.join('/'))) {
            return handler.apply(null, [ e, spandrelWidget ].concat(args));
          }
        }
      };
      spandrelHandlers.push([ event, selectorString, eventHandler ]);
      dom.on(event, eventHandler);
    });
  }

  /**
   * @param {string[]} path a property path through a nested spandrel config
   * @returns {string[]} the widget keys extracted from the path
   */
  function extractKeys(path) {
    // in the spandrel config, the path to the diff if nested is going
    // to follow the pattern:
    // members, key, config, kids, members, key, config, kids, members, key...
    return path.filter((p, i) => path[i - 1] === 'members');
  }

  function getValueFromBuildContext(ctx, path, name) {
    return resolveObjectPath(ctx, path.slice(0, lastIndexOf(path, name) + 1));
  }

  /**
   * Does a deep-get of a property path from an object. Works like the array
   * form of `_.property`.
   * @param {object} obj
   * @param {string[]} path
   * @returns {*}
   */
  function resolveObjectPath(obj, path) {
    return path.reduce((curr, prop) => curr[prop] || {}, obj);
  }

  /**
   * @param {string[]} selectors array of widget selectors
   * @param {string} path actual path to a queried widget
   * @returns {boolean}
   */
  function anyPathMatches(selectors, path) {
    return any(selectors, (selector) => pathMatches(path, selector));
  }

  function attr(el, name, attr) {
    if (attr) {
      el.setAttribute(name, attr);
    } else {
      el.removeAttribute(name);
    }
  }

  /**
   * @param {object} params
   * @returns {module:bajaux/lifecycle/WidgetManager} the manager passed either
   * as `params.data.manager` or `params.params.data.manager`
   */
  function extractManager(params = {}) {
    //TODO: does Widget itself need an API for this?
    let data = params.data;
    let manager = data && data.manager;
    if (!manager) {
      params = params.params;
      data = params && params.data;
      manager = data && data.manager;
    }
    return manager;
  }

  function trace(owner, msg, ...args) {
    logFinest(owner.constructor.name + ': ' + msg, ...args);
  }

  function setClassList(el, classList) {
    Object.defineProperty(el, 'classList', {
      configurable: true,
      enumerable: true,
      writable: true,
      value: classList
    });
  }

  return spandrel;
});