惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
S
SegmentFault 最新的问题
V
Visual Studio Blog
J
Java Code Geeks
宝玉的分享
宝玉的分享
美团技术团队
博客园 - Franky
酷 壳 – CoolShell
酷 壳 – CoolShell
H
Hackread – Cybersecurity News, Data Breaches, AI and More
有赞技术团队
有赞技术团队
量子位
Martin Fowler
Martin Fowler
MyScale Blog
MyScale Blog
Google DeepMind News
Google DeepMind News
Jina AI
Jina AI
博客园 - 叶小钗
月光博客
月光博客
P
Proofpoint News Feed
D
DataBreaches.Net
Blog — PlanetScale
Blog — PlanetScale
博客园_首页
腾讯CDC
Microsoft Azure Blog
Microsoft Azure Blog
Stack Overflow Blog
Stack Overflow Blog

RSS Minima activity

Commits · main · Dan Wolff / RSS Minima · GitLab Simplify focusability check - just use CSS selector (54a793c2) · Commits · Dan Wolff / RSS Minima · GitLab Add options for wraparound; remove "singleton" version: use more constants (9645e697) · Commits · Dan Wolff / RSS Minima · GitLab Keyboard nav: Extract constants (bbbc3736) · Commits · Dan Wolff / RSS Minima · GitLab Keyboard nav: Refactor (1c3d583a) · Commits · Dan Wolff / RSS Minima · GitLab 89b30bd99cd47db3f069b1afa1dab30ae768e80f to af96dfe02ba2e1bb1f8e305ec418e1dff4b4ef11 · Dan Wolff / RSS Minima · GitLab Support specifying a copy format, e.g. a yt-dlp command (89b30bd9) · Commits · Dan Wolff / RSS Minima · GitLab a28898e0b013a75a7f103049894ecab36ae3f990 to 46ac7df8d6b151c6c6f1eede52fdb75a5dca4ce4 · Dan Wolff / RSS Minima · GitLab ef463f06d6b3e3850441482fba774d61a91ab477 to a28898e0b013a75a7f103049894ecab36ae3f990 · Dan Wolff / RSS Minima · GitLab 339ccf9ac2206fdd123a9a38b5c9b05c426392e7 to ef463f06d6b3e3850441482fba774d61a91ab477 · Dan Wolff / RSS Minima · GitLab Support enabling and disabling feeds (339ccf9a) · Commits · Dan Wolff / RSS Minima · GitLab 3dcfae9d95512e48127a97effc2cc5071b60ecdd to 085fced8c32f6a71b9b256fa44828d016dd5fc9b · Dan Wolff / RSS Minima · GitLab Show the add feed form in the 'highlight' area (3dcfae9d) · Commits · Dan Wolff / RSS Minima · GitLab 6346d59cd010c1513ac9b7d4bf0a67424eb2d3b0 to 1df02a1b329902ab0e56fd1976851b8c0f715e65 · Dan Wolff / RSS Minima · GitLab c4da933b50794d649c07324c051c8b60ed45a770 to 6346d59cd010c1513ac9b7d4bf0a67424eb2d3b0 · Dan Wolff / RSS Minima · GitLab e21d4054912d86e30be16c105f916d35661ccb6f to c4da933b50794d649c07324c051c8b60ed45a770 · Dan Wolff / RSS Minima · GitLab Update readme (e21d4054) · Commits · Dan Wolff / RSS Minima · GitLab f6bc674460abacff785466f41feba4f73d3140d6 to fe6633a595231107eeb35f03b1d202fe1bd67626 · Dan Wolff / RSS Minima · GitLab Dan Wolff / RSS Minima · GitLab
Refactor keyboard nav (b7f4c485) · Commits · Dan Wolff / ...
Dan Wolff · 2024-03-13 · via RSS Minima activity
Original line number Diff line number Diff line
function isElement(x: unknown): x is HTMLElement | SVGElement | MathMLElement {
  return x instanceof HTMLElement ||
    x instanceof SVGElement ||
    x instanceof MathMLElement;
import {
  findAll,
  findAllFocusable,
  findFocusableByIndexOrLast,
  isElement,
  isElementFocusable,
  Lazy,
} from "./util.ts";

type GridDirection = "up" | "down" | "left" | "right";
type SequenceDirection = "prev" | "next";

class GroupModel {
  readonly #groupElement;
  #cursorElement?: HTMLElement | SVGElement;
  #cursor?: number | [number, number];

  constructor(groupElement: HTMLElement | SVGElement) {
    this.#groupElement = groupElement;
  }

function findVisibleByIndexOrLast(
  container: Element | undefined,
  selector: string,
  index: number,
): Element | undefined {
  if (!container) return;
  const elements = findAllVisible(container, selector);
  return elements[index] ?? elements[elements.length - 1];
  get #groupType() {
    const type = this.#groupElement.dataset.kbdGroup;
    if (type === "vertical" || type === "horizontal" || type === "grid") {
      return type;
    }

function findAllVisible(container: Element, selector: string): Element[] {
  return [...container.querySelectorAll(selector)].filter(isElementFocusable);
    return undefined;
  }

/**
 * Determine whether an element is visible (or can scroll to) and whether it is focusable.
 */
function isElementFocusable(element: Element) {
  if (!isElement(element)) return false;
  setCursor(focusableElement: EventTarget | null) {
    // Already handled, nothing to do.
    if (this.#cursorElement === focusableElement) return;

  if (element instanceof HTMLElement && !element.offsetParent) return false;
    if (!isElement(focusableElement)) return;

  if ("disabled" in element && element.disabled) return false;
    if (!focusableElement.matches("[data-kbd-focusable]")) return;

  // Check if the element has a non-zero width and height.
  const rect = element.getBoundingClientRect();
  if (rect.width === 0 || rect.height === 0) return false;
    const groupElement = this.#groupElement;
    if (!groupElement.contains(focusableElement)) return;

  // Check if the element is not hidden by CSS.
  const style = getComputedStyle(element);
  if (style.display === "none" || style.visibility === "hidden") return false;
    const groupType = this.#groupType;
    if (groupType === "vertical" || groupType === "horizontal") {
      const focusables = findAllFocusable(groupElement, "[data-kbd-focusable]");
      const index = focusables.indexOf(focusableElement);
      if (index === -1) return;

      this.#cursor = index;
    } else if (groupType === "grid") {
      const row = focusableElement.closest("[data-kbd-grid-row]");
      if (!isElement(row)) return;

      const rows = findAll(groupElement, "[data-kbd-grid-row]");
      const rowFocusables = findAllFocusable(row, "[data-kbd-focusable]");
      const rowIndex = rows.indexOf(row);
      const colIndex = rowFocusables.indexOf(focusableElement);

      if (rowIndex === -1 || colIndex === -1) return;

      this.#cursor = [colIndex, rowIndex];
    }

    this.#cursorElement = focusableElement;
  }

  #focus(
    focusable: Element | undefined,
    cursor: number | [number, number],
  ): boolean {
    if (!isElement(focusable)) return false;

    focusable.focus();
    this.#cursor = cursor;
    this.#cursorElement = focusable;
    return true;
  }

  #attemptToFocusSequence(direction: -1 | 1): boolean {
    const groupType = this.#groupType;
    const cursor = this.#cursor;
    if (groupType !== "vertical" && groupType !== "horizontal") return false;
    if (typeof cursor !== "number") return false;

    const focusables = findAllFocusable(
      this.#groupElement,
      "[data-kbd-focusable]",
    );

    return this.#focus(focusables[cursor + direction], cursor + direction);
  }

  #attemptToFocusUpDown(direction: -1 | 1): boolean {
    const cursor = this.#cursor;
    if (this.#groupType !== "grid") return false;
    if (!Array.isArray(cursor)) return false;

    const [cursorX, cursorY] = cursor;

    const rows = findAll(this.#groupElement, "[data-kbd-grid-row]");
    const row = rows[cursorY + direction];
    const focusable = findFocusableByIndexOrLast(
      row,
      "[data-kbd-focusable]",
      cursorX,
    );
    return this.#focus(focusable, [cursorX, cursorY + direction]);
  }

  #attemptToFocusLeftRight(direction: -1 | 1): boolean {
    const cursor = this.#cursor;
    if (this.#groupType !== "grid") return false;
    if (!this.#cursorElement) return false;
    if (!Array.isArray(cursor)) return false;

    const [, cursorY] = cursor;

    const rows = findAll(this.#groupElement, "[data-kbd-grid-row]");
    const row = rows[cursorY];
    const focusables = findAllFocusable(row, "[data-kbd-focusable]");
    const cursorX = focusables.indexOf(this.#cursorElement);
    const focusable = focusables[cursorX + direction];
    return this.#focus(focusable, [cursorX + direction, cursorY]);
  }

  focusCurrent(): boolean {
    if (this.#cursorElement) {
      this.#cursorElement.focus();
      return true;
    } else {
      const firstElement = this.#groupElement.querySelector(
        "[data-kbd-focusable]",
      );

      if (isElement(firstElement)) {
        this.setCursor(firstElement);
        firstElement.focus();
        return true;
      }
    }

    return false;
  }

  moveFocus(direction: GridDirection): boolean {
    const groupType = this.#groupType;

    if (
      (groupType === "vertical" && direction === "up") ||
      (groupType === "horizontal" && direction === "left")
    ) {
      return this.#attemptToFocusSequence(-1);
    } else if (
      (groupType === "vertical" && direction === "down") ||
      (groupType === "horizontal" && direction === "right")
    ) {
      return this.#attemptToFocusSequence(1);
    } else if (groupType === "grid") {
      if (direction === "up") {
        return this.#attemptToFocusUpDown(-1);
      } else if (direction === "down") {
        return this.#attemptToFocusUpDown(1);
      } else if (direction === "left") {
        return this.#attemptToFocusLeftRight(-1);
      } else if (direction === "right") {
        return this.#attemptToFocusLeftRight(1);
      }
    }

    return false;
  }
}

class DocumentModel {
  readonly #groupModels = new WeakMap<Element, GroupModel>();

  getGroupModel(element: EventTarget | null): GroupModel | undefined {
    if (!isElement(element)) return;

    const groupElement = element.closest("[data-kbd-group]");
    if (!isElement(groupElement)) return;

    let groupData = this.#groupModels.get(groupElement);
    if (!groupData) {
      groupData = new GroupModel(groupElement);
      this.#groupModels.set(groupElement, groupData);
    }

    return groupData;
  }

  getAdjacentGroupModel(
    element: EventTarget | null,
    direction: SequenceDirection,
  ): GroupModel | undefined {
    if (!isElement(element)) return;

    const groupElement = element.closest("[data-kbd-group]");
    if (!isElement(groupElement)) return;

    const groupElements = findAll(document.body, "[data-kbd-group]");
    const length = groupElements.length;
    const index = groupElements.indexOf(groupElement);
    const dir = direction === "prev" ? -1 : 1;
    const newIndex = index + dir;
    const newIndexWrappedAround = (newIndex + length) % length;

    return this.getGroupModel(groupElements[newIndexWrappedAround]);
  }
}

export class KeyboardNav {
  enable() {
    document.addEventListener("keydown", this.#listener);
    document.addEventListener("keydown", this.#keydownListener);
    document.addEventListener("focus", this.#focusListener, { capture: true });
  }

  disable() {
    document.removeEventListener("keydown", this.#listener);
    document.removeEventListener("keydown", this.#keydownListener);
    document.removeEventListener("focus", this.#focusListener);
  }

  #subTreeObserver?: MutationObserver;
  readonly #documentModel = new DocumentModel();

  readonly #subTreeObserver = new Lazy(() =>
    new MutationObserver(() => {
      if (!this.#lastFocused) return;
      const { element, getReplacement, gridTargetX } = this.#lastFocused;
      if (isElementFocusable(element)) return;

      const replacement = getReplacement();
      if (!replacement || !document.contains(replacement)) return;
      this.#focusElement({ element: replacement, getReplacement, gridTargetX });
    })
  );

  #lastFocused?: {
    element: Element;
@@ -70,150 +253,82 @@ export class KeyboardNav {
    this.#lastFocused = { element, getReplacement, gridTargetX };
    element.focus();

    this.#subTreeObserver ??= new MutationObserver(() => {
      if (!this.#lastFocused) return;
      const { element, getReplacement, gridTargetX } = this.#lastFocused;
      if (isElementFocusable(element)) return;

      const replacement = getReplacement();
      if (!replacement || !document.contains(replacement)) return;
      this.#focusElement({ element: replacement, getReplacement, gridTargetX });
    });
    const subTreeObserver = this.#subTreeObserver;
    subTreeObserver.observe(document, {
    this.#subTreeObserver.value.observe(document, {
      childList: true,
      subtree: true,
      attributes: true,
    });

    (element as HTMLElement).addEventListener(
    element.addEventListener(
      "blur",
      () => {
        if (this.#lastFocused?.element === element) {
          this.#lastFocused = undefined;
          subTreeObserver.disconnect();
          this.#subTreeObserver.value.disconnect();
        }
      },
      { once: true },
    );
  }

  #listener = (e: KeyboardEvent) => {
    const key = e.key;
    if (
      key !== "ArrowDown" && key !== "ArrowUp" &&
      key !== "ArrowLeft" && key !== "ArrowRight"
    ) return;

    if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;

    if (!isElement(e.target)) return;
  #focusListener = (e: FocusEvent) => {
    this.#documentModel.getGroupModel(e.target)?.setCursor(e.target);
  };

    const focusable = e.target.closest("[data-kbd-focusable]");
    if (!focusable) return;
  static #keyDirectionMap: Record<string, GridDirection> = {
    ArrowDown: "down",
    ArrowUp: "up",
    ArrowLeft: "left",
    ArrowRight: "right",
  };

    const group = focusable.closest("[data-kbd-group]");
    if (!isElement(group)) return;
  #keydownListener = (e: KeyboardEvent) => {
    const key = e.key;

    const groupType = group.dataset.kbdGroup;
    if (groupType === "vertical" || groupType === "horizontal") {
      const focusables = findAllVisible(group, "[data-kbd-focusable]");
      const index = focusables.indexOf(focusable);
      let targetIndex = index;
    if (e.altKey || e.ctrlKey || e.metaKey) return;

      if (
        (groupType === "vertical" && key === "ArrowUp") ||
        (groupType === "horizontal" && key === "ArrowLeft")
      ) {
        targetIndex--;
      } else if (
        (groupType === "vertical" && key === "ArrowDown") ||
        (groupType === "horizontal" && key === "ArrowRight")
      ) {
        targetIndex++;
      } else {
        return;
      }
    let didFocus: boolean | undefined;

      this.#focusElement({
        event: e,
        element: focusables[targetIndex],
        getReplacement: () =>
          findVisibleByIndexOrLast(
            group,
            "[data-kbd-focusable]",
            targetIndex,
          ),
        gridTargetX: undefined,
      });
    } else if (groupType === "grid") {
      const row = focusable.closest("[data-kbd-grid-row]");
      if (!isElement(row)) return;

      const rows = findAllVisible(group, "[data-kbd-grid-row]");
      const rowFocusables = findAllVisible(row, "[data-kbd-focusable]");
      const rowIndex = rows.indexOf(row);
      const colIndex = rowFocusables.indexOf(focusable);

      let targetX = this.#lastFocused?.gridTargetX ?? colIndex;
      let targetY = rowIndex;

      if (key === "ArrowUp") {
        targetY--;
      } else if (key === "ArrowLeft") {
        targetX = colIndex - 1;
      } else if (key === "ArrowDown") {
        targetY++;
      } else if (key === "ArrowRight") {
        targetX = colIndex + 1;
      } else {
        return;
    if (key === "Tab") {
      didFocus = this.#documentModel.getAdjacentGroupModel(
        e.target,
        e.shiftKey ? "prev" : "next",
      )
        ?.focusCurrent();
    } else if (!e.shiftKey && KeyboardNav.#keyDirectionMap[key]) {
      didFocus = this.#documentModel.getGroupModel(e.target)?.moveFocus(
        KeyboardNav.#keyDirectionMap[key],
      );
    }

      this.#focusElement({
        event: e,
        element: targetY === rowIndex
          ? rowFocusables[targetX]
          : findVisibleByIndexOrLast(
            rows[targetY],
            "[data-kbd-focusable]",
            targetX,
          ),
        getReplacement: () => {
          const row = findVisibleByIndexOrLast(
            group,
            "[data-kbd-grid-row]",
            targetY,
          );
          return findVisibleByIndexOrLast(row, "[data-kbd-focusable]", targetX);
        },
        gridTargetX: targetX,
      });
    if (didFocus) {
      e.preventDefault();
    }
  };
}

export class SingletonKeyboardNav {
  #count = 0;
  #kbd = new KeyboardNav();
  #enabledCounter = 0;
  #keyboardNav = new KeyboardNav();
  #warning;

  constructor(warning: string) {
    this.#warning = warning;
  }

  enable() {
    if (this.#count === 0) {
      this.#kbd.enable();
    if (this.#enabledCounter === 0) {
      this.#keyboardNav.enable();
    } else {
      console.warn(this.#warning);
    }
    this.#count++;
    this.#enabledCounter++;
  }

  disable() {
    this.#count--;
    if (this.#count === 0) {
      this.#kbd.disable();
    this.#enabledCounter--;
    if (this.#enabledCounter === 0) {
      this.#keyboardNav.disable();
    }
  }
}
Original line number Diff line number Diff line
export function isElement(x: unknown): x is HTMLElement | SVGElement {
  return x instanceof HTMLElement || x instanceof SVGElement;
}

export function findAll(container: Element, selector: string): Element[] {
  return [...container.querySelectorAll(selector)];
}

export function findAllFocusable(
  container: Element,
  selector: string,
): Element[] {
  return findAll(container, selector).filter(isElementFocusable);
}

export function findFocusableByIndexOrLast(
  container: Element | undefined,
  selector: string,
  index: number,
): Element | undefined {
  if (!container) return;
  const elements = findAllFocusable(container, selector);
  return elements[index] ?? elements[elements.length - 1];
}

/**
 * Determine whether an element can be focused.
 */
export function isElementFocusable(element: Element): boolean {
  if (!isElement(element)) return false;

  if (!element.offsetParent) return false;

  if (
    element instanceof HTMLButtonElement ||
    element instanceof HTMLInputElement ||
    element instanceof HTMLTextAreaElement
  ) {
    if (element.disabled || element.closest("fieldset[disabled]")) return false;
  } else if (element instanceof HTMLAnchorElement) {
    if (!element.href) return false;
  } else {
    if (element.tabIndex === -1 && !element.isContentEditable) return false;
  }

  // Check if the element is not hidden by CSS.
  const style = getComputedStyle(element);
  if (style.display === "none" || style.visibility === "hidden") return false;

  return true;
}

export class Lazy<T> {
  #init?: () => T;
  #value?: T;

  get value() {
    if (this.#init) {
      this.#value = this.#init();
      this.#init = undefined;
    }

    return this.#value as T;
  }

  constructor(init: () => T) {
    this.#init = init;
  }
}