feat: Replace the use of mentionSelectionKeyboard mixin to a composable (#9904)
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardNavigableList } from '../useKeyboardNavigableList';
|
||||
import { useKeyboardEvents } from '../useKeyboardEvents';
|
||||
|
||||
// Mock the useKeyboardEvents function
|
||||
vi.mock('../useKeyboardEvents', () => ({
|
||||
useKeyboardEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useKeyboardNavigableList', () => {
|
||||
let elementRef;
|
||||
let items;
|
||||
let onSelect;
|
||||
let adjustScroll;
|
||||
let selectedIndex;
|
||||
|
||||
const createMockEvent = () => ({ preventDefault: vi.fn() });
|
||||
|
||||
beforeEach(() => {
|
||||
elementRef = ref(null);
|
||||
items = ref(['item1', 'item2', 'item3']);
|
||||
onSelect = vi.fn();
|
||||
adjustScroll = vi.fn();
|
||||
selectedIndex = ref(0);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return moveSelectionUp and moveSelectionDown functions', () => {
|
||||
const result = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('moveSelectionUp');
|
||||
expect(result).toHaveProperty('moveSelectionDown');
|
||||
expect(typeof result.moveSelectionUp).toBe('function');
|
||||
expect(typeof result.moveSelectionDown).toBe('function');
|
||||
});
|
||||
|
||||
it('should move selection up correctly', () => {
|
||||
const { moveSelectionUp } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(0);
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should move selection down correctly', () => {
|
||||
const { moveSelectionDown } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(0);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
});
|
||||
|
||||
it('should call adjustScroll after moving selection', () => {
|
||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionUp();
|
||||
expect(adjustScroll).toHaveBeenCalledTimes(1);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(adjustScroll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should include Enter key handler when onSelect is provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
|
||||
expect(keyboardEvents).toHaveProperty('Enter');
|
||||
expect(keyboardEvents.Enter.allowOnFocusedInput).toBe(true);
|
||||
});
|
||||
|
||||
it('should not include Enter key handler when onSelect is not provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
|
||||
expect(keyboardEvents).not.toHaveProperty('Enter');
|
||||
});
|
||||
|
||||
it('should not trigger onSelect when items are empty', () => {
|
||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items: ref([]),
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionUp();
|
||||
moveSelectionDown();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call useKeyboardEvents with correct parameters', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
expect(useKeyboardEvents).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
elementRef
|
||||
);
|
||||
});
|
||||
|
||||
// Keyboard event handlers
|
||||
it('should handle ArrowUp key', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents.ArrowUp.action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Control+KeyP', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents['Control+KeyP'].action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle ArrowDown key', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents.ArrowDown.action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Control+KeyN', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents['Control+KeyN'].action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Enter key when onSelect is provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents.Enter.action(mockEvent);
|
||||
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not have Enter key handler when onSelect is not provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const keyboardEventsWithoutSelect = useKeyboardEvents.mock.calls[0][0];
|
||||
expect(keyboardEventsWithoutSelect).not.toHaveProperty('Enter');
|
||||
});
|
||||
|
||||
it('should set allowOnFocusedInput to true for all key handlers', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const keyHandlers = [
|
||||
'ArrowUp',
|
||||
'Control+KeyP',
|
||||
'ArrowDown',
|
||||
'Control+KeyN',
|
||||
'Enter',
|
||||
];
|
||||
keyHandlers.forEach(key => {
|
||||
if (keyboardEvents[key]) {
|
||||
expect(keyboardEvents[key].allowOnFocusedInput).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
118
app/javascript/dashboard/composables/useKeyboardNavigableList.js
Normal file
118
app/javascript/dashboard/composables/useKeyboardNavigableList.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* This composable provides keyboard navigation functionality for list-like UI components
|
||||
* such as dropdowns, autocomplete suggestions, or any list of selectable items.
|
||||
*
|
||||
* TODO - Things that can be improved in the future
|
||||
* - The scrolling should be handled by the component instead of the consumer of this composable
|
||||
* it can be done if we know the item height.
|
||||
* - The focus should be trapped within the list.
|
||||
* - onSelect should be callback instead of a function that is passed
|
||||
*/
|
||||
import { useKeyboardEvents } from './useKeyboardEvents';
|
||||
|
||||
/**
|
||||
* Wrap the action in a function that calls the action and prevents the default event behavior.
|
||||
* @param {Function} action - The action to be called.
|
||||
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
|
||||
*/
|
||||
const createAction = action => ({
|
||||
action: e => {
|
||||
action();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates keyboard event handlers for navigation.
|
||||
* @param {Function} moveSelectionUp - Function to move selection up.
|
||||
* @param {Function} moveSelectionDown - Function to move selection down.
|
||||
* @param {Function} [onSelect] - Optional function to handle selection.
|
||||
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
|
||||
* @returns {Object.<string, {action: Function, allowOnFocusedInput: boolean}>}
|
||||
*/
|
||||
const createKeyboardEvents = (
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
onSelect,
|
||||
items
|
||||
) => {
|
||||
const events = {
|
||||
ArrowUp: createAction(moveSelectionUp),
|
||||
'Control+KeyP': createAction(moveSelectionUp),
|
||||
ArrowDown: createAction(moveSelectionDown),
|
||||
'Control+KeyN': createAction(moveSelectionDown),
|
||||
};
|
||||
|
||||
// Adds an event handler for the Enter key if the onSelect function is provided.
|
||||
if (typeof onSelect === 'function') {
|
||||
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the selection index based on the current index, total number of items, and direction of movement.
|
||||
*
|
||||
* @param {number} currentIndex - The current index of the selected item.
|
||||
* @param {number} itemsLength - The total number of items in the list.
|
||||
* @param {string} direction - The direction of movement, either 'up' or 'down'.
|
||||
* @returns {number} The new index after moving in the specified direction.
|
||||
*/
|
||||
const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
|
||||
// If the selected index is the first item, move to the last item
|
||||
// If the selected index is the last item, move to the first item
|
||||
if (direction === 'up') {
|
||||
return currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
|
||||
}
|
||||
return currentIndex === itemsLength - 1 ? 0 : currentIndex + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* A composable for handling keyboard navigation in mention selection scenarios.
|
||||
*
|
||||
* @param {Object} options - The options for the composable.
|
||||
* @param {import('vue').Ref<HTMLElement>} options.elementRef - A ref to the DOM element that will receive keyboard events.
|
||||
* @param {import('vue').Ref<Array>} options.items - A ref to the array of selectable items.
|
||||
* @param {Function} [options.onSelect] - An optional function to be called when an item is selected.
|
||||
* @param {Function} options.adjustScroll - A function to adjust the scroll position after selection changes.
|
||||
* @param {import('vue').Ref<number>} options.selectedIndex - A ref to the currently selected index.
|
||||
* @returns {{
|
||||
* moveSelectionUp: Function,
|
||||
* moveSelectionDown: Function
|
||||
* }} An object containing functions to move the selection up and down.
|
||||
*/
|
||||
export function useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
}) {
|
||||
const moveSelection = direction => {
|
||||
selectedIndex.value = updateSelectionIndex(
|
||||
selectedIndex.value,
|
||||
items.value.length,
|
||||
direction
|
||||
);
|
||||
adjustScroll();
|
||||
};
|
||||
|
||||
const moveSelectionUp = () => moveSelection('up');
|
||||
const moveSelectionDown = () => moveSelection('down');
|
||||
|
||||
const keyboardEvents = createKeyboardEvents(
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
onSelect,
|
||||
items
|
||||
);
|
||||
|
||||
useKeyboardEvents(keyboardEvents, elementRef);
|
||||
|
||||
return {
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user