Skip to content

Commit

Permalink
feat: navigation manager (#177)
Browse files Browse the repository at this point in the history
  • Loading branch information
divdavem authored Oct 6, 2023
1 parent 77f3dcf commit f36bcde
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 0 deletions.
1 change: 1 addition & 0 deletions core/lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './intersection';
export * from './portal';
export * from './stores';
export * from './writables';
export * from './navManager';
67 changes: 67 additions & 0 deletions core/lib/services/navManager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {beforeEach, describe, expect, test} from 'vitest';
import {createNavManager} from './navManager';

describe(`navManager`, () => {
let parentElement: HTMLElement;
beforeEach(() => {
parentElement = document.body.appendChild(document.createElement('div'));
return () => {
parentElement.parentElement?.removeChild(parentElement);
};
});
const sendKey = (key: 'ArrowLeft' | 'ArrowRight') => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', {key}));

test('Basic functionalities', () => {
parentElement.innerHTML = `
<span id="element1"></span>
<input type="checkbox" id="element2">
<input type="text" id="element3" value="some content">
<span id="element4"></span>
`;
const element1 = document.getElementById('element1')!;
const element2 = document.getElementById('element2')!;
const element3 = document.getElementById('element3') as HTMLInputElement;
const element4 = document.getElementById('element4')!;
const navManager = createNavManager();
const directive1 = navManager.directive(element1);
const directive2 = navManager.directive(element2);
// intentionnally not called in DOM order to check that the array is properly sorted:
const directive4 = navManager.directive(element4);
const directive3 = navManager.directive(element3);
element1.focus();
expect(document.activeElement).toBe(element1);
sendKey('ArrowRight');
expect(document.activeElement).toBe(element2);
sendKey('ArrowRight');
expect(document.activeElement).toBe(element3);
element3.setSelectionRange(0, 0);
sendKey('ArrowRight');
// as the cursor is not at the end yet, the focus did not move
expect(document.activeElement).toBe(element3);
element3.setSelectionRange(element3.value.length, element3.value.length);
sendKey('ArrowRight');
expect(document.activeElement).toBe(element4);
sendKey('ArrowRight');
// last element, the focus cannot move:
expect(document.activeElement).toBe(element4);
// now go backward:
sendKey('ArrowLeft');
expect(document.activeElement).toBe(element3);
element3.setSelectionRange(1, 1);
sendKey('ArrowLeft');
// as the cursor is not at the beginning yet, the focus did not move
expect(document.activeElement).toBe(element3);
element3.setSelectionRange(0, 0);
sendKey('ArrowLeft');
expect(document.activeElement).toBe(element2);
sendKey('ArrowLeft');
expect(document.activeElement).toBe(element1);
sendKey('ArrowLeft');
// first element, the focus cannot move:
expect(document.activeElement).toBe(element1);
directive1?.destroy?.();
directive2?.destroy?.();
directive3?.destroy?.();
directive4?.destroy?.();
});
});
58 changes: 58 additions & 0 deletions core/lib/services/navManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type {Directive} from '../types';
import {compareDomOrder} from './sortUtils';
import {registrationArray} from './directiveUtils';
import {computed} from '@amadeus-it-group/tansu';

export type NavManager = ReturnType<typeof createNavManager>;

// cf https://html.spec.whatwg.org/multipage/input.html#concept-input-apply
const textInputTypes = new Set(['text', 'search', 'url', 'tel', 'password']);
const isTextInput = (element: any): element is HTMLInputElement => element instanceof HTMLInputElement && textInputTypes.has(element.type);

export const createNavManager = () => {
const array$ = registrationArray<HTMLElement>();
const sortedArray$ = computed(() => [...array$()].sort(compareDomOrder));
const directive: Directive = (element) => {
const onKeyDown = (event: KeyboardEvent) => {
let move = 0;
switch (event.key) {
case 'ArrowLeft':
move = -1;
break;
case 'ArrowRight':
move = 1;
break;
}
if (isTextInput(event.target)) {
const cursorPosition = event.target.selectionStart === event.target.selectionEnd ? event.target.selectionStart : null;
if ((cursorPosition !== 0 && move < 0) || (cursorPosition !== event.target.value.length && move > 0)) {
move = 0;
}
}
if (move != 0) {
const array = sortedArray$();
const currentIndex = array.indexOf(element);
const newIndex = currentIndex + move;
if (newIndex < array.length && newIndex >= 0) {
const newItem = array[newIndex];
event.preventDefault();
newItem.focus();
if (isTextInput(newItem)) {
const position = move < 0 ? newItem.value.length : 0;
newItem.setSelectionRange(position, position);
}
}
}
};
element.addEventListener('keydown', onKeyDown);
const unregister = array$.register(element);
return {
destroy() {
element.removeEventListener('keydown', onKeyDown);
unregister();
},
};
};

return {directive};
};
26 changes: 26 additions & 0 deletions core/lib/services/sortUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {describe, expect, it} from 'vitest';
import {compareDomOrder} from './sortUtils';

describe('arrayUtils', () => {
describe('compareDomOrder', () => {
it('should sort in the right order', () => {
const element = document.createElement('div');
const element1 = document.createElement('div');
element1.id = 'id1';
const element2 = document.createElement('div');
element2.id = 'id2';
const element3 = document.createElement('div');
element3.id = 'id3';
element.appendChild(element1);
element.appendChild(element2);
element.appendChild(element3);
expect([element1, element2, element3].sort(compareDomOrder)).toStrictEqual([element1, element2, element3]);
expect([element1, element3, element2].sort(compareDomOrder)).toStrictEqual([element1, element2, element3]);
expect([element2, element1, element3].sort(compareDomOrder)).toStrictEqual([element1, element2, element3]);
expect([element2, element3, element1].sort(compareDomOrder)).toStrictEqual([element1, element2, element3]);
expect([element3, element1, element2].sort(compareDomOrder)).toStrictEqual([element1, element2, element3]);
expect([element3, element2, element1].sort(compareDomOrder)).toStrictEqual([element1, element2, element3]);
expect([element1, element3, element1].sort(compareDomOrder)).toStrictEqual([element1, element1, element3]);
});
});
});
14 changes: 14 additions & 0 deletions core/lib/services/sortUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const compareDefault = (a: any, b: any) => (a < b ? -1 : a > b ? 1 : 0);

export const compareDomOrder = (element1: Node, element2: Node) => {
if (element1 === element2) {
return 0;
}
const result = element1.compareDocumentPosition(element2);
if (result & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1;
} else if (result & Node.DOCUMENT_POSITION_PRECEDING) {
return 1;
}
throw new Error('failed to compare elements');
};

0 comments on commit f36bcde

Please sign in to comment.