Search

Keyboard Shortcuts

Interactions

Interact with KeyLines using just the keyboard.

Keyboard Shortcuts
View live example →

Most interactions with KeyLines charts are performed using a mouse,but you can also enable full control of the chart using a keyboard. This is important for accessibility and useful for advanced users.

This demo uses a mixture of KeyLines and HTML events to show how you can control KeyLines using the keyboard. Use the shortcuts listed on the right-hand side to navigate and manipulate the chart. For example:

  • to change focus to a different visible node, press the TAB key. The node in focus has a large, light purple border.
  • to select or deselect the node currently in focus, press ENTER. Selected items have a thin, dark purple border and purple label background.
  • to search the chart, press the / key and type a search term. Type 'ta' to filter the search results, and then press Enter to zoom to and add the node you've found to the current selection.

For more information on KeyLines accessibility, see Accessibility and UX.

Key functions used:

import KeyLines from "keylines";
let chart;
let canvas;
let focusPool;
let labelsToIds;
let focusId = null;
let focusIndex = 0;
const searchDiv = document.getElementById("searchDiv");
const searchInput = document.getElementById("searchInput");
const containerDiv = document.getElementById("fullscreen") || document.body;

const KEYCODES = {
  TAB: 9,
  ENTER: 13,
  LARROW: 37,
  UARROW: 38,
  RARROW: 39,
  DARROW: 40,
  ZERO: 48,
  FF_PLUS: 61,
  Q: 81,
  FF_MINUS: 173,
  PLUS: 187,
  MINUS: 189,
  PERIOD: 190,
  FSLASH: 191,
};

const debounce = (fn, timeout = 100) => {
  let id;
  return (args) => {
    if (id) clearTimeout(id);
    id = setTimeout(() => {
      fn(args);
    }, timeout);
  };
};
const wrapAroundArrayIfNeeded = (array, index) => {
  const { length } = array;
  if (index < 0) return length - 1;
  if (index > length - 1) return 0;
  return index;
};
const prevInArray = (array, index) => wrapAroundArrayIfNeeded(array, index - 1);
const nextInArray = (array, index) => wrapAroundArrayIfNeeded(array, index + 1);
const addEventListenerOnce = (domElement, event, handler) => {
  const listener = () => {
    domElement.removeEventListener(event, listener);
    handler();
  };
  domElement.addEventListener(event, listener);
};
const isNode = (item) => item.type === "node";
const isHidden = (node) => !!node.hi;
const isInView = (node) => {
  const { x, y } = chart.viewCoordinates(node.x, node.y);
  const { width, height } = chart.viewOptions();
  return x > 0 && x < width && y > 0 && y < height;
};
const proximityCheck = (direction) => {
  const { zoom, width, height } = chart.viewOptions();
  // At all zoom levels, border is approximately same size as radius of standard sized node
  const border = 20 * zoom + 5;
  return {
    up: (node) => {
      const { y } = chart.viewCoordinates(node.x, node.y);
      return y < border;
    },
    down: (node) => {
      const { y } = chart.viewCoordinates(node.x, node.y);
      return height - y < border;
    },
    left: (node) => {
      const { x } = chart.viewCoordinates(node.x, node.y);
      return x < border;
    },
    right: (node) => {
      const { x } = chart.viewCoordinates(node.x, node.y);
      return width - x < border;
    },
  }[direction];
};
const selectionIsNearEdge = (direction) => {
  const isNearEdge = proximityCheck(direction);
  const selection = chart.selection();
  return selection.some((id) => {
    const item = chart.getItem(id);
    return isNode(item) && isInView(item) && isNearEdge(item);
  });
};
const lastSelectedVisibleNode = () => {
  const selection = chart.selection();
  const nodeId = selection.reverse().find((id) => {
    const item = chart.getItem(id);
    return isNode(item) && isInView(item);
  });
  return chart.getItem(nodeId);
};
const showTooltip = () => {
  const tooltip = document.createElement("div");
  tooltip.id = "tt";
  tooltip.className = "tooltip";
  tooltip.style.display = "block";
  const { top, left } = searchInput.getBoundingClientRect();
  tooltip.style.top = `${Math.round(top) - 22}px`;
  tooltip.style.left = `${Math.round(left)}px`;
  tooltip.innerHTML = "To return to the chart press the TAB key";
  containerDiv.appendChild(tooltip);
};
const hideTooltip = () => {
  const tooltip = document.getElementById("tt");
  if (tooltip) {
    tooltip.remove();
  }
};
const activateItem = (direction) => {
  const contextMenu = document.getElementById("contextMenu");
  if (contextMenu) {
    const activeItem = contextMenu.querySelector("a.highlight");
    if (activeItem) activeItem.classList.remove("highlight");
    const items = [...contextMenu.querySelectorAll("a")];
    const indexOfActive = items.indexOf(activeItem) || 0;
    const index =
      direction === "above" ? prevInArray(items, indexOfActive) : nextInArray(items, indexOfActive);
    const item = items[index];
    item.classList.add("highlight");
  }
};
const clickActiveItem = () => {
  const contextMenu = document.getElementById("contextMenu");
  if (contextMenu) {
    const activeItem = contextMenu.querySelector("a.highlight");
    if (activeItem) activeItem.click();
  }
};
const navigateContextMenu = ({ keyCode, preventDefault }) => {
  preventDefault();
  switch (keyCode) {
    case KEYCODES.Q:
      hideContextMenu();
      break;
    case KEYCODES.ENTER:
      clickActiveItem();
      break;
    case KEYCODES.UARROW:
      activateItem("above");
      break;
    case KEYCODES.DARROW:
      activateItem("below");
      break;
    default:
      break;
  }
};
const createListItems = (opts) =>
  opts.map(({ id, text, onClick }) => {
    const li = document.createElement("li");
    const a = document.createElement("a");
    a.id = id;
    a.href = "#";
    a.tabIndex = -1;
    a.innerHTML = text;
    a.addEventListener("click", onClick);
    a.addEventListener("contextmenu", (event) => {
      event.preventDefault();
    });
    li.appendChild(a);
    return li;
  });
