Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Focus #10

Merged
merged 7 commits into from
Jun 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@types/rimraf": "^3.0.0",
"@types/sinon": "^9.0.4",
"@types/sinon-chai": "^3.2.4",
"@types/tabbable": "^3.1.0",
"@types/webpack": "^4.41.13",
"@typescript-eslint/eslint-plugin": "^3.0.1",
"@typescript-eslint/parser": "^3.0.1",
Expand All @@ -54,4 +55,4 @@
"node": ">=10"
},
"repository": "git@github.com:idoros/zeejs.git"
}
}
5 changes: 3 additions & 2 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
},
"dependencies": {
"@popperjs/core": "^2.4.0",
"@zeejs/core": "^0.0.1"
"@zeejs/core": "^0.0.1",
"tabbable": "^4.0.0"
},
"files": [
"cjs",
Expand All @@ -29,4 +30,4 @@
},
"license": "MIT",
"repository": "https://github.com/idoros/zeejs/tree/master/packages/browser"
}
}
163 changes: 163 additions & 0 deletions packages/browser/src/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import tabbable from 'tabbable';

export function watchFocus(layersWrapper: HTMLElement) {
layersWrapper.addEventListener(`focus`, onFocus, { capture: true });
layersWrapper.addEventListener(`keydown`, onKeyDown, { capture: true });
return {
stop() {
layersWrapper.removeEventListener(`focus`, onFocus);
layersWrapper.removeEventListener(`keydown`, onKeyDown);
},
};
}

type Focusable = { focus: () => void };

const onFocus = (event: FocusEvent) => {
if (event.target && event.target instanceof HTMLElement) {
const layer = findContainingLayer(event.target);
if (!layer || layer.hasAttribute(`inert`)) {
// ToDo: skip in case focus is invoked by `onKeyDown`
const availableLayers = Array.from(
document.querySelectorAll<HTMLElement>(`zeejs-layer:not([inert])`)
);
while (availableLayers.length) {
const layer = availableLayers.shift()!;
const layerId = layer.id;
const origin = document.querySelector<HTMLElement>(`[data-origin="${layerId}"]`);
if (origin) {
const element = queryFirstTabbable(layer, origin, true);
if (element) {
element.focus();
return;
}
}
}
event.target.blur();
}
}
};

const onKeyDown = (event: KeyboardEvent) => {
if (event.code !== `Tab`) {
return;
}
const isForward = !event.shiftKey;
const activeElement = document.activeElement;
if (activeElement) {
const layer = findContainingLayer(activeElement);
if (layer) {
const nextElement = queryNextTabbable(layer, activeElement, isForward);
if (nextElement) {
event.preventDefault();
nextElement.focus();
}
}
}
};

function queryNextTabbable(
layer: HTMLElement,
currentElement: Element,
isForward: boolean
): Focusable | null {
const list = tabbable(layer);
if (list.length === 0) {
throw new Error(
`queryNextTabbable was called with currentElement that is not contained in layer`
);
}
const edgeIndex = isForward ? list.length - 1 : 0;
const currentIndex = list.indexOf(currentElement as HTMLElement);
if (currentIndex === edgeIndex) {
const layerId = layer.id;
if (!layerId) {
// top layer
if (isForward) {
// loop to start
return queryTabbableElement(layer, list[0], isForward);
} else {
// move backward on root layer - do nothing
// let browser navigate to chrome (URL)
return null;
}
} else {
// nested layer
const originElement = document.querySelector(`[data-origin="${layerId}"]`);
if (!originElement) {
// ToDo: handle missing origin?
return null;
}
const originLayer = findContainingLayer(originElement);
if (!originLayer) {
// ToDo: handle missing origin layer, maybe return originElement?
return null;
}
// stay in layer if parent is inert (trap focus)
if (originLayer.hasAttribute(`inert`)) {
const loopBackToElement = isForward ? list[0] : list[list.length - 1];
return queryTabbableElement(layer, loopBackToElement, isForward);
}
// move to next element in parent layer
return queryNextTabbable(originLayer, originElement, isForward);
}
}
const nextIndex = currentIndex + (isForward ? 1 : -1);
const nextElement = list[nextIndex];
const isOriginElement = nextElement.tagName === `ZEEJS-ORIGIN`;
if (isOriginElement) {
return queryFirstTabbable(layer, nextElement, isForward);
} else {
return nextElement;
}
}

