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

推荐订阅源

罗磊的独立博客
U
Unit 42
N
Netflix TechBlog - Medium
人人都是产品经理
人人都是产品经理
Hugging Face - Blog
Hugging Face - Blog
腾讯CDC
小众软件
小众软件
V
Visual Studio Blog
T
Tailwind CSS Blog
Engineering at Meta
Engineering at Meta
博客园 - 叶小钗
GbyAI
GbyAI
爱范儿
爱范儿
雷峰网
雷峰网
Microsoft Azure Blog
Microsoft Azure Blog
D
DataBreaches.Net
博客园_首页
D
Docker
A
About on SuperTechFans
G
Google Developers Blog
I
InfoQ
T
The Blog of Author Tim Ferriss
V
V2EX
博客园 - Franky

RSS Minima activity

Commits · main · Dan Wolff / RSS Minima · GitLab Simplify focusability check - just use CSS selector (54a793c2) · 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 Refactor keyboard nav (b7f4c485) · 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
Add options for wraparound; remove "singleton" version: u...
Dan Wolff · 2024-03-15 · via RSS Minima activity
Commit 9645e697 authored by Dan Wolff's avatar Dan Wolff
Browse files

Add options for wraparound; remove "singleton" version: use more constants

Original line number Diff line number Diff line
export const ATTR_FOCUSABLE = "data-kbd-focusable";
export const ATTR_GRID_ROW = "data-kbd-grid-row";
export const ATTR_GROUP = "data-kbd-group";
const name = "keyboardnav";
export const ATTR_FOCUSABLE = `data-${name}-focusable`;
export const ATTR_GRID_ROW = `data-${name}-grid-row`;
export const ATTR_GROUP = `data-${name}-group`;

export const SEL_FOCUSABLE = `[${ATTR_FOCUSABLE}]`;
export const SEL_GRID_ROW = `[${ATTR_GRID_ROW}]`;
export const SEL_GROUP = `[${ATTR_GROUP}]`;

export const DATA_GROUP_TYPE = "kbdGroup";
export const DATA_GROUP_TYPE = `${name}Group`;
export const DATA_TEMPORARY_FOCUSABLE = `${name}Temp`;

export const GROUP_TYPE_GRID = "grid";
export const GROUP_TYPE_HORIZONTAL = "horizontal";
export const GROUP_TYPE_VERTICAL = "vertical";