const getContextMenuPosition = (contextMenu, coords) => {
  const { x, y } = coords;
  const { width, height } = chart.viewOptions();
  const { width: w, height: h } = contextMenu.getBoundingClientRect();
  const { top: offsetY, left: offsetX } = canvas.getBoundingClientRect();
  const [baseY, baseX] = [y + offsetY, x + offsetX];
  const top = baseY + h < height ? baseY : baseY - h;
  const left = baseX + w < width ? baseX : baseX - w;
  return { top, left };
};
const getDefaultPosition = (contextMenu) => {
  const { width, height } = chart.viewOptions();
  const { width: w, height: h } = contextMenu.getBoundingClientRect();
  const top = (height - h) / 2;
  const left = (width - w) / 2;
  return { top, left };
};
const showContextMenu = (listItems, coords) => {
  hideContextMenu();
  const contextMenu = document.createElement("ul");
  contextMenu.id = "contextMenu";
  contextMenu.role = "menu";
  contextMenu.style.display = "block";
  contextMenu.className = "kl-dropdown-menu";
  listItems.forEach((li) => contextMenu.appendChild(li));
  containerDiv.appendChild(contextMenu);
  const { top, left } = coords
    ? getContextMenuPosition(contextMenu, coords)
    : getDefaultPosition(contextMenu);
  contextMenu.style.top = `${Math.round(top)}px`;
  contextMenu.style.left = `${Math.round(left)}px`;
  contextMenu.addEventListener("click", (event) => {
    event.preventDefault();
    hideContextMenu();
  });
  contextMenu.addEventListener("contextmenu", (event) => {
    event.preventDefault();
  });
  chart.off("key-down", navigateChart);
  chart.on("key-down", navigateContextMenu);
};
const hideContextMenu = () => {
  const contextMenu = document.getElementById("contextMenu");
  if (contextMenu) {
    chart.off("key-down", navigateContextMenu);
    chart.on("key-down", navigateChart);
    contextMenu.remove();
    canvas.focus();
  }
};
const clearFocus = () => {
  if (focusId) {
    chart.setProperties({ id: focusId, ha0: null });
  }
  focusId = null;
};
const changeFocus = (shift) => {
  clearFocus();
  focusIndex = shift ? prevInArray(focusPool, focusIndex) : nextInArray(focusPool, focusIndex);
  focusId = focusPool[focusIndex];
  chart.setProperties({ id: focusId, ha0: { c: "rgba(140, 100, 166, 0.5)", r: 35, w: 15 } });
};
const selectFocus = () => {
  const selection = chart.selection();
  chart.selection([...selection, focusId]);
};
const deselectFocus = () => {
  const selection = chart.selection();
  chart.selection(selection.filter((id) => id !== focusId));
};
const focusIsSelected = () => {
  const selection = chart.selection();
  return selection.includes(focusId);
};
const toggleFocusSelection = () => {
  if (focusIsSelected()) {
    deselectFocus();
  } else {
    selectFocus();
  }
};
const isFocusRemovedOrHidden = () => {
  const focus = chart.getItem(focusId);
  return !focus || isHidden(focus);
};
const updateState = () => {
  if (isFocusRemovedOrHidden()) clearFocus();
  focusPool = [];
  focusIndex = 0;
  labelsToIds = new Map();
  chart.each({ type: "node", items: "all" }, (node) => {
    if (!isHidden(node)) {
      const { id, t } = node;
      labelsToIds.set(t, id);
      if (isInView(node)) focusPool.push(id);
    }
  });
};
const createOptions = (labels) =>
  labels.map((label) => {
    const option = document.createElement("option");
    option.value = label;
    return option;
  });
