From 6c576cbc376ce5194d445e21f259faaf476c6407 Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Thu, 28 May 2020 14:06:14 +0300 Subject: [PATCH 1/7] feat(browser): handle Tabbing between layers - new watchFocus(layerWrapper) to manipulate tabbing --- package.json | 3 +- packages/browser/package.json | 5 +- packages/browser/src/focus.ts | 128 ++++++++++++ packages/browser/src/index.ts | 1 + packages/browser/test/focus.spec.ts | 292 ++++++++++++++++++++++++++++ yarn.lock | 10 + 6 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 packages/browser/src/focus.ts create mode 100644 packages/browser/test/focus.spec.ts diff --git a/package.json b/package.json index cc89cd1..3da7e63 100644 --- a/package.json +++ b/package.json @@ -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", @@ -54,4 +55,4 @@ "node": ">=10" }, "repository": "git@github.com:idoros/zeejs.git" -} \ No newline at end of file +} diff --git a/packages/browser/package.json b/packages/browser/package.json index fd7058d..afeaba0 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -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", @@ -29,4 +30,4 @@ }, "license": "MIT", "repository": "https://github.com/idoros/zeejs/tree/master/packages/browser" -} \ No newline at end of file +} diff --git a/packages/browser/src/focus.ts b/packages/browser/src/focus.ts new file mode 100644 index 0000000..bed5b6f --- /dev/null +++ b/packages/browser/src/focus.ts @@ -0,0 +1,128 @@ +import tabbable from 'tabbable'; + +export function watchFocus(layersWrapper: HTMLElement) { + layersWrapper.addEventListener(`keydown`, onKeyDown, { capture: true }); + return { + stop() { + layersWrapper.removeEventListener(`keydown`, onKeyDown); + }, + }; +} + +const onKeyDown = (event: KeyboardEvent) => { + if (event.code === `Tab` && document.activeElement) { + const activeElement = document.activeElement; + const layer = findContainingLayer(activeElement); + const isForward = !event.shiftKey; + if (layer && activeElement) { + const nextElement = queryNextTabbable(layer, activeElement, isForward); + if (nextElement) { + event.preventDefault(); + nextElement.focus(); + } + } + } +}; + +type Focusable = { focus: () => void }; + +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.dataset.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 + // ToDo: handle blocking 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; + } + 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(`[data-id="${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; + } +} + +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; +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index d4d3ac5..f61a896 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1 +1,2 @@ export { bindOverlay } from './bind-overlay'; +export { watchFocus } from './focus'; diff --git a/packages/browser/test/focus.spec.ts b/packages/browser/test/focus.spec.ts new file mode 100644 index 0000000..7f59c97 --- /dev/null +++ b/packages/browser/test/focus.spec.ts @@ -0,0 +1,292 @@ +import { watchFocus } from '../src'; +import { HTMLTestDriver } from './html-test-driver'; +import { getInteractionApi } from '@zeejs/test-browser/browser'; +import { expect } from 'chai'; + +describe(`focus`, () => { + let testDriver: HTMLTestDriver; + const { keyboard } = getInteractionApi(); + + before('setup test driver', () => (testDriver = new HTMLTestDriver())); + afterEach('clear test driver', () => testDriver.clean()); + + it(`should [Tab] navigate through layer`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + +
+ ` + ); + watchFocus(container); + + const bgBeforeInput = expectHTMLQuery(`#bgBeforeInput`); + const layerInput = expectHTMLQuery(`#layerInput`); + const bgAfterInput = expectHTMLQuery(`#bgAfterInput`); + + bgBeforeInput.focus(); + expect(document.activeElement, `start focus before layer`).to.equal(bgBeforeInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus inside layer`).to.equal(layerInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus after layer`).to.equal(bgAfterInput); + + // it would be nice to have native behavior (go to browser chrome) + // but other layers are in the way of the focus and cannot be skipped + // without specifically focusing another element or loosing focus. + await keyboard.press(`Tab`); + expect(document.activeElement, `back to start`).to.equal(bgBeforeInput); + }); + + it(`should [Shift+Tab] navigate through layer (backwards)`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + +
+ ` + ); + watchFocus(container); + + const bgBeforeInput = expectHTMLQuery(`#bgBeforeInput`); + const layerInput = expectHTMLQuery(`#layerInput`); + const bgAfterInput = expectHTMLQuery(`#bgAfterInput`); + + bgAfterInput.focus(); + expect(document.activeElement, `start focus after layer`).to.equal(bgAfterInput); + + await keyboard.press(`Shift+Tab`); + expect(document.activeElement, `focus inside layer`).to.equal(layerInput); + + await keyboard.press(`Shift+Tab`); + expect(document.activeElement, `focus before layer`).to.equal(bgBeforeInput); + + await keyboard.press(`Shift+Tab`); + const activeReturnedToChrome = + document.activeElement === document.body || + document.activeElement === document.body.parentElement; // html in firefox + expect(activeReturnedToChrome, `focus on browser chrome`).to.equal(true); + }); + + it(`should [Tab] navigate from layer that is the last tabbable element`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + +
+ ` + ); + watchFocus(container); + + const bgBeforeInput = expectHTMLQuery(`#bgBeforeInput`); + const layerInputA = expectHTMLQuery(`#layerInputA`); + const layerInputB = expectHTMLQuery(`#layerInputB`); + + bgBeforeInput.focus(); + expect(document.activeElement, `start focus before layer`).to.equal(bgBeforeInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `first focus inside layer`).to.equal(layerInputA); + + await keyboard.press(`Tab`); + expect(document.activeElement, `second focus inside layer`).to.equal(layerInputB); + + await keyboard.press(`Tab`); + expect(document.activeElement, `back to start`).to.equal(bgBeforeInput); + }); + + it(`should [Shift+Tab] navigate from layer that is the first tabbable element (backwards)`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + +
+ ` + ); + watchFocus(container); + + const bgAfterInput = expectHTMLQuery(`#bgAfterInput`); + const layerInputA = expectHTMLQuery(`#layerInputA`); + const layerInputB = expectHTMLQuery(`#layerInputB`); + + bgAfterInput.focus(); + expect(document.activeElement, `start focus after layer`).to.equal(bgAfterInput); + + await keyboard.press(`Shift+Tab`); + expect(document.activeElement, `second focus inside layer`).to.equal(layerInputB); + + await keyboard.press(`Shift+Tab`); + expect(document.activeElement, `first focus inside layer`).to.equal(layerInputA); + + await keyboard.press(`Shift+Tab`); + expect(document.activeElement, `back to start`).to.equal(bgAfterInput); + }); + + it(`should [Tab] into deeply nested layer`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + + + + +
+ ` + ); + watchFocus(container); + + const bgBeforeInput = expectHTMLQuery(`#bgBeforeInput`); + const layerDeepInput = expectHTMLQuery(`#layerDeepInput`); + const bgAfterInput = expectHTMLQuery(`#bgAfterInput`); + + bgBeforeInput.focus(); + expect(document.activeElement, `start focus before layer`).to.equal(bgBeforeInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus inside deep layer`).to.equal(layerDeepInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus after layers`).to.equal(bgAfterInput); + }); + + it(`should [Tab] over layer with no tabbable elements`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + no tabbable elements /> + +
+ ` + ); + watchFocus(container); + + const bgBeforeInput = expectHTMLQuery(`#bgBeforeInput`); + const bgAfterInput = expectHTMLQuery(`#bgAfterInput`); + + bgBeforeInput.focus(); + expect(document.activeElement, `start focus before layer`).to.equal(bgBeforeInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus after layer`).to.equal(bgAfterInput); + }); + + it(`should [Tab] skip over missing layer`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + +
+ ` + ); + watchFocus(container); + + const bgBeforeInput = expectHTMLQuery(`#bgBeforeInput`); + const bgAfterInput = expectHTMLQuery(`#bgAfterInput`); + + bgBeforeInput.focus(); + expect(document.activeElement, `start focus before layer`).to.equal(bgBeforeInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus after layer`).to.equal(bgAfterInput); + }); + + it(`should [Tab] out of edge layer and into first layer`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + + + +
+ ` + ); + watchFocus(container); + + const firstLayerInput = expectHTMLQuery(`#firstLayerInput`); + const lastLayerInput = expectHTMLQuery(`#lastLayerInput`); + + lastLayerInput.focus(); + expect(document.activeElement, `start in last layer input`).to.equal(lastLayerInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus first layer input`).to.equal(firstLayerInput); + }); + + it(`should [Tab] back to input on a single input`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + +
+ ` + ); + watchFocus(container); + + const layerInput = expectHTMLQuery(`#layerInput`); + + layerInput.focus(); + expect(document.activeElement, `start in only input`).to.equal(layerInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `back to start`).to.equal(layerInput); + }); +}); diff --git a/yarn.lock b/yarn.lock index fd0fea7..716eee9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1207,6 +1207,11 @@ resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" integrity sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA== +"@types/tabbable@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/tabbable/-/tabbable-3.1.0.tgz#540d4c2729872560badcc220e73c9412c1d2bffe" + integrity sha512-LL0q/bTlzseaXQ8j91eZ+Z8FQUzo0nwkng00B8365qULvFyiSOWylxV8m31Gmee3QuidkDqR72a9NRfR8s4qTw== + "@types/tapable@*", "@types/tapable@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.5.tgz#9adbc12950582aa65ead76bffdf39fe0c27a3c02" @@ -7886,6 +7891,11 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +tabbable@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261" + integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ== + table@^5.2.3: version "5.4.6" resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" From b44fb95f4e6c23c183b0a7385c52c8601a644993 Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Thu, 28 May 2020 14:10:36 +0300 Subject: [PATCH 2/7] feat(react): integrate focus handling - use watchFocus() to handle tabbing - unique id to layers and data attr with the id to the layer origin - renamed all layers to - renamed layer origin nodes to --- packages/react/src/layer.tsx | 4 ++-- packages/react/src/root.tsx | 22 +++++++++++++++++++--- packages/react/test/test.spec.tsx | 28 +++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/react/src/layer.tsx b/packages/react/src/layer.tsx index 3a5319b..c5de71a 100644 --- a/packages/react/src/layer.tsx +++ b/packages/react/src/layer.tsx @@ -32,9 +32,9 @@ export const Layer = ({ children, overlap = `window`, backdrop = `none` }: Layer return ( - + {layer.element ? ReactDOM.createPortal(children, layer.element) : null} - + ); }; diff --git a/packages/react/src/root.tsx b/packages/react/src/root.tsx index 5989ef5..28492e6 100644 --- a/packages/react/src/root.tsx +++ b/packages/react/src/root.tsx @@ -1,4 +1,4 @@ -import { bindOverlay } from '@zeejs/browser'; +import { bindOverlay, watchFocus } from '@zeejs/browser'; import { createLayer, Layer } from '@zeejs/core'; import React, { useRef, @@ -35,6 +35,16 @@ export interface RootProps { children: ReactNode; } +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + 'zeejs-origin': any; + 'zeejs-layer': any; + } + } +} + const css = ` zeejs-block { position: fixed;top: 0;left: 0;right: 0;bottom: 0; @@ -88,6 +98,7 @@ export const Root = ({ className, style, children }: RootProps) => { }, []); const layer = useMemo(() => { + let idCounter = 0; return createLayer({ extendLayer: { element: (null as unknown) as HTMLElement, @@ -95,11 +106,13 @@ export const Root = ({ className, style, children }: RootProps) => { } as LayerExtended, defaultSettings: defaultLayerSettings, onChange() { + // console.log(`onChange!!!`, document.activeElement?.tagName); updateLayers(); }, init(layer, settings) { layer.settings = settings; layer.element = document.createElement(`zeejs-layer`); // ToDo: test that each layer has a unique element + layer.element.dataset[`id`] = `layer-${idCounter++}`; if (layer.parentLayer) { if (settings.overlap === `window`) { layer.element.classList.add(`zeejs--overlapWindow`); @@ -118,18 +131,21 @@ export const Root = ({ className, style, children }: RootProps) => { }, []); useEffect(() => { + const wrapper = rootRef.current!; document.head.appendChild(parts.style); - layer.element = rootRef.current!.firstElementChild! as HTMLElement; + layer.element = wrapper.firstElementChild! as HTMLElement; + const { stop: stopFocus } = watchFocus(wrapper); updateLayers(); () => { document.head.removeChild(parts.style); + stopFocus(); }; }, []); return (
-
{children}
+ {children} {/* layers injected here*/}
diff --git a/packages/react/test/test.spec.tsx b/packages/react/test/test.spec.tsx index e4e5249..b8d2d01 100644 --- a/packages/react/test/test.spec.tsx +++ b/packages/react/test/test.spec.tsx @@ -11,7 +11,7 @@ chai.use(domElementMatchers); describe(`react`, () => { let testDriver: ReactTestDriver; - const { click, clickIfPossible } = getInteractionApi(); + const { click, clickIfPossible, keyboard } = getInteractionApi(); before('setup test driver', () => (testDriver = new ReactTestDriver())); afterEach('clear test driver', () => testDriver.clean()); @@ -373,4 +373,30 @@ describe(`react`, () => { }); }); }); + + describe(`focus`, () => { + it(`should keep layer as part of tab order`, async () => { + const { expectHTMLQuery } = testDriver.render(() => ( + + + + + + + + )); + const bgBeforeInput = expectHTMLQuery(`#bgBeforeInput`); + const layerInput = expectHTMLQuery(`#layerInput`); + const bgAfterInput = expectHTMLQuery(`#bgAfterInput`); + + bgBeforeInput.focus(); + expect(document.activeElement, `start focus before layer`).to.equal(bgBeforeInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus inside layer`).to.equal(layerInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus after layer`).to.equal(bgAfterInput); + }); + }); }); From d7a82138981077da5f5d75ad3c749b58552e1164 Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Sun, 31 May 2020 11:14:35 +0300 Subject: [PATCH 3/7] feat: focus trap under blocking layers - focus trap between layers above blocking backdrop - catch focus under inert and pass to first focusable element - inert attribute to layers under blocking layer --- packages/browser/src/focus.ts | 49 ++++++++++++--- packages/browser/test/focus.spec.ts | 98 +++++++++++++++++++++++++++++ packages/react/src/root.tsx | 7 +++ packages/react/test/test.spec.tsx | 21 +++++++ 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/focus.ts b/packages/browser/src/focus.ts index bed5b6f..a586eef 100644 --- a/packages/browser/src/focus.ts +++ b/packages/browser/src/focus.ts @@ -1,20 +1,52 @@ 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(`zeejs-layer:not([inert])`) + ); + while (availableLayers.length) { + const layer = availableLayers.shift()!; + const layerId = layer.dataset.id!; + const origin = document.querySelector(`[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` && document.activeElement) { - const activeElement = document.activeElement; + if (event.code !== `Tab`) { + return; + } + const isForward = !event.shiftKey; + const activeElement = document.activeElement; + if (activeElement) { const layer = findContainingLayer(activeElement); - const isForward = !event.shiftKey; - if (layer && activeElement) { + if (layer) { const nextElement = queryNextTabbable(layer, activeElement, isForward); if (nextElement) { event.preventDefault(); @@ -24,8 +56,6 @@ const onKeyDown = (event: KeyboardEvent) => { } }; -type Focusable = { focus: () => void }; - function queryNextTabbable( layer: HTMLElement, currentElement: Element, @@ -53,7 +83,6 @@ function queryNextTabbable( } } else { // nested layer - // ToDo: handle blocking layer const originElement = document.querySelector(`[data-origin="${layerId}"]`); if (!originElement) { // ToDo: handle missing origin? @@ -64,6 +93,12 @@ function queryNextTabbable( // 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); } } diff --git a/packages/browser/test/focus.spec.ts b/packages/browser/test/focus.spec.ts index 7f59c97..8ac68c6 100644 --- a/packages/browser/test/focus.spec.ts +++ b/packages/browser/test/focus.spec.ts @@ -289,4 +289,102 @@ describe(`focus`, () => { await keyboard.press(`Tab`); expect(document.activeElement, `back to start`).to.equal(layerInput); }); + + it(`should [Tab] trap focus and ignore elements of inert parent layer`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + + +
+ ` + ); + watchFocus(container); + + const layerFirstInput = expectHTMLQuery(`#layerFirstInput`); + const layerLastInput = expectHTMLQuery(`#layerLastInput`); + + layerFirstInput.focus(); + expect(document.activeElement, `start focus in layer`).to.equal(layerFirstInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `move to another element in layer`).to.equal(layerLastInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `back to layer start`).to.equal(layerFirstInput); + + await keyboard.press(`Shift+Tab`); + expect(document.activeElement, `Shift+Tab in layer back to last`).to.equal(layerLastInput); + }); + + it(`should [Tab] trap focus and ignore elements of inert parent layer (multi layers)`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + + + + + + + +
+ ` + ); + watchFocus(container); + + const layerXFirstInput = expectHTMLQuery(`#layerXFirstInput`); + const layerXLastInput = expectHTMLQuery(`#layerXLastInput`); + const layerYInput = expectHTMLQuery(`#layerYInput`); + + layerXFirstInput.focus(); + expect(document.activeElement, `start focus in layer`).to.equal(layerXFirstInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `move into nested layer`).to.equal(layerYInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `out to layer again`).to.equal(layerXLastInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `rollback ignoring inert parent`).to.equal(layerXFirstInput); + + await keyboard.press(`Shift+Tab`); + expect(document.activeElement, `Shift+Tab in layer back to last`).to.equal(layerXLastInput); + }); + + it(`should catch focus under inert and pass to first focusable element in a non-inert layer`, async () => { + const { expectHTMLQuery, container } = testDriver.render( + () => ` +
+ + + + + + + +
+ ` + ); + watchFocus(container); + + const layerInput = expectHTMLQuery(`#layerInput`); + + await keyboard.press(`Tab`); + expect(document.activeElement, `focus first non inert input`).to.equal(layerInput); + }); }); diff --git a/packages/react/src/root.tsx b/packages/react/src/root.tsx index 28492e6..a05c9f2 100644 --- a/packages/react/src/root.tsx +++ b/packages/react/src/root.tsx @@ -94,6 +94,13 @@ export const Root = ({ className, style, children }: RootProps) => { root.appendChild(parts.block); } root.appendChild(element); + element.removeAttribute(`inert`); + } + const indexOfBlock = Array.from(root.children).indexOf(parts.block); + if (indexOfBlock !== -1) { + for (let i = indexOfBlock; i >= 0; --i) { + root.children[i].setAttribute(`inert`, ``); + } } }, []); diff --git a/packages/react/test/test.spec.tsx b/packages/react/test/test.spec.tsx index b8d2d01..785418f 100644 --- a/packages/react/test/test.spec.tsx +++ b/packages/react/test/test.spec.tsx @@ -398,5 +398,26 @@ describe(`react`, () => { await keyboard.press(`Tab`); expect(document.activeElement, `focus after layer`).to.equal(bgAfterInput); }); + + it(`should trap focus in blocking layer`, async () => { + const { expectHTMLQuery } = testDriver.render(() => ( + + + + + + + + + )); + const layerFirstInput = expectHTMLQuery(`#layerFirstInput`); + const layerLastInput = expectHTMLQuery(`#layerLastInput`); + + layerLastInput.focus(); + expect(document.activeElement, `start focus in layer`).to.equal(layerLastInput); + + await keyboard.press(`Tab`); + expect(document.activeElement, `ignore blocked parent`).to.equal(layerFirstInput); + }); }); }); From 908988c4c0996a2ad26c3e54416d396d37058704 Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Tue, 2 Jun 2020 10:43:45 +0300 Subject: [PATCH 4/7] feat(browser): update layers util - set the correct layers+backdrop in DOM with order and focus inmind - new createRoot(): DOMLayer to contain all browser specific config --- packages/browser/src/index.ts | 3 + packages/browser/src/root.ts | 62 ++++++ packages/browser/src/update-layers.ts | 74 +++++++ packages/browser/test/update-layers.spec.ts | 230 ++++++++++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 packages/browser/src/root.ts create mode 100644 packages/browser/src/update-layers.ts create mode 100644 packages/browser/test/update-layers.spec.ts diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index f61a896..02b0463 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,2 +1,5 @@ +export type { DOMLayer } from './root'; +export { defaultLayerSettings, createRoot } from './root'; export { bindOverlay } from './bind-overlay'; export { watchFocus } from './focus'; +export { updateLayers, createBackdropParts } from './update-layers'; diff --git a/packages/browser/src/root.ts b/packages/browser/src/root.ts new file mode 100644 index 0000000..fa16b9e --- /dev/null +++ b/packages/browser/src/root.ts @@ -0,0 +1,62 @@ +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; +} +export type DOMLayer = Layer; + +export const defaultLayerSettings: LayerSettings = { + overlap: `window`, + backdrop: `none`, +}; + +export function createRoot() { + let idCounter = 0; + let wrapper: HTMLElement; + const rootLayer = createLayer({ + extendLayer: { + element: (null as unknown) as HTMLElement, + settings: defaultLayerSettings, + } as DOMLayer, + defaultSettings: defaultLayerSettings, + 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 { + setWrapper(rootWrapper: HTMLElement, mainLayer?: HTMLElement) { + if (wrapper) { + return; + } + wrapper = rootWrapper; + if (mainLayer) { + rootLayer.element = mainLayer; + } + }, + rootLayer, + }; +} diff --git a/packages/browser/src/update-layers.ts b/packages/browser/src/update-layers.ts new file mode 100644 index 0000000..91d4fd6 --- /dev/null +++ b/packages/browser/src/update-layers.ts @@ -0,0 +1,74 @@ +import { DOMLayer } from './root'; +import { findContainingLayer } from './focus'; + +export function createBackdropParts() { + return { + block: document.createElement(`zeejs-block`), + hide: document.createElement(`zeejs-hide`), + }; +} + +export function updateLayers( + wrapper: HTMLElement, + topLayer: DOMLayer, + { + hide, + block, + }: { + hide: HTMLElement; + block: HTMLElement; + } +) { + const layers = topLayer.generateDisplayList(); + let blocking: HTMLElement | null = null; + let hiding: HTMLElement | null = null; + const layersIds = new Set(); + // append new layers, set order and find backdrop position + for (const [index, { element, settings }] of layers.entries()) { + layersIds.add(element.id); + element.setAttribute(`z-index`, String(index)); + if (element.parentElement !== wrapper) { + wrapper.appendChild(element); + } + if (settings.backdrop !== `none`) { + blocking = element; + if (settings.backdrop === `hide`) { + hiding = element; + } + } + } + // remove old layers & backdrop + const blockedIndex = hiding + ? Number(hiding.getAttribute(`z-index`)) + : blocking + ? Number(blocking.getAttribute(`z-index`)) + : 0; + for (const element of Array.from(wrapper.children)) { + if (!layersIds.has(element.id)) { + wrapper.removeChild(element); + } else { + if (Number(element.getAttribute(`z-index`)) < blockedIndex) { + element.setAttribute(`inert`, ``); + } else { + element.removeAttribute(`inert`); + } + } + } + // append backdrop if needed + if (hiding) { + wrapper.insertBefore(hide, hiding); + hide.setAttribute(`z-index`, hiding.getAttribute(`z-index`)!); + } + if (blocking) { + wrapper.insertBefore(block, blocking); + block.setAttribute(`z-index`, blocking.getAttribute(`z-index`)!); + } + // blur inert active element + if (document.activeElement) { + const focusedLayer = findContainingLayer(document.activeElement); + if (focusedLayer && focusedLayer.hasAttribute(`inert`)) { + ((document.activeElement as unknown) as HTMLOrSVGElement).blur(); + } + } + return layers; +} diff --git a/packages/browser/test/update-layers.spec.ts b/packages/browser/test/update-layers.spec.ts new file mode 100644 index 0000000..cc6abb6 --- /dev/null +++ b/packages/browser/test/update-layers.spec.ts @@ -0,0 +1,230 @@ +import { updateLayers, createRoot, createBackdropParts } from '../src'; +import { HTMLTestDriver } from './html-test-driver'; +import { expect } from 'chai'; + +describe(`update-layers`, () => { + let testDriver: HTMLTestDriver; + const backdropParts = createBackdropParts(); + + before('setup test driver', () => (testDriver = new HTMLTestDriver())); + afterEach('clear test driver', () => testDriver.clean()); + + it(`should append root layer`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root layer`).to.equal(1); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + }); + + it(`should append child layer after root layer`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + const childLayer = rootLayer.createLayer(); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root & child layers`).to.equal(2); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + expect(wrapper.children[1], `child`).to.equal(childLayer.element); + }); + + it(`should append multiple changes (before first layer update)`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + const layerA = rootLayer.createLayer(); + const layerAChild = layerA.createLayer(); + const layerB = rootLayer.createLayer(); + const layerBChild = layerB.createLayer(); + layerA.removeLayer(layerAChild); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, A, B & BChild layers`).to.equal(4); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + expect(wrapper.children[1], `layerA`).to.equal(layerA.element); + expect(wrapper.children[2], `layerB`).to.equal(layerB.element); + expect(wrapper.children[3], `layerBChild`).to.equal(layerBChild.element); + }); + + it(`should remove layers`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + const layerA = rootLayer.createLayer(); + const layerAChild = layerA.createLayer(); + const layerB = rootLayer.createLayer(); + const layerBChild = layerB.createLayer(); + + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, A, AChild, B & BChild layers`).to.equal(5); + + layerA.removeLayer(layerAChild); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, A, B & BChild layers`).to.equal(4); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + expect(wrapper.children[1], `layerA`).to.equal(layerA.element); + expect(wrapper.children[2], `layerB`).to.equal(layerB.element); + expect(wrapper.children[3], `layerBChild`).to.equal(layerBChild.element); + }); + + it(`should add a layer between layers`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + const layerA = rootLayer.createLayer(); + const layerB = rootLayer.createLayer(); + updateLayers(wrapper, rootLayer, backdropParts); + + const layerAChild = layerA.createLayer(); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, A, AChild, B`).to.equal(4); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + expect(wrapper.children[1], `layerA`).to.equal(layerA.element); + expect(wrapper.children[2], `layerB`).to.equal(layerB.element); + expect(wrapper.children[3], `layerAChild`).to.equal(layerAChild.element); + expect(wrapper.children[0].getAttribute(`z-index`), `root 1st`).to.equal(`0`); + expect(wrapper.children[1].getAttribute(`z-index`), `layerA 2nd`).to.equal(`1`); + expect(wrapper.children[3].getAttribute(`z-index`), `layerAChild 3rd`).to.equal(`2`); + expect(wrapper.children[2].getAttribute(`z-index`), `layerB 4th`).to.equal(`3`); + // ToDo: add a screen snapshot test + }); + + it(`should place block element before last layer with backdrop=block`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + const firstLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + const secondLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, first, block, second`).to.equal(4); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + expect(wrapper.children[1], `firstLayer`).to.equal(firstLayer.element); + expect(wrapper.children[2], `block`).to.equal(backdropParts.block); + expect(wrapper.children[3], `secondLayer`).to.equal(secondLayer.element); + }); + + it(`should place hide+block element before last layer with backdrop=hide`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + const firstLayer = rootLayer.createLayer({ + settings: { backdrop: `hide`, overlap: `window` }, + }); + const secondLayer = rootLayer.createLayer({ + settings: { backdrop: `hide`, overlap: `window` }, + }); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, first, hide, block, second`).to.equal(5); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + expect(wrapper.children[1], `firstLayer`).to.equal(firstLayer.element); + expect(wrapper.children[2], `hide`).to.equal(backdropParts.hide); + expect(wrapper.children[3], `block`).to.equal(backdropParts.block); + expect(wrapper.children[4], `secondLayer`).to.equal(secondLayer.element); + }); + + it(`should place hide & block separately before last layers with backdrop=hide/block`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + const firstLayer = rootLayer.createLayer({ + settings: { backdrop: `hide`, overlap: `window` }, + }); + const secondLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, hide, first, block, second`).to.equal(5); + expect(wrapper.children[0], `root`).to.equal(rootLayer.element); + expect(wrapper.children[1], `hide`).to.equal(backdropParts.hide); + expect(wrapper.children[2], `firstLayer`).to.equal(firstLayer.element); + expect(wrapper.children[3], `block`).to.equal(backdropParts.block); + expect(wrapper.children[4], `secondLayer`).to.equal(secondLayer.element); + }); + + it(`should set attribute inert for all layers before backdrop=block`, () => { + const { rootLayer, setWrapper } = createRoot(); + const wrapper = document.createElement(`div`); + setWrapper(wrapper); + + rootLayer.createLayer({ settings: { backdrop: `block`, overlap: `window` } }); + const secondLayer = rootLayer.createLayer({ + settings: { backdrop: `hide`, overlap: `window` }, + }); + secondLayer.createLayer(); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, first, hide, block, second, secondChild`).to.equal( + 6 + ); + expect(wrapper.children[0].hasAttribute(`inert`), `root inert`).to.equal(true); + expect(wrapper.children[1].hasAttribute(`inert`), `firstLayer inert`).to.equal(true); + expect(wrapper.children[2].hasAttribute(`inert`), `hide not inert`).to.equal(false); + expect(wrapper.children[3].hasAttribute(`inert`), `block not inert`).to.equal(false); + expect(wrapper.children[4].hasAttribute(`inert`), `secondLayer not inert`).to.equal(false); + expect(wrapper.children[5].hasAttribute(`inert`), `secondLayer child not inert`).to.equal( + false + ); + + rootLayer.removeLayer(secondLayer); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(wrapper.children.length, `root, block, first`).to.equal(3); + expect(wrapper.children[0].hasAttribute(`inert`), `root inert after change`).to.equal(true); + expect(wrapper.children[1].hasAttribute(`inert`), `block not inert after change`).to.equal( + false + ); + expect( + wrapper.children[2].hasAttribute(`inert`), + `firstLayer not inert after change` + ).to.equal(false); + }); + + it(`should keep focus within a layer`, () => { + const { container: wrapper } = testDriver.render(() => ``); + const { rootLayer, setWrapper } = createRoot(); + setWrapper(wrapper); + const rootInput = document.createElement(`input`); + rootLayer.element.appendChild(rootInput); + wrapper.appendChild(rootLayer.element); + rootInput.focus(); + + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement).to.equal(rootInput); + }); + + it(`should blur within an inert layer`, () => { + const { container: wrapper } = testDriver.render(() => ``); + const { rootLayer, setWrapper } = createRoot(); + setWrapper(wrapper); + const rootInput = document.createElement(`input`); + rootLayer.element.appendChild(rootInput); + wrapper.appendChild(rootLayer.element); + rootInput.focus(); + rootLayer.createLayer({ settings: { backdrop: `block`, overlap: `window` } }); + + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `blur inert`).to.be.oneOf([null, document.body]); + }); +}); From 5e40e6eb2f75ec7b65a498cdfc9f67014e00b8d1 Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Tue, 2 Jun 2020 13:02:52 +0300 Subject: [PATCH 5/7] feat: re-focus activated layer - handle re-focus in the updateLayers process - changed layer id to be an actual id instead of data-id - refactor react root to use createRoot from browser package -removed unnecessary setWrapper() in createRoot() - @zeejs/browser --- packages/browser/src/focus.ts | 8 +- packages/browser/src/index.ts | 2 +- packages/browser/src/root.ts | 29 ++--- packages/browser/src/update-layers.ts | 56 +++++--- packages/browser/test/focus.spec.ts | 28 ++-- packages/browser/test/update-layers.spec.ts | 134 ++++++++++++++++---- packages/react/src/layer.tsx | 2 +- packages/react/src/root.tsx | 115 ++++------------- packages/react/test/test.spec.tsx | 22 ++++ 9 files changed, 231 insertions(+), 165 deletions(-) diff --git a/packages/browser/src/focus.ts b/packages/browser/src/focus.ts index a586eef..f48f6b7 100644 --- a/packages/browser/src/focus.ts +++ b/packages/browser/src/focus.ts @@ -23,7 +23,7 @@ const onFocus = (event: FocusEvent) => { ); while (availableLayers.length) { const layer = availableLayers.shift()!; - const layerId = layer.dataset.id!; + const layerId = layer.id; const origin = document.querySelector(`[data-origin="${layerId}"]`); if (origin) { const element = queryFirstTabbable(layer, origin, true); @@ -70,7 +70,7 @@ function queryNextTabbable( const edgeIndex = isForward ? list.length - 1 : 0; const currentIndex = list.indexOf(currentElement as HTMLElement); if (currentIndex === edgeIndex) { - const layerId = layer.dataset.id; + const layerId = layer.id; if (!layerId) { // top layer if (isForward) { @@ -131,7 +131,7 @@ function queryFirstTabbable( // ToDo: handle invalid origin element return null; } - const layer = document.querySelector(`[data-id="${originId}"]`); + const layer = document.querySelector(`#${originId}`); if (!layer) { // skip missing layer return queryNextTabbable(originLayer, originElement, isForward); @@ -151,7 +151,7 @@ function queryFirstTabbable( } } -function findContainingLayer(element: Element) { +export function findContainingLayer(element: Element) { let current: Element | null = element; while (current) { if (current.tagName === `ZEEJS-LAYER`) { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 02b0463..590efc1 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,5 +1,5 @@ export type { DOMLayer } from './root'; -export { defaultLayerSettings, createRoot } from './root'; +export { createRoot } from './root'; export { bindOverlay } from './bind-overlay'; export { watchFocus } from './focus'; export { updateLayers, createBackdropParts } from './update-layers'; diff --git a/packages/browser/src/root.ts b/packages/browser/src/root.ts index fa16b9e..c9f043d 100644 --- a/packages/browser/src/root.ts +++ b/packages/browser/src/root.ts @@ -1,4 +1,4 @@ -import { createLayer, Layer } from '@zeejs/core'; +import { createLayer, Layer, Change } from '@zeejs/core'; import { bindOverlay } from './bind-overlay'; export const overlapBindConfig = Symbol(`overlap-bind`); @@ -19,15 +19,23 @@ export const defaultLayerSettings: LayerSettings = { backdrop: `none`, }; -export function createRoot() { +export function createRoot({ + onChange, +}: { + onChange?: (change: Change) => void; +} = {}) { let idCounter = 0; - let wrapper: HTMLElement; const rootLayer = createLayer({ extendLayer: { element: (null as unknown) as HTMLElement, settings: defaultLayerSettings, - } as DOMLayer, + } as LayerExtended, defaultSettings: defaultLayerSettings, + onChange(change) { + if (onChange) { + onChange(change); + } + }, init(layer, settings) { layer.settings = settings; layer.element = document.createElement(`zeejs-layer`); // ToDo: test that each layer has a unique element @@ -47,16 +55,5 @@ export function createRoot() { } }, }); - return { - setWrapper(rootWrapper: HTMLElement, mainLayer?: HTMLElement) { - if (wrapper) { - return; - } - wrapper = rootWrapper; - if (mainLayer) { - rootLayer.element = mainLayer; - } - }, - rootLayer, - }; + return rootLayer; } diff --git a/packages/browser/src/update-layers.ts b/packages/browser/src/update-layers.ts index 91d4fd6..575bd76 100644 --- a/packages/browser/src/update-layers.ts +++ b/packages/browser/src/update-layers.ts @@ -1,24 +1,25 @@ import { DOMLayer } from './root'; import { findContainingLayer } from './focus'; -export function createBackdropParts() { +export interface BackdropElements { + hide: HTMLElement; + block: HTMLElement; +} +export function createBackdropParts(): BackdropElements { return { block: document.createElement(`zeejs-block`), hide: document.createElement(`zeejs-hide`), }; } +const lastFocusMap = new WeakMap(); + export function updateLayers( wrapper: HTMLElement, topLayer: DOMLayer, - { - hide, - block, - }: { - hide: HTMLElement; - block: HTMLElement; - } + { hide, block }: BackdropElements ) { + const focusedElement = (document.activeElement as unknown) as HTMLElement | SVGElement | null; const layers = topLayer.generateDisplayList(); let blocking: HTMLElement | null = null; let hiding: HTMLElement | null = null; @@ -37,7 +38,9 @@ export function updateLayers( } } } - // remove old layers & backdrop + // - remove un-needed layers/backdrop + // - find activated layers + const activatedLayers: Element[] = []; const blockedIndex = hiding ? Number(hiding.getAttribute(`z-index`)) : blocking @@ -47,9 +50,11 @@ export function updateLayers( if (!layersIds.has(element.id)) { wrapper.removeChild(element); } else { - if (Number(element.getAttribute(`z-index`)) < blockedIndex) { + const index = Number(element.getAttribute(`z-index`)); + if (index < blockedIndex) { element.setAttribute(`inert`, ``); - } else { + } else if (element.hasAttribute(`inert`)) { + activatedLayers.push(element); element.removeAttribute(`inert`); } } @@ -64,11 +69,32 @@ export function updateLayers( block.setAttribute(`z-index`, blocking.getAttribute(`z-index`)!); } // blur inert active element - if (document.activeElement) { - const focusedLayer = findContainingLayer(document.activeElement); + if (focusedElement) { + const focusedLayer = findContainingLayer(focusedElement); if (focusedLayer && focusedLayer.hasAttribute(`inert`)) { - ((document.activeElement as unknown) as HTMLOrSVGElement).blur(); + lastFocusMap.set(focusedLayer, focusedElement); + focusedElement.blur(); + } + } + // re-focus last input from activated layer + const currentlyFocused = !!( + document.activeElement && findContainingLayer(document.activeElement) + ); + if (!currentlyFocused && activatedLayers.length) { + // top layer last + const sortedLayers = activatedLayers.sort( + (a, b) => Number(a.getAttribute(`z-index`)) - Number(b.getAttribute(`z-index`)) + ); + let refocusElement: HTMLElement | SVGElement | void; + while (!refocusElement && sortedLayers.length) { + const currentLayer = sortedLayers.pop()!; + const lastInput = lastFocusMap.get(currentLayer); + if (lastInput) { + refocusElement = lastInput; + } + } + if (refocusElement) { + refocusElement.focus(); } } - return layers; } diff --git a/packages/browser/test/focus.spec.ts b/packages/browser/test/focus.spec.ts index 8ac68c6..14e5756 100644 --- a/packages/browser/test/focus.spec.ts +++ b/packages/browser/test/focus.spec.ts @@ -19,7 +19,7 @@ describe(`focus`, () => {
- + @@ -56,7 +56,7 @@ describe(`focus`, () => { - + @@ -92,7 +92,7 @@ describe(`focus`, () => { - + @@ -126,7 +126,7 @@ describe(`focus`, () => { - + @@ -161,10 +161,10 @@ describe(`focus`, () => { - + - + @@ -195,7 +195,7 @@ describe(`focus`, () => { - + no tabbable elements /> @@ -245,10 +245,10 @@ describe(`focus`, () => { - + - + @@ -273,7 +273,7 @@ describe(`focus`, () => { - + @@ -299,7 +299,7 @@ describe(`focus`, () => { - + @@ -333,12 +333,12 @@ describe(`focus`, () => { - + - + @@ -374,7 +374,7 @@ describe(`focus`, () => { - + diff --git a/packages/browser/test/update-layers.spec.ts b/packages/browser/test/update-layers.spec.ts index cc6abb6..2471e15 100644 --- a/packages/browser/test/update-layers.spec.ts +++ b/packages/browser/test/update-layers.spec.ts @@ -10,9 +10,8 @@ describe(`update-layers`, () => { afterEach('clear test driver', () => testDriver.clean()); it(`should append root layer`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); updateLayers(wrapper, rootLayer, backdropParts); @@ -21,9 +20,8 @@ describe(`update-layers`, () => { }); it(`should append child layer after root layer`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); const childLayer = rootLayer.createLayer(); updateLayers(wrapper, rootLayer, backdropParts); @@ -34,9 +32,8 @@ describe(`update-layers`, () => { }); it(`should append multiple changes (before first layer update)`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); const layerA = rootLayer.createLayer(); const layerAChild = layerA.createLayer(); @@ -53,10 +50,8 @@ describe(`update-layers`, () => { }); it(`should remove layers`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); - const layerA = rootLayer.createLayer(); const layerAChild = layerA.createLayer(); const layerB = rootLayer.createLayer(); @@ -77,9 +72,8 @@ describe(`update-layers`, () => { }); it(`should add a layer between layers`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); const layerA = rootLayer.createLayer(); const layerB = rootLayer.createLayer(); updateLayers(wrapper, rootLayer, backdropParts); @@ -100,9 +94,8 @@ describe(`update-layers`, () => { }); it(`should place block element before last layer with backdrop=block`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); const firstLayer = rootLayer.createLayer({ settings: { backdrop: `block`, overlap: `window` }, @@ -120,9 +113,8 @@ describe(`update-layers`, () => { }); it(`should place hide+block element before last layer with backdrop=hide`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); const firstLayer = rootLayer.createLayer({ settings: { backdrop: `hide`, overlap: `window` }, @@ -141,9 +133,8 @@ describe(`update-layers`, () => { }); it(`should place hide & block separately before last layers with backdrop=hide/block`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); const firstLayer = rootLayer.createLayer({ settings: { backdrop: `hide`, overlap: `window` }, @@ -162,9 +153,8 @@ describe(`update-layers`, () => { }); it(`should set attribute inert for all layers before backdrop=block`, () => { - const { rootLayer, setWrapper } = createRoot(); + const rootLayer = createRoot(); const wrapper = document.createElement(`div`); - setWrapper(wrapper); rootLayer.createLayer({ settings: { backdrop: `block`, overlap: `window` } }); const secondLayer = rootLayer.createLayer({ @@ -201,8 +191,7 @@ describe(`update-layers`, () => { it(`should keep focus within a layer`, () => { const { container: wrapper } = testDriver.render(() => ``); - const { rootLayer, setWrapper } = createRoot(); - setWrapper(wrapper); + const rootLayer = createRoot(); const rootInput = document.createElement(`input`); rootLayer.element.appendChild(rootInput); wrapper.appendChild(rootLayer.element); @@ -215,8 +204,7 @@ describe(`update-layers`, () => { it(`should blur within an inert layer`, () => { const { container: wrapper } = testDriver.render(() => ``); - const { rootLayer, setWrapper } = createRoot(); - setWrapper(wrapper); + const rootLayer = createRoot(); const rootInput = document.createElement(`input`); rootLayer.element.appendChild(rootInput); wrapper.appendChild(rootLayer.element); @@ -227,4 +215,104 @@ describe(`update-layers`, () => { expect(document.activeElement, `blur inert`).to.be.oneOf([null, document.body]); }); + + it(`should re-focus last focused element of activate layer`, () => { + const { container: wrapper } = testDriver.render(() => ``); + const rootLayer = createRoot(); + const rootInput = document.createElement(`input`); + rootLayer.element.appendChild(rootInput); + wrapper.appendChild(rootLayer.element); + rootInput.focus(); + const blockingLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `blur inert`).to.be.oneOf([null, document.body]); + + rootLayer.removeLayer(blockingLayer); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `refocus`).to.be.equal(rootInput); + }); + + it(`should re-focus last focused element of TOP activated layer`, () => { + const { container: wrapper } = testDriver.render(() => ``); + const rootLayer = createRoot(); + const rootInput = document.createElement(`input`); + const middleLayerInput = document.createElement(`input`); + rootLayer.element.appendChild(rootInput); + wrapper.appendChild(rootLayer.element); + rootInput.focus(); + const middleLayer = rootLayer.createLayer(); + middleLayer.element.appendChild(middleLayerInput); + updateLayers(wrapper, rootLayer, backdropParts); + middleLayerInput.focus(); + + const blockingLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `blur inert`).to.be.oneOf([null, document.body]); + + rootLayer.removeLayer(blockingLayer); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `refocus`).to.be.equal(middleLayerInput); + }); + + it(`should re-focus last focused element of TOP activated layer that HAD focus`, () => { + const { container: wrapper } = testDriver.render(() => ``); + const rootLayer = createRoot(); + const rootInput = document.createElement(`input`); + const middleLayerInput = document.createElement(`input`); + rootLayer.element.appendChild(rootInput); + wrapper.appendChild(rootLayer.element); + rootInput.focus(); + const middleLayer = rootLayer.createLayer(); + middleLayer.element.appendChild(middleLayerInput); + updateLayers(wrapper, rootLayer, backdropParts); + + const blockingLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `blur inert`).to.be.oneOf([null, document.body]); + + rootLayer.removeLayer(blockingLayer); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `refocus`).to.be.equal(rootInput); + }); + + it(`should maintain focus in active layer even when a re-activated layer had previous focus`, () => { + const { container: wrapper } = testDriver.render(() => ``); + const rootLayer = createRoot(); + const rootInput = document.createElement(`input`); + const topLayerInput = document.createElement(`input`); + rootLayer.element.appendChild(rootInput); + wrapper.appendChild(rootLayer.element); + rootInput.focus(); + const middleLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `blur inert`).to.be.oneOf([null, document.body]); + + const topLayer = rootLayer.createLayer(); + topLayer.element.appendChild(topLayerInput); + updateLayers(wrapper, rootLayer, backdropParts); + topLayerInput.focus(); + + expect(document.activeElement, `top focus`).to.equal(topLayerInput); + + rootLayer.removeLayer(middleLayer); + updateLayers(wrapper, rootLayer, backdropParts); + + expect(document.activeElement, `top focus not changed`).to.equal(topLayerInput); + }); }); diff --git a/packages/react/src/layer.tsx b/packages/react/src/layer.tsx index c5de71a..b02149d 100644 --- a/packages/react/src/layer.tsx +++ b/packages/react/src/layer.tsx @@ -32,7 +32,7 @@ export const Layer = ({ children, overlap = `window`, backdrop = `none` }: Layer return ( - + {layer.element ? ReactDOM.createPortal(children, layer.element) : null} diff --git a/packages/react/src/root.tsx b/packages/react/src/root.tsx index a05c9f2..18f9717 100644 --- a/packages/react/src/root.tsx +++ b/packages/react/src/root.tsx @@ -1,33 +1,13 @@ -import { bindOverlay, watchFocus } from '@zeejs/browser'; -import { createLayer, Layer } from '@zeejs/core'; -import React, { - useRef, - useMemo, - createContext, - CSSProperties, - ReactNode, - useEffect, - useCallback, -} from 'react'; +import { + watchFocus, + createRoot, + DOMLayer, + updateLayers, + createBackdropParts, +} from '@zeejs/browser'; +import React, { useRef, useMemo, createContext, CSSProperties, ReactNode, useEffect } from 'react'; -const overlapBind = Symbol(`overlap-bind`); - -interface LayerSettings { - overlap: `window` | HTMLElement; - backdrop: `none` | `block` | `hide`; -} -interface LayerExtended { - element: HTMLElement; - settings: LayerSettings; - [overlapBind]: ReturnType; -} -type ReactLayer = Layer; -export const zeejsContext = createContext((null as any) as ReactLayer); - -const defaultLayerSettings: LayerSettings = { - overlap: `window`, - backdrop: `none`, -}; +export const zeejsContext = createContext((null as any) as DOMLayer); export interface RootProps { className?: string; @@ -70,79 +50,32 @@ const css = ` export const Root = ({ className, style, children }: RootProps) => { const rootRef = useRef(null); - const parts = useMemo(() => { + + const { rootLayer, parts } = useMemo(() => { const style = document.createElement(`style`); style.innerText = css; - const block = document.createElement(`zeejs-block`); - const hide = document.createElement(`zeejs-hide`); - return { style, block, hide }; - }, []); - - const updateLayers = useCallback(() => { - const root = rootRef.current!; - if (!root) { - return; - } - const layers = layer.generateDisplayList(); - // ToDo: reorder/remove/add with minimal changes - root.textContent = ``; - for (const { element, settings } of layers) { - if (settings.backdrop !== `none`) { - if (settings.backdrop === `hide`) { - root.appendChild(parts.hide); - } - root.appendChild(parts.block); - } - root.appendChild(element); - element.removeAttribute(`inert`); - } - const indexOfBlock = Array.from(root.children).indexOf(parts.block); - if (indexOfBlock !== -1) { - for (let i = indexOfBlock; i >= 0; --i) { - root.children[i].setAttribute(`inert`, ``); - } - } - }, []); - - const layer = useMemo(() => { - let idCounter = 0; - return createLayer({ - extendLayer: { - element: (null as unknown) as HTMLElement, - settings: defaultLayerSettings, - } as LayerExtended, - defaultSettings: defaultLayerSettings, + const parts = { style, ...createBackdropParts() }; + const rootLayer = createRoot({ onChange() { - // console.log(`onChange!!!`, document.activeElement?.tagName); - updateLayers(); - }, - init(layer, settings) { - layer.settings = settings; - layer.element = document.createElement(`zeejs-layer`); // ToDo: test that each layer has a unique element - layer.element.dataset[`id`] = `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[overlapBind] = bindOverlay(settings.overlap, layer.element); - } - } - }, - destroy(layer) { - if (layer[overlapBind]) { - layer[overlapBind].stop(); // not tested because its a side effect:/ + const wrapper = rootRef.current; + if (!wrapper) { + return; } + // ToDo: fix: "unstable_flushDiscreteUpdates: Cannot flush updates when React is already rendering" + // Layer component creates layer, causing sync changes to wrapper DOM. + // React console.error with "Warning: unstable_flushDiscreteUpdates" when there is a focused element withing a changing DOM element. + updateLayers(wrapper, rootLayer, parts); }, }); + return { rootLayer, parts }; }, []); useEffect(() => { const wrapper = rootRef.current!; document.head.appendChild(parts.style); - layer.element = wrapper.firstElementChild! as HTMLElement; + rootLayer.element = wrapper.firstElementChild! as HTMLElement; const { stop: stopFocus } = watchFocus(wrapper); - updateLayers(); + updateLayers(wrapper, rootLayer, parts); () => { document.head.removeChild(parts.style); stopFocus(); @@ -151,7 +84,7 @@ export const Root = ({ className, style, children }: RootProps) => { return (
- + {children} {/* layers injected here*/} diff --git a/packages/react/test/test.spec.tsx b/packages/react/test/test.spec.tsx index 785418f..cec12c2 100644 --- a/packages/react/test/test.spec.tsx +++ b/packages/react/test/test.spec.tsx @@ -419,5 +419,27 @@ describe(`react`, () => { await keyboard.press(`Tab`); expect(document.activeElement, `ignore blocked parent`).to.equal(layerFirstInput); }); + + it(`should re-focus last element of an un-blocked layer`, () => { + const { expectHTMLQuery, setData } = testDriver.render( + (renderLayer) => ( + + + {renderLayer ? layer content : null} + + ), + { initialData: false } + ); + const bgInput = expectHTMLQuery(`#bgInput`); + bgInput.focus(); + + setData(true); + + expect(document.activeElement, `blocked input blur`).to.equal(document.body); + + setData(false); + + expect(document.activeElement, `refocus input`).to.equal(bgInput); + }); }); }); From 5f9ffb332eee228b877b21e1db1656b3b12c9edb Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Tue, 2 Jun 2020 13:06:30 +0300 Subject: [PATCH 6/7] fix: removed missing import --- packages/browser/src/root.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/root.ts b/packages/browser/src/root.ts index c9f043d..eefb23b 100644 --- a/packages/browser/src/root.ts +++ b/packages/browser/src/root.ts @@ -1,4 +1,4 @@ -import { createLayer, Layer, Change } from '@zeejs/core'; +import { createLayer, Layer } from '@zeejs/core'; import { bindOverlay } from './bind-overlay'; export const overlapBindConfig = Symbol(`overlap-bind`); @@ -22,7 +22,7 @@ export const defaultLayerSettings: LayerSettings = { export function createRoot({ onChange, }: { - onChange?: (change: Change) => void; + onChange?: () => void; } = {}) { let idCounter = 0; const rootLayer = createLayer({ @@ -31,9 +31,9 @@ export function createRoot({ settings: defaultLayerSettings, } as LayerExtended, defaultSettings: defaultLayerSettings, - onChange(change) { + onChange() { if (onChange) { - onChange(change); + onChange(); } }, init(layer, settings) { From af0b017027d67ff94bbaed1235ceb4c00687dff3 Mon Sep 17 00:00:00 2001 From: Ido Rosenthal Date: Wed, 3 Jun 2020 09:51:33 +0300 Subject: [PATCH 7/7] feat: optional async blur/re-focus on updateLayers - new option for async focus change in update layers - fix React implementation to async update focus during render --- packages/browser/src/update-layers.ts | 83 ++++++++++++++++++--- packages/browser/test/update-layers.spec.ts | 29 +++++++ packages/react/src/root.tsx | 6 +- packages/react/test/test.spec.tsx | 19 ++++- 4 files changed, 118 insertions(+), 19 deletions(-) diff --git a/packages/browser/src/update-layers.ts b/packages/browser/src/update-layers.ts index 575bd76..eaf5756 100644 --- a/packages/browser/src/update-layers.ts +++ b/packages/browser/src/update-layers.ts @@ -12,14 +12,37 @@ export function createBackdropParts(): BackdropElements { }; } -const lastFocusMap = new WeakMap(); +type Focusable = HTMLElement | SVGElement; + +const lastFocusMap = new WeakMap(); +const asyncFocusChangePromise = new WeakMap< + Element, + { + promise: Promise; + blur: Focusable | void; + focus: Focusable | void; + } +>(); export function updateLayers( wrapper: HTMLElement, topLayer: DOMLayer, - { hide, block }: BackdropElements -) { - const focusedElement = (document.activeElement as unknown) as HTMLElement | SVGElement | null; + { hide, block }: BackdropElements, + asyncFocusChange?: false +): void; +export function updateLayers( + wrapper: HTMLElement, + topLayer: DOMLayer, + { hide, block }: BackdropElements, + asyncFocusChange: true +): Promise; +export async function updateLayers( + wrapper: HTMLElement, + topLayer: DOMLayer, + { hide, block }: BackdropElements, + asyncFocusChange?: boolean +): Promise { + const focusedElement = (document.activeElement as unknown) as Focusable | null; const layers = topLayer.generateDisplayList(); let blocking: HTMLElement | null = null; let hiding: HTMLElement | null = null; @@ -44,8 +67,8 @@ export function updateLayers( const blockedIndex = hiding ? Number(hiding.getAttribute(`z-index`)) : blocking - ? Number(blocking.getAttribute(`z-index`)) - : 0; + ? Number(blocking.getAttribute(`z-index`)) + : 0; for (const element of Array.from(wrapper.children)) { if (!layersIds.has(element.id)) { wrapper.removeChild(element); @@ -68,15 +91,17 @@ export function updateLayers( wrapper.insertBefore(block, blocking); block.setAttribute(`z-index`, blocking.getAttribute(`z-index`)!); } - // blur inert active element + // find and save reference for active element in inert layer + let elementToBlur: void | Focusable; + let elementToFocus: void | Focusable; if (focusedElement) { const focusedLayer = findContainingLayer(focusedElement); if (focusedLayer && focusedLayer.hasAttribute(`inert`)) { lastFocusMap.set(focusedLayer, focusedElement); - focusedElement.blur(); + elementToBlur = focusedElement; } } - // re-focus last input from activated layer + // find re-focus last input from activated layer const currentlyFocused = !!( document.activeElement && findContainingLayer(document.activeElement) ); @@ -85,7 +110,7 @@ export function updateLayers( const sortedLayers = activatedLayers.sort( (a, b) => Number(a.getAttribute(`z-index`)) - Number(b.getAttribute(`z-index`)) ); - let refocusElement: HTMLElement | SVGElement | void; + let refocusElement: Focusable | void; while (!refocusElement && sortedLayers.length) { const currentLayer = sortedLayers.pop()!; const lastInput = lastFocusMap.get(currentLayer); @@ -94,7 +119,43 @@ export function updateLayers( } } if (refocusElement) { - refocusElement.focus(); + elementToFocus = refocusElement; } } + // sync/async blur/refocus + if (asyncFocusChange) { + let buffered = asyncFocusChangePromise.get(wrapper); + if (buffered) { + buffered.blur = elementToBlur; + buffered.focus = elementToFocus; + } else { + buffered = { + promise: Promise.resolve().then(() => { + const change = asyncFocusChangePromise.get(wrapper); + if (change) { + changeFocus(change); + } + asyncFocusChangePromise.delete(wrapper); + }), + blur: elementToBlur, + focus: elementToFocus, + }; + asyncFocusChangePromise.set(wrapper, buffered); + } + return buffered.promise; + } else { + changeFocus({ + blur: elementToBlur, + focus: elementToFocus, + }); + } +} + +function changeFocus({ blur, focus }: { blur: Focusable | void; focus: Focusable | void }) { + if (blur) { + blur.blur(); + } + if (focus) { + focus.focus(); + } } diff --git a/packages/browser/test/update-layers.spec.ts b/packages/browser/test/update-layers.spec.ts index 2471e15..e40cbdd 100644 --- a/packages/browser/test/update-layers.spec.ts +++ b/packages/browser/test/update-layers.spec.ts @@ -315,4 +315,33 @@ describe(`update-layers`, () => { expect(document.activeElement, `top focus not changed`).to.equal(topLayerInput); }); + + it(`should optionally blur/refocus asynchronically`, async () => { + const { container: wrapper } = testDriver.render(() => ``); + const rootLayer = createRoot(); + const rootInput = document.createElement(`input`); + rootLayer.element.appendChild(rootInput); + wrapper.appendChild(rootLayer.element); + rootInput.focus(); + const blockingLayer = rootLayer.createLayer({ + settings: { backdrop: `block`, overlap: `window` }, + }); + + const waitForBlur = updateLayers(wrapper, rootLayer, backdropParts, true /*async*/); + + expect(document.activeElement, `no sync blur`).to.equal(rootInput); + + await waitForBlur; + + expect(document.activeElement, `async blur`).to.be.oneOf([null, document.body]); + + rootLayer.removeLayer(blockingLayer); + const waitForRefocus = updateLayers(wrapper, rootLayer, backdropParts, true /*async*/); + + expect(document.activeElement, `no sync blur`).to.be.oneOf([null, document.body]); + + await waitForRefocus; + + expect(document.activeElement, `async re-focus`).to.equal(rootInput); + }); }); diff --git a/packages/react/src/root.tsx b/packages/react/src/root.tsx index 18f9717..c6418d5 100644 --- a/packages/react/src/root.tsx +++ b/packages/react/src/root.tsx @@ -61,10 +61,8 @@ export const Root = ({ className, style, children }: RootProps) => { if (!wrapper) { return; } - // ToDo: fix: "unstable_flushDiscreteUpdates: Cannot flush updates when React is already rendering" - // Layer component creates layer, causing sync changes to wrapper DOM. - // React console.error with "Warning: unstable_flushDiscreteUpdates" when there is a focused element withing a changing DOM element. - updateLayers(wrapper, rootLayer, parts); + // buffer delay blur/re-focus because Layer renders and updates during render + updateLayers(wrapper, rootLayer, parts, /*asyncFocusChange*/ true); }, }); return { rootLayer, parts }; diff --git a/packages/react/test/test.spec.tsx b/packages/react/test/test.spec.tsx index cec12c2..73983e5 100644 --- a/packages/react/test/test.spec.tsx +++ b/packages/react/test/test.spec.tsx @@ -3,8 +3,9 @@ import { domElementMatchers } from './chai-dom-element'; import { ReactTestDriver } from './react-test-driver'; import { expectImageSnapshot, getInteractionApi } from '@zeejs/test-browser/browser'; import React from 'react'; +import { waitFor } from 'promise-assist'; import chai, { expect } from 'chai'; -import { stub } from 'sinon'; +import { stub, spy } from 'sinon'; import sinonChai from 'sinon-chai'; chai.use(sinonChai); chai.use(domElementMatchers); @@ -420,7 +421,9 @@ describe(`react`, () => { expect(document.activeElement, `ignore blocked parent`).to.equal(layerFirstInput); }); - it(`should re-focus last element of an un-blocked layer`, () => { + it(`should re-focus last element of an un-blocked layer`, async () => { + const warnSpy = spy(console, `warn`); + const errorSpy = spy(console, `error`); const { expectHTMLQuery, setData } = testDriver.render( (renderLayer) => ( @@ -435,11 +438,19 @@ describe(`react`, () => { setData(true); - expect(document.activeElement, `blocked input blur`).to.equal(document.body); + await waitFor(() => { + expect(document.activeElement, `blocked input blur`).to.equal(document.body); + }); setData(false); - expect(document.activeElement, `refocus input`).to.equal(bgInput); + await waitFor(() => { + expect(document.activeElement, `refocus input`).to.equal(bgInput); + }); + /* blur/re-focus is delayed because React listens for blur of rendered elements during render. + just check that no logs have been called. */ + expect(warnSpy, `no react warning`).to.have.callCount(0); + expect(errorSpy, `no react error`).to.have.callCount(0); }); }); });