function queryTabbableElement(layer: HTMLElement, element: HTMLElement, isForward: boolean) {
const isOriginElement = element.tagName === `ZEEJS-ORIGIN`;
if (isOriginElement) {
return queryFirstTabbable(layer, element, isForward);
} else {
return element;
}
}

function queryFirstTabbable(
originLayer: HTMLElement,
originElement: HTMLElement,
isForward: boolean
): Focusable | null {
const originId = originElement.dataset.origin;
if (!originId) {
// ToDo: handle invalid origin element
return null;
}
const layer = document.querySelector<HTMLElement>(`#${originId}`);
if (!layer) {
// skip missing layer
return queryNextTabbable(originLayer, originElement, isForward);
}
const list = tabbable(layer);
if (list.length === 0) {
// empty layer - query next after origin element
return queryNextTabbable(originLayer, originElement, isForward);
}
const edgeIndex = isForward ? 0 : list.length - 1;
const firstElement = list[edgeIndex];
if (firstElement.tagName === `ZEEJS-ORIGIN`) {
// query first element in nested layer
return queryFirstTabbable(layer, firstElement, isForward);
} else {
return firstElement;
}
}

export function findContainingLayer(element: Element) {
let current: Element | null = element;
while (current) {
if (current.tagName === `ZEEJS-LAYER`) {
return current as HTMLElement;
}
current = current.parentElement;
}
return null;
}
4 changes: 4 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export type { DOMLayer } from './root';
export { createRoot } from './root';
export { bindOverlay } from './bind-overlay';
export { watchFocus } from './focus';
export { updateLayers, createBackdropParts } from './update-layers';
59 changes: 59 additions & 0 deletions packages/browser/src/root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createLayer, Layer } from '@zeejs/core';
import { bindOverlay } from './bind-overlay';

export const overlapBindConfig = Symbol(`overlap-bind`);

export interface LayerSettings {
overlap: `window` | HTMLElement;
backdrop: `none` | `block` | `hide`;
}
export interface LayerExtended {
element: HTMLElement;
settings: LayerSettings;
[overlapBindConfig]: ReturnType<typeof bindOverlay>;
}
export type DOMLayer = Layer<LayerExtended, LayerSettings>;

export const defaultLayerSettings: LayerSettings = {
overlap: `window`,
backdrop: `none`,
};

export function createRoot({
onChange,
}: {
onChange?: () => void;
} = {}) {
let idCounter = 0;
const rootLayer = createLayer({
extendLayer: {
element: (null as unknown) as HTMLElement,
settings: defaultLayerSettings,
} as LayerExtended,
defaultSettings: defaultLayerSettings,
onChange() {
if (onChange) {
onChange();
}
},
init(layer, settings) {
layer.settings = settings;
layer.element = document.createElement(`zeejs-layer`); // ToDo: test that each layer has a unique element
layer.element.id = `zeejs-layer-${idCounter++}`;
if (layer.parentLayer) {
if (settings.overlap === `window`) {
layer.element.classList.add(`zeejs--overlapWindow`);
} else if (settings.overlap instanceof HTMLElement) {
layer.element.classList.add(`zeejs--overlapElement`);
layer[overlapBindConfig] = bindOverlay(settings.overlap, layer.element);
}
}
},
destroy(layer) {
if (layer[overlapBindConfig]) {
layer[overlapBindConfig].stop(); // not tested because its a side effect:/
}
},
});
return rootLayer;
}
Loading