const updateSelectionAndZoomToNode = (label) => {
  const id = labelsToIds.get(label);
  const selection = chart.selection();
  chart.selection([...selection, id]);
  chart.zoom("fit", { animate: true, time: 500, ids: [id] });
};
const updateDatalist = () => {
  let datalist = document.getElementById("employees");
  if (datalist) {
    searchDiv.removeChild(datalist);
    searchInput.removeAttribute("list");
  }
  datalist = document.createElement("datalist");
  datalist.id = "employees";
  const labels = [...labelsToIds.keys()];
  const options = createOptions(labels);
  options.forEach((option) => datalist.appendChild(option));
  searchDiv.appendChild(datalist);
  searchInput.setAttribute("list", datalist.id);
};
const itemOpts = [
  {
    id: "hideSelectedNodes",
    text: "Hide selected nodes",
    onClick: () => {
      const selection = chart.selection();
      const props = [];
      chart.each({ type: "node" }, ({ id }) => {
        if (selection.includes(id)) props.push({ id, hi: true });
      });
      chart.animateProperties(props);
      updateState();
    },
  },
  {
    id: "removeSelectedNodes",
    text: "Remove selected nodes",
    onClick: () => {
      const selection = chart.selection();
      chart.removeItem(selection);
      updateState();
    },
  },
  {
    id: "addNeighbourNodesToSelection",
    text: "Add neighbour nodes to selection",
    onClick: () => {
      const selection = chart.selection();
      const neighbours = chart.graph().neighbours(selection);
      chart.selection([...selection, ...neighbours.nodes]);
    },
  },
];
const backgroundOpts = [
  {
    id: "showAll",
    text: "Show all hidden nodes",
    onClick: () => {
      chart.filter(() => true);
      updateState();
    },
  },
];
const panIfSelectionIsNearEdge = (direction) => {
  if (selectionIsNearEdge(direction)) {
    chart.pan(direction);
  }
};
const clearSelectionOrFocus = () => {
  const selection = chart.selection();
  const { length } = selection;
  if (length > 0) chart.selection([]);
  else clearFocus();
};
const enableSearch = () => {
  addEventListenerOnce(document.body, "keyup", () => {
    searchInput.focus();
  });
};
const switchMode = ({ modifierKeys: { consistentCtrl } }) => {
  if (!consistentCtrl) {
    chart.options({ handMode: false });
  }
};
const buildContextMenu = (node, coords) => {
  const opts = node ? itemOpts : backgroundOpts;
  const listItems = createListItems(opts);
  showContextMenu(listItems, coords);
};
const openContentMenu = () => {
  const node = lastSelectedVisibleNode();
  buildContextMenu(node, node ? chart.viewCoordinates(node.x, node.y) : null);
};
const navigateChart = ({ keyCode, modifierKeys: { shift }, preventDefault }) => {
  switch (keyCode) {
    case KEYCODES.ZERO:
      chart.zoom("fit");
      break;
    case KEYCODES.PLUS:
    case KEYCODES.FF_PLUS:
      chart.zoom("in");
      break;
    case KEYCODES.MINUS:
    case KEYCODES.FF_MINUS:
      chart.zoom("out");
      break;
    case KEYCODES.PERIOD:
      openContentMenu();
      break;
    case KEYCODES.LARROW:
      panIfSelectionIsNearEdge("left");
      break;
    case KEYCODES.UARROW:
      panIfSelectionIsNearEdge("up");
      break;
    case KEYCODES.RARROW:
      panIfSelectionIsNearEdge("right");
      break;
    case KEYCODES.DARROW:
      panIfSelectionIsNearEdge("down");
      break;
    case KEYCODES.Q:
      clearSelectionOrFocus();
      break;
    case KEYCODES.ENTER:
      toggleFocusSelection();
      break;
    case KEYCODES.FSLASH:
      enableSearch();
      preventDefault();
      break;
    case KEYCODES.TAB:
      changeFocus(shift);
      preventDefault();
      break;
    default:
      break;
  }
};
const initialiseView = () => {
  chart.zoom("fit");
  chart.layout("organic");
  chart.selection(["343112"]);
};
const initialiseInteractions = () => {
  canvas.focus();

  updateState();

  searchInput.addEventListener("focusin", (event) => {
    event.preventDefault();
    showTooltip();
    updateDatalist();
  });
  searchInput.addEventListener("focusout", (event) => {
    event.preventDefault();
    hideTooltip();
  });
  searchInput.addEventListener("keydown", (event) => {
    const { TAB, ENTER } = KEYCODES;
    if (event.keyCode === ENTER || event.keyCode === TAB) {
      const label = event.target.value;
      updateSelectionAndZoomToNode(label);
      if (event.keyCode === TAB) {
        event.preventDefault();
        canvas.focus();
      }
    }
  });
  chart.on("key-up", switchMode);
  chart.on("key-down", navigateChart);

  const debouncedUpdateState = debounce(updateState);
  const disableLinkSelection = ({ id, preventDefault }) => {
    const item = chart.getItem(id);
    if (item && !isNode(item)) preventDefault();
  };
  const contextMenuHandler = ({ id, x, y, preventDefault }) => {
    const item = chart.getItem(id);
    if (item) {
      if (isNode(item)) {
        buildContextMenu(item, { x, y });
      } else {
        chart.selection([]);
        preventDefault();
      }
    } else {
      chart.selection([]);
      buildContextMenu(null, { x, y });
    }
  };
  chart.on("click", hideContextMenu);
  chart.on("click", disableLinkSelection);
  chart.on("view-change", hideContextMenu);
  chart.on("view-change", debouncedUpdateState);
  chart.on("context-menu", contextMenuHandler);
};
const startKeyLines = async () => {
  chart = await KeyLines.create({
    id: "kl",
    container: "klchart",
    options: {
      logo: { u: "/images/Logo.png" },
      selectionColour: "rgb(120,100,150)",
    },
  });

  canvas = document.getElementById("kl");

  const data = await fetch("/keyboardshortcuts-data.json").then((res) => res.json());
  chart.load(data);
  initialiseView();
  initialiseInteractions();
};
window.addEventListener("DOMContentLoaded", startKeyLines);
<!doctype html>
<html lang="en" style="background-color: #2d383f">
  <head>
    <meta charset="utf-8" />
    <title>Keyboard Shortcuts</title>
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/keylines.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div id="klchart" class="klchart"></div>
    <script type="module" src="./code.js"></script>
  </body>
