hokulea/aria-voyager

View on GitHub
packages/ember-aria-voyager/package/src/modifiers/listbox.ts

Summary

Maintainability
D
2 days
Test Coverage
import { IndexEmitStrategy, ItemEmitStrategy, Listbox, ReactiveUpdateStrategy } from 'aria-voyager';
import Modifier from 'ember-modifier';
import isEqual from 'lodash.isequal';

import type { EmitStrategy } from 'aria-voyager';
import type { NamedArgs, PositionalArgs } from 'ember-modifier';

function asArray(val?: unknown) {
  if (val === undefined) {
    return [];
  }

  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
  return Array.isArray(val) ? val : [val];
}

function createItemEmitter<T>(listbox: Listbox, options: NamedArgs<ListboxSignature<T>>) {
  return new ItemEmitStrategy(listbox, {
    select: (selection: HTMLElement[]) => {
      (options.select as ((selection: HTMLElement | HTMLElement[]) => void) | undefined)?.(
        options.multi ? selection : (selection[0] as HTMLElement)
      );
    },

    activateItem: (item: HTMLElement) => {
      (options.activateItem as ((item: HTMLElement) => void) | undefined)?.(item);
    }
  });
}

function createIndexEmitter<T>(listbox: Listbox, options: NamedArgs<ListboxSignature<T>>) {
  const findByIndex = (index: number) => {
    return (options as WithItems<T>).items[index] ?? undefined;
  };

  return new IndexEmitStrategy(listbox, {
    select: (selection: number[]) => {
      if (options.multi) {
        const items = selection
          .map((index) => findByIndex(index))
          .filter((i) => i !== undefined) as T[];

        (options.select as ((selection: T[]) => void) | undefined)?.(items);
      } else {
        const item = findByIndex(selection[0] as number);

        if (item) {
          (options.select as ((selection: T) => void) | undefined)?.(item);
        }
      }
    },

    activateItem: (index: number) => {
      const item = findByIndex(index);

      if (item) {
        (options.activateItem as ((item: T) => void) | undefined)?.(item);
      }
    }
  });
}

type WithItems<T> = {
  items: T[];
  selection?: T | T[];
  activateItem?: (item: T) => void;
} & (
  | {
      multi: true;
      select?: (selection: T[]) => void;
    }
  | {
      multi?: false;
      select?: (selection: T) => void;
    }
);

type OptionalItems = {
  items?: HTMLElement[];
  selection?: HTMLElement | HTMLElement[];
  activateItem?: (item: HTMLElement) => void;
} & (
  | {
      multi: true;
      select?: (selection: HTMLElement[]) => void;
    }
  | {
      multi?: false;
      select?: (selection: HTMLElement) => void;
    }
);

interface ListboxSignature<T> {
  Args: {
    Positional: [];
    Named: { disabled?: boolean } & (WithItems<T> | OptionalItems);
  };
}

export default class ListboxModifier<T> extends Modifier<ListboxSignature<T>> {
  private listbox?: Listbox;
  private declare updater: ReactiveUpdateStrategy;
  private declare emitter: EmitStrategy;

  private prevItems?: T[];
  private prevSelection?: T | T[];
  private prevMulti?: boolean;
  private prevDisabled?: boolean;

  modify(
    element: Element,
    _: PositionalArgs<ListboxSignature<T>>,
    options: NamedArgs<ListboxSignature<T>>
  ) {
    if (!this.listbox) {
      this.updater = new ReactiveUpdateStrategy();

      this.listbox = new Listbox(element as HTMLElement, {
        updater: this.updater
      });
    }

    if (options.items && !(this.emitter instanceof IndexEmitStrategy)) {
      this.emitter = createIndexEmitter<T>(this.listbox, options);
    } else if (!options.items && !(this.emitter instanceof ItemEmitStrategy)) {
      this.emitter = createItemEmitter<T>(this.listbox, options);
    }

    if (options.items && !isEqual(this.prevItems, (options as WithItems<T>).items)) {
      this.updater.updateItems();
      this.prevItems = [...(options as WithItems<T>).items];
    }

    if (options.selection && !isEqual(asArray(this.prevSelection), asArray(options.selection))) {
      this.updater.updateSelection();
      this.prevSelection = asArray(options.selection);
    }

    let optionsChanged = false;

    if (this.prevMulti !== options.multi) {
      if (options.multi) {
        element.setAttribute('aria-multiselectable', 'true');
      } else {
        element.removeAttribute('aria-multiselectable');
      }

      optionsChanged = true;

      this.prevMulti = options.multi;
    }

    if (this.prevDisabled !== options.disabled) {
      if (options.disabled) {
        element.setAttribute('aria-disabled', 'true');
      } else {
        element.removeAttribute('aria-disabled');
      }

      optionsChanged = true;

      this.prevDisabled = options.disabled;
    }

    if (optionsChanged) {
      this.updater.updateOptions();
    }
  }
}