Skip to content

Commit d446c35

Browse files
committed
ui: attach WASD key listener to the main UI, not the document body
For android-graphics/sokatoa#4271 Signed-off-by: Christian W. Damus <cdamus.ext@eclipsesource.com>
1 parent de57204 commit d446c35

File tree

3 files changed

+128
-7
lines changed

3 files changed

+128
-7
lines changed

ui/src/base/dom_utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,20 @@ export function bindEventListener<K extends keyof HTMLElementEventMap>(
137137
},
138138
};
139139
}
140+
141+
export function ancestorThat(el: Element | null, predicate: (htmlEl: HTMLElement) => boolean): HTMLElement | undefined {
142+
let result: HTMLElement | undefined;
143+
144+
while (!result && el instanceof HTMLElement) {
145+
if (predicate(el)) {
146+
result = el;
147+
}
148+
el = el.parentElement;
149+
}
150+
151+
return result;
152+
}
153+
154+
export function matchesSelector(selector: string): (el: HTMLElement) => boolean {
155+
return (el) => el.matches(selector);
156+
}

ui/src/base/dom_utils_unittest.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
// limitations under the License.
1414

1515
import {
16+
ancestorThat,
1617
bindEventListener,
1718
elementIsEditable,
1819
findRef,
1920
isOrContains,
21+
matchesSelector,
2022
toHTMLElement,
2123
} from './dom_utils';
2224

@@ -162,3 +164,102 @@ describe('bindEventListener', () => {
162164
}).not.toThrow();
163165
});
164166
});
167+
168+
describe('ancestorThat', () => {
169+
test('returns undefined for null element', () => {
170+
expect(ancestorThat(null, () => true)).toBeUndefined();
171+
});
172+
173+
test('returns the element itself if it matches the predicate', () => {
174+
const el = document.createElement('div');
175+
el.classList.add('target');
176+
expect(ancestorThat(el, (e) => e.classList.contains('target'))).toBe(el);
177+
});
178+
179+
test('finds ancestor matching predicate', () => {
180+
const grandparent = document.createElement('div');
181+
grandparent.classList.add('grandparent');
182+
const parent = document.createElement('div');
183+
parent.classList.add('parent');
184+
const child = document.createElement('div');
185+
child.classList.add('child');
186+
187+
grandparent.appendChild(parent);
188+
parent.appendChild(child);
189+
190+
expect(ancestorThat(child, (e) => e.classList.contains('parent'))).toBe(
191+
parent,
192+
);
193+
expect(
194+
ancestorThat(child, (e) => e.classList.contains('grandparent')),
195+
).toBe(grandparent);
196+
});
197+
198+
test('returns closest matching ancestor', () => {
199+
const outer = document.createElement('div');
200+
outer.classList.add('match');
201+
const inner = document.createElement('div');
202+
inner.classList.add('match');
203+
const child = document.createElement('div');
204+
205+
outer.appendChild(inner);
206+
inner.appendChild(child);
207+
208+
expect(ancestorThat(child, (e) => e.classList.contains('match'))).toBe(
209+
inner,
210+
);
211+
});
212+
213+
test('returns undefined when no ancestor matches', () => {
214+
const parent = document.createElement('div');
215+
const child = document.createElement('div');
216+
parent.appendChild(child);
217+
218+
expect(
219+
ancestorThat(child, (e) => e.classList.contains('nonexistent')),
220+
).toBeUndefined();
221+
});
222+
223+
test('does not match non-HTMLElement ancestors', () => {
224+
const svgElement = document.createElementNS(
225+
'http://www.w3.org/2000/svg',
226+
'svg',
227+
);
228+
expect(ancestorThat(svgElement, () => true)).toBeUndefined();
229+
});
230+
});
231+
232+
describe('matchesSelector', () => {
233+
test('matchesSelector for a single class', () => {
234+
const el = document.createElement('div');
235+
el.classList.add('foo');
236+
237+
const hasFoo = matchesSelector('.foo');
238+
const hasBar = matchesSelector('.bar');
239+
240+
expect(hasFoo(el)).toBe(true);
241+
expect(hasBar(el)).toBe(false);
242+
});
243+
244+
test('checks for multiple classes', () => {
245+
const el = document.createElement('div');
246+
el.classList.add('foo', 'bar');
247+
248+
const hasFooAndBar = matchesSelector('.foo.bar');
249+
const hasFooAndBaz = matchesSelector('.foo.baz');
250+
251+
expect(hasFooAndBar(el)).toBe(true);
252+
expect(hasFooAndBaz(el)).toBe(false);
253+
});
254+
255+
test('works with ancestorThat', () => {
256+
const parent = document.createElement('div');
257+
parent.classList.add('container', 'active');
258+
const child = document.createElement('div');
259+
parent.appendChild(child);
260+
261+
expect(ancestorThat(child, matchesSelector('.container'))).toBe(parent);
262+
expect(ancestorThat(child, matchesSelector('.container.active'))).toBe(parent);
263+
expect(ancestorThat(child, matchesSelector('.container.inactive'))).toBeUndefined();
264+
});
265+
});

ui/src/frontend/viewer_page/wasd_navigation_handler.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// limitations under the License.
1414

1515
import {DisposableStack} from '../../base/disposable_stack';
16-
import {currentTargetOffset, elementIsEditable} from '../../base/dom_utils';
16+
import {ancestorThat, currentTargetOffset, elementIsEditable, matchesSelector} from '../../base/dom_utils';
1717
import {Animation} from '../animation';
1818

1919
// When first starting to pan or zoom, move at least this many units.
@@ -113,18 +113,21 @@ export class KeyboardNavigationHandler implements Disposable {
113113
this.onZoomed = onZoomed;
114114
this.trash = new DisposableStack();
115115

116-
if (!element.getAttribute('tabindex')) {
116+
// Don't add the listener on the document body because we may be embedded in a host application.
117+
// Instead, add the listener on the containing UIMain if we can find it, otherwise the element
118+
const keyTarget = ancestorThat(this.element, matchesSelector('.pf-ui-main')) ?? this.element;
119+
if (!keyTarget.getAttribute('tabindex')) {
117120
// Make it focusable and also tabbable for keyboard accessibility
118-
element.setAttribute('tabindex', '0');
121+
keyTarget.setAttribute('tabindex', '0');
119122
}
120123

121-
document.body.addEventListener('keydown', this.boundOnKeyDown);
122-
document.body.addEventListener('keyup', this.boundOnKeyUp);
124+
keyTarget.addEventListener('keydown', this.boundOnKeyDown);
125+
keyTarget.addEventListener('keyup', this.boundOnKeyUp);
123126
this.element.addEventListener('mousemove', this.boundOnMouseMove);
124127
this.trash.defer(() => {
125128
this.element.removeEventListener('mousemove', this.boundOnMouseMove);
126-
document.body.removeEventListener('keyup', this.boundOnKeyUp);
127-
document.body.removeEventListener('keydown', this.boundOnKeyDown);
129+
keyTarget.removeEventListener('keyup', this.boundOnKeyUp);
130+
keyTarget.removeEventListener('keydown', this.boundOnKeyDown);
128131
});
129132
}
130133

0 commit comments

Comments
 (0)