aurelia/aurelia

View on GitHub
packages/__tests__/src/3-runtime-html/focus.spec.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import { Constructable } from '@aurelia/kernel';
import { PLATFORM, assert, eachCartesianJoin, TestContext, createFixture } from '@aurelia/testing';
import { isNode } from '../util.js';

describe('3-runtime-html/focus.spec.ts', function () {
  // there are focus/blur API that won't work with JSDOM
  // example of error thrown:
  // Error: Not implemented: window.blur
  // at module.exports (/home/circleci/repo/node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17)
  // at Window.blur (/home/circleci/repo/node_modules/jsdom/lib/jsdom/browser/Window.js:563:7)
  // at assertionFn (file:///home/circleci/repo/packages/__tests__/dist/esm/__tests__/3-runtime-html/focus.spec.js:231:25)
  // at Context.<anonymous> (file:///home/circleci/repo/packages/__tests__/dist/esm/__tests__/3-runtime-html/focus.spec.js:282:23) undefined
  if (typeof process !== 'undefined') {
    return;
  }

  interface IApp {
    hasFocus?: boolean;
    isFocused?: boolean;
    isFocused2?: boolean;
    selectedOption?: string;
  }

  describe('basic scenarios', function () {

    describe('with non-focusable element', function () {
      it('focuses when there is tabindex attribute', async function () {
        const { appHost, component, ctx } = createFixture(
          `<template>
            <div focus.two-way="hasFocus" id="blurred" tabindex="-1"></div>
          </template>`,
          class App {
            public hasFocus = true;
          }
        );

        const activeElement = ctx.doc.activeElement;
        const div = appHost.querySelector('app div');
        assert.notEqual(div, null, '<app/> <div/> not null');
        assert.equal(activeElement.tagName, 'DIV', 'activeElement === <div/>');
        assert.equal(activeElement, div, 'activeElement === <div/>');
        assert.equal(component.hasFocus, true, 'It should not have affected component.hasFocus');
      });
    });

    it('invokes focus when there is **NO** tabindex attribute', function () {
      let callCount = 0;
      PLATFORM.window.HTMLDivElement.prototype.focus = function () {
        callCount++;
        return PLATFORM.HTMLElement.prototype.focus.call(this);
      };

      const { testHost, component, ctx } = createFixture(
        `<template>
          <div focus.two-way="hasFocus" id="blurred"></div>
        </template>`,
        class App {
          public hasFocus = true;
        }
      );

      const activeElement = ctx.doc.activeElement;
      const div = testHost.querySelector('app div');
      assert.equal(callCount, 1, 'It should have invoked focus on DIV element prototype');
      assert.notEqual(div, null, '<app/> <div/> should not be null');
      assert.notEqual(activeElement.tagName, 'DIV');
      assert.notEqual(activeElement, div);
      assert.equal(component.hasFocus, true, 'It should not have affected component.hasFocus');

      // focus belongs to HTMLElement class
      delete PLATFORM.window.HTMLDivElement.prototype.focus;
    });

    const specs = [
      ['<input/>', `<input focus.two-way=hasFocus id=blurred>`],
      ['<select/>', `<select focus.two-way=hasFocus id=blurred></select>`],
      ['<button/>', '<button focus.two-way=hasFocus id=blurred></button>'],
      ['<video/>', '<video tabindex=1 focus.two-way=hasFocus id=blurred></video>'],
      ['<select/> + <option/>', `<select focus.two-way=hasFocus id=blurred><option tabindex=1>Hello</option></select>`],
      ['<textarea/>', `<textarea focus.two-way=hasFocus id=blurred></textarea>`]
    ];
    if (!isNode()) {
      specs.push(['<div/>', '<div contenteditable focus.two-way=hasFocus id=blurred></div>']);
    }
    for (const [desc, template] of specs) {
      describe(`with ${desc}`, function () {
        it('Works in basic scenario', function () {
          const { testHost, component, ctx } = createFixture(
            `<template>
              ${template}
            </template>`,
            class App {
              public hasFocus = true;
            }
          );

          const elName = desc.replace(/^<|\/>.*$/g, '');
          const activeElement = ctx.doc.activeElement;
          const focusable = testHost.querySelector(`app ${elName}`);
          assert.notEqual(focusable, null, `focusable el (<${elName}/>) is not null`);
          assert.equal(activeElement.tagName, elName.toUpperCase());
          assert.equal(activeElement, focusable);
          assert.equal(component.hasFocus, true, 'It should not have affected component.hasFocus');
        });
      });
    }

    // Doesn't seem to be implemented yet in JSDOM
    if (PLATFORM.window.customElements === void 0) {
      return;
    }
    // For combination with native custom element, there needs to be tests based on several combinations
    // Factors that need to be considered are: shadow root, shadow root with a focusable element,
    //                  no shadow root, no shadow root with a focusable element
    //                  tab-index/ content-editable attribute on custom element itself
    //
    // Assertion should at least focus on which active element is
    //                  How the component will be affected by the start up
    //                  Focus method on custom element has been invoked
    describe('CustomElements -- Initialization only', function () {

      // when shadowModes is null, custom element sets its innerHTML directly on it own
      // instead of its shadow root
      const shadowModes: ShadowRootMode[] = ['open', 'closed', null];
      const ceTemplates = [
        '<input />',
        '<textarea></textarea>',
        '<select><option></option><option></option></select>',
        '<div contenteditable="true"></div>',
        '<div tabindex="1"></div>'
      ];
      // controls tests of focusability of the native custom element
      const ceProps: Record<string, any>[] = [
        { tabIndex: 1 },
        { contentEditable: true },
        // Test case: CE itself is not focusable
        {}
      ];

      eachCartesianJoin(
        [shadowModes, ceTemplates, ceProps],
        (shadowMode, ceTemplate, ceProp) => {
          const hasShadowRoot = shadowMode !== null;
          const isFocusable = ceProp && (typeof ceProp.tabIndex !== 'undefined' || ceProp.contentEditable);
          const ceName = `ce-${Math.random().toString().slice(-6)}`;

          it(`works with ${isFocusable ? 'focusable' : ''} custom element ${ceName}, #shadowRoot: ${shadowMode}`, function () {
            const { testHost, start, component, ctx } = createFixture<IApp>(
              `<template><${ceName} focus.two-way=hasFocus></${ceName}></template>`,
              class App {
                public hasFocus = true;
              },
              [],
              false/* autoStart? */
            );
            const CustomEl = defineCustomElement(ctx, ceName, ceTemplate, { tabIndex: 1 }, shadowMode);
            let callCount = 0;
            // only track call, virtually no different without this layer
            Object.defineProperty(CustomEl.prototype, 'focus', {
              configurable: true,
              value: function focus(options?: FocusOptions): void {
                callCount++;
                if (hasShadowRoot) {
                  return HTMLElement.prototype.focus.call(this, options);
                } else {
                  const focusableEl = this.querySelector('input')
                    || this.querySelector('textarea')
                    || this.querySelector('select')
                    || this.querySelector('[contenteditable]')
                    || this.querySelector('[tabindex]');
                  if (focusableEl) {
                    return (focusableEl as HTMLElement).focus();
                  }
                  return HTMLElement.prototype.focus.call(this, options);
                }
              }
            });
            void start();

            const activeElement = ctx.doc.activeElement;
            const ceEl = testHost.querySelector(`app ${ceName}`);
            assert.equal(callCount, 1, 'It should have called focus()');
            assert.notEqual(ceEl, null);
            if (isFocusable) {
              if (hasShadowRoot) {
                assert.equal(activeElement.tagName, ceName.toUpperCase());
                assert.equal(activeElement, ceEl);
              } else {
                assert.notEqual(
                  activeElement,
                  ceEl,
                  'Custom element should NOT have focus when it has focusable light dom child'
                );
              }
            }
            assert.equal(component.hasFocus, true, 'It should not have affected component.hasFocus');
          });
        }
      );
    });
  });

  describe('Interactive scenarios', function () {
    const focusAttrs = [
      'focus.two-way=hasFocus',
      // 'focus.two-way=hasFocus',
      // 'focus="value.two-way: hasFocus"',
      // 'focus="value.bind: hasFocus"'
    ];
    const templates: IFocusTestCase[] = [
      {
        title: focusAttr => `Works when shifting focus away from <input/> [${focusAttr}]`,
        template: (focusAttr) => `
          <input ${focusAttr} />
          <div></div>
          <button>Click me</button>
        `,
        getFocusable: 'input',
        app: class App {
          public hasFocus = true;
        },
        assertionFn: (ctx, testHost, component, focusable) => {
          const doc = ctx.doc;
          const win = ctx.wnd;
          const button = testHost.querySelector('button');
          button.focus();
          dispatchEventWith(ctx, focusable, 'blur', false);
          assert.equal(doc.activeElement, button);
          assert.equal(component.hasFocus, false, '+ button@focus');

          focusable.focus();
          dispatchEventWith(ctx, focusable, 'focus', false);
          assert.equal(doc.activeElement, focusable);
          assert.equal(component.hasFocus, true, 'input@focus');

          dispatchEventWith(ctx, win, 'blur', false);
          assert.equal(doc.activeElement, focusable);
          assert.equal(component.hasFocus, true, 'window@blur');
        }
      },
      {
        title: focusAttr => `Works when shifting focus away from <select/> [${focusAttr}]`,
        template: (focusAttr) => `<template>
          <select ${focusAttr} value.bind="anyThing">
            <option>1</option>
            <option>2</option>
          </select>
          <button>Click me</button>
        </template>`,
        getFocusable: 'select',
        app: class App {
          public hasFocus = true;
          public selectedOption: '1' | '2' = '1';
        },
        assertionFn: async (ctx, testHost, component, focusable) => {
          const doc = ctx.doc;
          const win = ctx.wnd;
          const button = testHost.querySelector('button');
          button.focus();
          dispatchEventWith(ctx, focusable, 'blur', false);
          assert.equal(doc.activeElement, button);
          assert.equal(component.hasFocus, false, '> button@focus');

          focusable.focus();
          dispatchEventWith(ctx, focusable, 'focus', false);
          assert.equal(doc.activeElement, focusable);
          assert.equal(component.hasFocus, true, 'select@focus');

          win.blur();
          assert.equal(doc.activeElement, focusable);
          assert.equal(component.hasFocus, true, 'window@blur');

          component.selectedOption = '2';
          ctx.platform.domQueue.flush();
          assert.equal(doc.activeElement, focusable);
          assert.equal(component.hasFocus, true, 'select@change');
        }
      },
      {
        title: focusAttr => `Multiple focus bindings and focus stealing between <input/> [${focusAttr}]`,
        template: (focusAttr) => `<template>
          <input ${focusAttr} id=input1>
          <input focus.two-way="isFocused2" id=input2>
          <button>Click me</button>
        </template>`,
        getFocusable: 'input',
        app: class App {
          public hasFocus = true;
          public isFocused2 = false;
        },
        assertionFn(ctx, testHost, component, focusable) {
          const input2: HTMLInputElement = testHost.querySelector('#input2');
          assert.notEqual(focusable, input2, '@setup: focusable === #input2');
          input2.focus();
          dispatchEventWith(ctx, input2, 'focus', false);
          dispatchEventWith(ctx, focusable, 'blur', false);
          assert.equal(ctx.doc.activeElement, input2, '#input2@focus -> document.activeElement === #input2');
          assert.equal(component.isFocused2, true, '#input2@focus -> component.isFocused2 === true');
          assert.equal(component.hasFocus, false, '#input2@focus -> component.hasFocus === false');
        }
      }
    ];

    eachCartesianJoin(
      [focusAttrs, templates],
      (command, { title, template, getFocusable, app, assertionFn }: IFocusTestCase) => {
        it(title(command), async function () {
          const { testHost, start, component, ctx } = createFixture<IApp>(
            template(command),
            app,
            [],
            false
          );

          await start();
          const doc = ctx.doc;
          const activeElement = doc.activeElement;
          const focusable: HTMLElement = typeof getFocusable === 'string'
            ? testHost.querySelector(getFocusable)
            : getFocusable(testHost);
          assert.notEqual(focusable, null);
          if (typeof getFocusable === 'string') {
            const parts = getFocusable.split(' ');
            assert.equal(activeElement.tagName, parts[parts.length - 1].toUpperCase());
          }
          assert.equal(activeElement, focusable, '@setup -> document.activeElement === focusable');
          assert.equal(component.hasFocus, true, 'It should not have affected component.hasFocus');
          await assertionFn(ctx, testHost, component, focusable);
        });
      }
    );

    interface IFocusTestCase<T extends IApp = IApp> {
      template: TemplateFn;
      app: Constructable<T>;
      assertionFn: AssertionFn;
      getFocusable: string | ((testHost: HTMLElement) => HTMLElement);
      title(focusAttr: string): string;
    }
  });

  function defineCustomElement(ctx: TestContext, name: string, template: string, props: Record<string, any> = null, mode: 'open' | 'closed' | null = 'open') {
    class CustomEl extends ctx.HTMLElement {
      public constructor() {
        super();
        if (mode !== null) {
          this.attachShadow({ mode }).innerHTML = template;
        } else {
          this.innerHTML = template;
        }
        for (const p in props) {
          this[p] = props[p];
        }
      }
    }
    ctx.platform.customElements.define(name, CustomEl);
    return CustomEl;
  }

  function dispatchEventWith(ctx: TestContext, target: EventTarget, name: string, bubbles = true) {
    target.dispatchEvent(new ctx.CustomEvent(name, { bubbles }));
  }

  type TemplateFn = (focusAttrBindingCommand: string) => string;

  interface AssertionFn<T extends IApp = IApp> {
    // eslint-disable-next-line @typescript-eslint/prefer-function-type
    (ctx: TestContext, testHost: HTMLElement, component: T, focusable: HTMLElement): void | Promise<void>;
  }
});