export const MOVE_DOWN = "d";
export const MOVE_UP = "u";
export const MOVE_LEFT = "l";
export const MOVE_RIGHT = "r";
export const MOVE_PREV = "p";
export const MOVE_NEXT = "n";
Original line number Diff line number Diff line
import {
  DATA_GROUP_TYPE,
  DATA_TEMPORARY_FOCUSABLE,
  GROUP_TYPE_GRID,
  GROUP_TYPE_HORIZONTAL,
  GROUP_TYPE_VERTICAL,
  MOVE_DOWN,
  MOVE_LEFT,
  MOVE_NEXT,
  MOVE_PREV,
  MOVE_RIGHT,
  MOVE_UP,
  SEL_FOCUSABLE,
  SEL_GRID_ROW,
  SEL_GROUP,
} from "./constants.ts";
import {
  arrayAt,
  findAll,
  findAllFocusable,
  findAllVisible,
@@ -16,8 +24,12 @@ import {
  Lazy,
} from "./util.ts";

type GridDirection = "up" | "down" | "left" | "right";
type SequenceDirection = "prev" | "next";
type GridDirection =
  | typeof MOVE_UP
  | typeof MOVE_DOWN
  | typeof MOVE_LEFT
  | typeof MOVE_RIGHT;
type SequenceDirection = typeof MOVE_PREV | typeof MOVE_NEXT;

interface GroupModel {
  readonly type: string;
@@ -43,10 +55,12 @@ interface GridCurrent {
  cells: HTMLElement[];
}
class GridModel implements GroupModel {
  readonly #wraparound;
  #current?: GridCurrent;
  readonly type = GROUP_TYPE_GRID;

  constructor(readonly container: HTMLElement) {
  constructor(readonly container: HTMLElement, wraparound: boolean) {
    this.#wraparound = wraparound;
  }

  setCurrent(focusableElement: HTMLElement): boolean {
@@ -119,7 +133,7 @@ class GridModel implements GroupModel {
    const current = this.#getCurrent();
    if (!current) return false;

    const row = current.rows[current.y + direction];
    const row = arrayAt(current.rows, current.y + direction, this.#wraparound);
    if (!row) return false;

    const requestedX = current.requestedX ?? current.x;
@@ -142,7 +156,11 @@ class GridModel implements GroupModel {
    const current = this.#getCurrent();
    if (!current) return false;

    const cell = current.cells[current.x + direction];
    const cell = arrayAt(
      current.cells,
      current.x + direction,
      this.#wraparound,
    );
    if (!cell) return false;

    current.cell = cell;
@@ -154,13 +172,13 @@ class GridModel implements GroupModel {
  }

  moveFocus(direction: GridDirection): boolean {
    if (direction === "up") {
    if (direction === MOVE_UP) {
      return this.#tryMoveFocusUpDown(-1);
    } else if (direction === "down") {
    } else if (direction === MOVE_DOWN) {
      return this.#tryMoveFocusUpDown(1);
    } else if (direction === "left") {
    } else if (direction === MOVE_LEFT) {
      return this.#tryMoveFocusLeftRight(-1);
    } else if (direction === "right") {
    } else if (direction === MOVE_RIGHT) {
      return this.#tryMoveFocusLeftRight(1);
    }

@@ -174,11 +192,15 @@ interface SequenceCurrent {
  index: number;
}
class SequenceModel implements GroupModel {
  readonly #wraparound;
  #current?: SequenceCurrent;
  constructor(
    readonly type: typeof GROUP_TYPE_HORIZONTAL | typeof GROUP_TYPE_VERTICAL,
    readonly container: HTMLElement,
  ) {}
    wraparound: boolean,
  ) {
    this.#wraparound = wraparound;
  }

  setCurrent(focusableElement: HTMLElement): boolean {
    if (!isElementFocusable(focusableElement)) return false;
@@ -223,7 +245,11 @@ class SequenceModel implements GroupModel {
    const current = this.#getCurrent();
    if (!current) return false;

    const cell = current.cells[current.index + direction];
    const cell = arrayAt(
      current.cells,
      current.index + direction,
      this.#wraparound,
    );
    if (!cell) return false;

    current.cell = cell;
@@ -236,13 +262,13 @@ class SequenceModel implements GroupModel {
  moveFocus(direction: GridDirection): boolean {
    const type = this.type;
    if (
      (direction === "left" && type == GROUP_TYPE_HORIZONTAL) ||
      (direction === "up" && type === GROUP_TYPE_VERTICAL)
      (direction === MOVE_LEFT && type == GROUP_TYPE_HORIZONTAL) ||
      (direction === MOVE_UP && type === GROUP_TYPE_VERTICAL)
    ) {
      return this.#tryMoveFocus(-1);
    } else if (
      (direction === "right" && type == GROUP_TYPE_HORIZONTAL) ||
      (direction === "down" && type === GROUP_TYPE_VERTICAL)
      (direction === MOVE_RIGHT && type == GROUP_TYPE_HORIZONTAL) ||
      (direction === MOVE_DOWN && type === GROUP_TYPE_VERTICAL)
    ) {
      return this.#tryMoveFocus(1);
    }
@@ -253,19 +279,30 @@ class SequenceModel implements GroupModel {

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

  readonly #tabWraparound;
  readonly #creators: Record<
    string,
    ((container: HTMLElement) => GroupModel) | undefined
  > = {
    [GROUP_TYPE_GRID]: (x) => new GridModel(x),
    [GROUP_TYPE_HORIZONTAL]: (x) => new SequenceModel(GROUP_TYPE_HORIZONTAL, x),
    [GROUP_TYPE_VERTICAL]: (x) => new SequenceModel(GROUP_TYPE_VERTICAL, x),
  };
  >;

  getGroupModel(element: EventTarget | null): GroupModel | undefined {
    if (!isElement(element)) return;
  constructor(
    { tabWraparound = false, arrowKeyWraparound = true }: KeyboardNavOptions =
      {},
  ) {
    this.#tabWraparound = tabWraparound;

    this.#creators = {
      [GROUP_TYPE_GRID]: (x) => new GridModel(x, arrowKeyWraparound),

      [GROUP_TYPE_HORIZONTAL]: (x) =>
        new SequenceModel(GROUP_TYPE_HORIZONTAL, x, arrowKeyWraparound),

      [GROUP_TYPE_VERTICAL]: (x) =>
        new SequenceModel(GROUP_TYPE_VERTICAL, x, arrowKeyWraparound),
    };
  }

  getGroupModel(element: HTMLElement): GroupModel | undefined {
    const groupElement = element.closest(SEL_GROUP);
    if (!isElement(groupElement)) return;

@@ -287,26 +324,45 @@ class DocumentModel {
  }

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

    const groupElement = element.closest(SEL_GROUP);
    if (!isElement(groupElement)) return;

    const groupElements = findAll(document.body, SEL_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;
    const index = groupElements.indexOf(groupModel.container);
    if (index === -1) return;

    const dir = direction === MOVE_PREV ? -1 : 1;
    const adjacentElement = arrayAt(
      groupElements,
      index + dir,
      this.#tabWraparound,
    );

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

export interface KeyboardNavOptions {
  /**
   * Whether to
   * Default: `false`
   */
  tabWraparound?: boolean;

  /**
   * Whether
   * Default: `true`
   */
  arrowKeyWraparound?: boolean;
}

export class KeyboardNav {
  readonly #documentModel;

  constructor(options?: KeyboardNavOptions) {
    this.#documentModel = new DocumentModel(options);
  }

  enable() {
    document.addEventListener("keydown", this.#keydownListener);
    document.addEventListener("focus", this.#focusListener, { capture: true });
@@ -315,10 +371,10 @@ export class KeyboardNav {
  disable() {
    document.removeEventListener("keydown", this.#keydownListener);
    document.removeEventListener("focus", this.#focusListener);
    this.#subTreeObserver.valueIfInitialized?.disconnect();
    this.#temporaryFocusableDiv.valueIfInitialized?.remove();
  }

  readonly #documentModel = new DocumentModel();

  readonly #subTreeObserver = new Lazy(() =>
    new MutationObserver(() => {
      if (this.#lastFocused && !isElementFocusable(this.#lastFocused.element)) {
@@ -333,13 +389,27 @@ export class KeyboardNav {
    })
  );

  readonly #temporaryFocusableDiv = new Lazy(() => {
    const div = document.createElement("div");
    div.tabIndex = 0;
    div.dataset[DATA_TEMPORARY_FOCUSABLE] = "";
    Object.assign(div.style, {
      position: "absolute",
      width: 0,
      height: 0,
      overflow: "hidden",
    });
    div.onblur = () => div.remove();
    return div;
  });

  #lastFocused?: {
    group: GroupModel;
    element: HTMLElement;
  };

  #focusListener = (e: FocusEvent) => {
    this.#subTreeObserver.value.disconnect();
    this.#subTreeObserver.valueIfInitialized?.disconnect();

    const element = e.target;
    if (element instanceof HTMLElement) {
@@ -359,10 +429,10 @@ export class KeyboardNav {
  };

  static #keyDirectionMap: Record<string, GridDirection> = {
    ArrowDown: "down",
    ArrowUp: "up",
    ArrowLeft: "left",
    ArrowRight: "right",
    ArrowDown: MOVE_DOWN,
    ArrowUp: MOVE_UP,
    ArrowLeft: MOVE_LEFT,
    ArrowRight: MOVE_RIGHT,
  };

  #keydownListener = (e: KeyboardEvent) => {
@@ -370,14 +440,34 @@ export class KeyboardNav {

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

    if (!(e.target instanceof HTMLElement)) return;

    let didFocus: boolean | undefined;

    if (key === "Tab") {
      didFocus = !!this.#documentModel.getAdjacentGroupModel(
        e.target,
        e.shiftKey ? "prev" : "next",
      )
        ?.focusCurrent();
      const groupModel = this.#documentModel.getGroupModel(e.target);

      if (groupModel) {
        const adjacentModel = this.#documentModel.getAdjacentGroupModel(
          groupModel,
          e.shiftKey ? MOVE_PREV : MOVE_NEXT,
        );

        if (adjacentModel) {
          didFocus = !!adjacentModel.focusCurrent();
        } else {
          // We can't control focus fully, but we can ensure that tabbing twice
          // will allow the user to tab out of the page.
          const div = this.#temporaryFocusableDiv.value;
          if (e.shiftKey) {
            document.body.prepend(div);
          } else {
            document.body.append(div);
          }
          div.focus();
          didFocus = true;
        }
      }
    } else if (!e.shiftKey && KeyboardNav.#keyDirectionMap[key]) {
      didFocus = this.#documentModel.getGroupModel(e.target)?.moveFocus(
        KeyboardNav.#keyDirectionMap[key],
@@ -389,29 +479,3 @@ export class KeyboardNav {
    }
  };
}

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

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

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

  disable() {
    this.#enabledCounter--;
    if (this.#enabledCounter === 0) {
      this.#keyboardNav.disable();
    }
  }
}
Original line number Diff line number Diff line
import { createContext } from "preact";
import { SingletonKeyboardNav } from "./mod.ts";
import { createContext, RefObject } from "preact";
import { useContext, useEffect } from "preact/hooks";
import {
  ATTR_FOCUSABLE,
  ATTR_GRID_ROW,
  ATTR_GROUP,
  GROUP_TYPE_GRID,
  GROUP_TYPE_HORIZONTAL,
  GROUP_TYPE_VERTICAL,
} from "./constants.ts";
import { GROUP_TYPE_VERTICAL } from "./constants.ts";
import { GROUP_TYPE_HORIZONTAL } from "./constants.ts";
import { KeyboardNav, KeyboardNavOptions } from "./mod.ts";

const keyboardNavContext = createContext(
  new SingletonKeyboardNav(
    "useKeyboardNav should only be called in one place",
  ),
);
const keyboardNavContext = createContext<RefObject<KeyboardNav>>({
  current: null,
});

export const useKeyboardNav = () => {
export const useKeyboardNav = (options?: KeyboardNavOptions) => {
  const ctx = useContext(keyboardNavContext);
  useEffect(() => {
    ctx.enable();
    return () => ctx.disable();
  }, [ctx]);
    if (ctx.current) {
      console.warn("useKeyboardNav should only be called in one place");
      return;
    }

    const nav = new KeyboardNav(options);
    nav.enable();
    ctx.current = nav;

    return () => {
      nav.disable();
      ctx.current = null;
    };
  }, [ctx, options]);
};

export const keyboardProps = {
Original line number Diff line number Diff line
@@ -53,6 +53,16 @@ export function isElementFocusable(element: HTMLElement): boolean {
  return true;
}

export function arrayAt<T>(
  array: T[],
  index: number,
  allowWraparound: boolean,
): T | undefined {
  return allowWraparound
    ? array[(index + array.length) % array.length]
    : array[index];
}

export class Lazy<T> {
  #init?: () => T;
  #value?: T;
@@ -66,6 +76,10 @@ export class Lazy<T> {
    return this.#value as T;
  }

  get valueIfInitialized() {
    return this.#value;
  }

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