</html>
.key {
  display: inline-block;
  width: auto;
  min-width: 24px;
  color: #5e5e5e;
  font: bold 14px arial;
  text-decoration: none;
  text-align: center;
  margin: 3px auto;
  padding: 3px 6px;
  background: #eff0f2;
  border-radius: 3px;
  border-top: 1px solid #f5f5f5;
  box-shadow:
    inset 0 0 20px #e8e8e8,
    0 1px 0 #c3c3c3,
    0 1px 0 #c9c9c9,
    0 1px 2px #333;
  text-shadow: 0px 1px 0px #f5f5f5;
}

.inline-key {
  margin-right: 3px;
  margin-left: 6px;
}

.arrow-keys {
  color: #5e5e5e;
  font: bold 14px arial;
  margin: auto;
  vertical-align: center;
}

#shortcutList {
  margin-bottom: 10px;
}
#shortcutList tr {
  height: 29px;
}

#shortcutList tr > td:first-child {
  width: 97px;
  padding-right: 6px;
  white-space: nowrap;
}
hr {
  margin: 10px 0;
}

#searchInput {
  margin-bottom: 0;
}
.search-label > span {
  vertical-align: sub;
  vertical-align: -webkit-baseline-middle;
}
.search-label * {
  margin-top: -6px;
}
.tooltip {
  z-index: 1000;
  padding-left: 4px;
  padding-right: 4px;
  color: white;
  border-radius: 5px;
  background-color: #333;
  position: absolute;
  font-size: 10px;
  text-align: center;
}
/* removes the dropdown arrow from the seachbox */
input::-webkit-calendar-picker-indicator {
  display: none;
}

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.