diff --git a/core/lib/services/index.ts b/core/lib/services/index.ts index 842f8f67cc..849e146f4e 100644 --- a/core/lib/services/index.ts +++ b/core/lib/services/index.ts @@ -5,3 +5,4 @@ export * from './intersection'; export * from './portal'; export * from './stores'; export * from './writables'; +export * from './navManager'; diff --git a/core/lib/services/navManager.spec.ts b/core/lib/services/navManager.spec.ts new file mode 100644 index 0000000000..eaa982549f --- /dev/null +++ b/core/lib/services/navManager.spec.ts @@ -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?.(); + }); +}); diff --git a/core/lib/services/navManager.ts b/core/lib/services/navManager.ts new file mode 100644 index 0000000000..5e1d12040f --- /dev/null +++ b/core/lib/services/navManager.ts @@ -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}; +}; diff --git a/core/lib/services/sortUtils.spec.ts b/core/lib/services/sortUtils.spec.ts new file mode 100644 index 0000000000..ac3f97a038 --- /dev/null +++ b/core/lib/services/sortUtils.spec.ts @@ -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]); + }); + }); +}); diff --git a/core/lib/services/sortUtils.ts b/core/lib/services/sortUtils.ts new file mode 100644 index 0000000000..ab6d4b870e --- /dev/null +++ b/core/lib/services/sortUtils.ts @@ -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'); +};