feat: Rewrite keyboardEventListener mixin to a composable (#9831)
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import {
|
||||
LAYOUT_QWERTY,
|
||||
LAYOUT_QWERTZ,
|
||||
LAYOUT_AZERTY,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
describe('useDetectKeyboardLayout', () => {
|
||||
beforeEach(() => {
|
||||
window.cw_keyboard_layout = null;
|
||||
});
|
||||
|
||||
it('returns cached layout if available', async () => {
|
||||
window.cw_keyboard_layout = LAYOUT_QWERTY;
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTY);
|
||||
});
|
||||
|
||||
it('should detect QWERTY layout using modern method', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'q'],
|
||||
['KeyW', 'w'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'y'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTY);
|
||||
});
|
||||
|
||||
it('should detect QWERTZ layout using modern method', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'q'],
|
||||
['KeyW', 'w'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'z'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTZ);
|
||||
});
|
||||
|
||||
it('should detect AZERTY layout using modern method', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'a'],
|
||||
['KeyW', 'z'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'y'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_AZERTY);
|
||||
});
|
||||
|
||||
it('should use legacy method if navigator.keyboard is not available', async () => {
|
||||
navigator.keyboard = undefined;
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect([LAYOUT_QWERTY, LAYOUT_QWERTZ, LAYOUT_AZERTY]).toContain(layout);
|
||||
});
|
||||
|
||||
it('should cache the detected layout', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'q'],
|
||||
['KeyW', 'w'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'y'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTY);
|
||||
|
||||
const layoutAgain = await useDetectKeyboardLayout();
|
||||
expect(layoutAgain).toBe(LAYOUT_QWERTY);
|
||||
expect(navigator.keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { unref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
describe('useKeyboardEvents', () => {
|
||||
it('should be defined', () => {
|
||||
expect(useKeyboardEvents).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return a function', () => {
|
||||
expect(useKeyboardEvents).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = unref({ value: el });
|
||||
const events = {
|
||||
'ALT+KeyL': () => {},
|
||||
};
|
||||
|
||||
const mountedMock = vi.fn();
|
||||
const unmountedMock = vi.fn();
|
||||
useKeyboardEvents(events, elRef);
|
||||
mountedMock();
|
||||
unmountedMock();
|
||||
|
||||
expect(mountedMock).toHaveBeenCalled();
|
||||
expect(unmountedMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
LAYOUT_QWERTY,
|
||||
LAYOUT_QWERTZ,
|
||||
LAYOUT_AZERTY,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
/**
|
||||
* Detects the keyboard layout using a legacy method by creating a hidden input and dispatching a key event.
|
||||
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||
*/
|
||||
async function detectLegacy() {
|
||||
const input = document.createElement('input');
|
||||
input.style.position = 'fixed';
|
||||
input.style.top = '-100px';
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
return new Promise(resolve => {
|
||||
const keyboardEvent = new KeyboardEvent('keypress', {
|
||||
key: 'y',
|
||||
keyCode: 89,
|
||||
which: 89,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
const handler = e => {
|
||||
document.body.removeChild(input);
|
||||
document.removeEventListener('keypress', handler);
|
||||
if (e.key === 'z') {
|
||||
resolve(LAYOUT_QWERTY);
|
||||
} else if (e.key === 'y') {
|
||||
resolve(LAYOUT_QWERTZ);
|
||||
} else {
|
||||
resolve(LAYOUT_AZERTY);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keypress', handler);
|
||||
input.dispatchEvent(keyboardEvent);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the keyboard layout using the modern navigator.keyboard API.
|
||||
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||
*/
|
||||
async function detect() {
|
||||
const map = await navigator.keyboard.getLayoutMap();
|
||||
const q = map.get('KeyQ');
|
||||
const w = map.get('KeyW');
|
||||
const e = map.get('KeyE');
|
||||
const r = map.get('KeyR');
|
||||
const t = map.get('KeyT');
|
||||
const y = map.get('KeyY');
|
||||
|
||||
return [q, w, e, r, t, y].join('').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses either the modern or legacy method to detect the keyboard layout, caching the result.
|
||||
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||
*/
|
||||
export async function useDetectKeyboardLayout() {
|
||||
const cachedLayout = window.cw_keyboard_layout;
|
||||
if (cachedLayout) {
|
||||
return cachedLayout;
|
||||
}
|
||||
|
||||
const layout = navigator.keyboard ? await detect() : await detectLegacy();
|
||||
window.cw_keyboard_layout = layout;
|
||||
return layout;
|
||||
}
|
||||
115
app/javascript/dashboard/composables/useKeyboardEvents.js
Normal file
115
app/javascript/dashboard/composables/useKeyboardEvents.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { onMounted, onBeforeUnmount, unref } from 'vue';
|
||||
import {
|
||||
isActiveElementTypeable,
|
||||
isEscape,
|
||||
keysToModifyInQWERTZ,
|
||||
LAYOUT_QWERTZ,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { createKeybindingsHandler } from 'tinykeys';
|
||||
|
||||
const keyboardListenerMap = new WeakMap();
|
||||
|
||||
/**
|
||||
* Determines if the keyboard event should be ignored based on the element type and handler settings.
|
||||
* @param {Event} e - The event object.
|
||||
* @param {Object|Function} handler - The handler configuration or function.
|
||||
* @returns {boolean} - True if the event should be ignored, false otherwise.
|
||||
*/
|
||||
const shouldIgnoreEvent = (e, handler) => {
|
||||
const isTypeable = isActiveElementTypeable(e);
|
||||
const allowOnFocusedInput =
|
||||
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
|
||||
|
||||
if (isTypeable) {
|
||||
if (isEscape(e)) {
|
||||
e.target.blur();
|
||||
}
|
||||
return !allowOnFocusedInput;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the event handler to include custom logic before executing the handler.
|
||||
* @param {Function} handler - The original event handler.
|
||||
* @returns {Function} - The wrapped handler.
|
||||
*/
|
||||
const keydownWrapper = handler => {
|
||||
return e => {
|
||||
if (shouldIgnoreEvent(e, handler)) return;
|
||||
// extract the action to perform from the handler
|
||||
|
||||
const actionToPerform =
|
||||
typeof handler === 'function' ? handler : handler.action;
|
||||
actionToPerform(e);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps all provided keyboard events in handlers that respect the current keyboard layout.
|
||||
* @param {Object} events - The object containing event names and their handlers.
|
||||
* @returns {Object} - The object with event names possibly modified based on the keyboard layout and wrapped handlers.
|
||||
*/
|
||||
async function wrapEventsInKeybindingsHandler(events) {
|
||||
const wrappedEvents = {};
|
||||
const currentLayout = await useDetectKeyboardLayout();
|
||||
|
||||
Object.keys(events).forEach(originalEventName => {
|
||||
const modifiedEventName =
|
||||
currentLayout === LAYOUT_QWERTZ &&
|
||||
keysToModifyInQWERTZ.has(originalEventName)
|
||||
? `Shift+${originalEventName}`
|
||||
: originalEventName;
|
||||
|
||||
wrappedEvents[modifiedEventName] = keydownWrapper(
|
||||
events[originalEventName]
|
||||
);
|
||||
});
|
||||
return wrappedEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up keyboard event listeners on the specified element.
|
||||
* @param {Element} root - The DOM element to attach listeners to.
|
||||
* @param {Object} events - The events to listen for.
|
||||
*/
|
||||
const setupListeners = (root, events) => {
|
||||
if (root instanceof Element && events) {
|
||||
const keydownHandler = createKeybindingsHandler(events);
|
||||
const handler = window.addEventListener('keydown', keydownHandler);
|
||||
keyboardListenerMap.set(root, handler);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes keyboard event listeners from the specified element.
|
||||
* @param {Element} root - The DOM element to remove listeners from.
|
||||
*/
|
||||
const removeListeners = root => {
|
||||
if (root instanceof Element) {
|
||||
const handlerToRemove = keyboardListenerMap.get(root);
|
||||
document.removeEventListener('keydown', handlerToRemove);
|
||||
keyboardListenerMap.delete(root);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Vue composable to handle keyboard events with support for different keyboard layouts.
|
||||
* @param {Object} keyboardEvents - The keyboard events to handle.
|
||||
* @param {ref} elRef - A Vue ref to the element to attach the keyboard events to.
|
||||
*/
|
||||
export function useKeyboardEvents(keyboardEvents, elRef = null) {
|
||||
onMounted(async () => {
|
||||
const el = unref(elRef);
|
||||
const getKeyboardEvents = () => keyboardEvents || null;
|
||||
const events = getKeyboardEvents();
|
||||
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
|
||||
setupListeners(el, wrappedEvents);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const el = unref(elRef);
|
||||
removeListeners(el);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user