UNPKG

navkit-vue

Version:

Vue navigation and utility composables

776 lines (768 loc) 29.1 kB
import { computed, isRef, ref, unref, watch, onMounted, onUnmounted, nextTick } from 'vue'; var KeyboardEnum; (function (KeyboardEnum) { KeyboardEnum["Left"] = "ArrowLeft"; KeyboardEnum["Right"] = "ArrowRight"; KeyboardEnum["Up"] = "ArrowUp"; KeyboardEnum["Down"] = "ArrowDown"; KeyboardEnum["Enter"] = "Enter"; KeyboardEnum["Back"] = "Escape"; })(KeyboardEnum || (KeyboardEnum = {})); // Logic function useNavigation({ rows, onColumnStart, onColumnEnd, onRowStart, onRowEnd, onEnter, onReturn, initialPosition = { row: 0, col: 0 }, disabled = ref(false), focusableSelector = "[data-focusable]", autofocus = true, focusClass = "focused", cyclic = false, invertAxis = false, autoNextRow = false, holdColumnPerRow = false, }) { const computedRows = computed(() => (isRef(rows) ? rows.value : rows)); const currentPosition = ref(initialPosition); const lastFocusedElement = ref(null); const positions = ref(Array.from({ length: computedRows.value.length }).map((_) => 0)); let isProcessing = false; // Disabling the hook const isDisabled = computed(() => unref(disabled)); const toggleDisabled = (value) => { if (value !== undefined) { if (isRef(disabled)) { disabled.value = value; } else { disabled = value; } } else { if (isRef(disabled)) { disabled.value = !disabled.value; } else { disabled = !disabled; } } }; const findFocusableElements = () => { return Array.from(document.querySelectorAll(focusableSelector)); }; const isValidPosition = (pos) => { if (pos.row < 0 || pos.row >= computedRows.value.length) return false; return pos.col >= 0 && pos.col < computedRows.value[pos.row]; }; const getFocusableElement = (pos) => { const elements = findFocusableElements(); if (!elements.length) return null; let currentRow = 0; let currentCol = 0; for (const element of elements) { if (currentRow === pos.row && currentCol === pos.col) { return element; } currentCol++; if (currentCol >= computedRows.value[currentRow]) { currentRow++; currentCol = 0; } } return null; }; // Function to focus an element const focusElement = (element) => { if (isProcessing || !element) return; isProcessing = true; try { // Remove focus from previous element if (lastFocusedElement.value) { lastFocusedElement.value.classList.remove(focusClass); lastFocusedElement.value.blur(); } // Focus new element element.classList.add(focusClass); if (autofocus) { element.focus(); } lastFocusedElement.value = element; } catch (error) { console.error("Focus error:", error); } finally { isProcessing = false; } }; function setRowPosition(row, position) { return (positions.value[row] = position); } // Handler for keydown events const handleKeyDown = (event) => { try { if (isDisabled.value || isProcessing) return; const newPosition = { ...currentPosition.value }; switch (event.key) { case KeyboardEnum.Up: if (invertAxis) { newPosition.col--; positions.value[newPosition.row] = newPosition.col; if (newPosition.col < 0) { if (cyclic) { newPosition.col = computedRows.value[newPosition.row] - 1; } else { onRowStart === null || onRowStart === void 0 ? void 0 : onRowStart(); return; } } break; } newPosition.row--; if (newPosition.row < 0) { if (cyclic) { newPosition.row = computedRows.value.length - 1; } else { onRowStart === null || onRowStart === void 0 ? void 0 : onRowStart(); return; } } if (newPosition.col >= computedRows.value[newPosition.row]) { newPosition.col = computedRows.value[newPosition.row] - 1; } else { newPosition.col = holdColumnPerRow ? currentPosition.value.col : positions.value[newPosition.row]; } break; case KeyboardEnum.Down: if (invertAxis) { newPosition.col++; positions.value[newPosition.row] = newPosition.col; if (newPosition.col >= computedRows.value[newPosition.row]) { if (cyclic) { newPosition.col = 0; } else { onRowEnd === null || onRowEnd === void 0 ? void 0 : onRowEnd(); return; } } break; } newPosition.row++; if (newPosition.row >= computedRows.value.length) { if (cyclic) { newPosition.row = 0; } else { onRowEnd === null || onRowEnd === void 0 ? void 0 : onRowEnd(); return; } } if (newPosition.col >= computedRows.value[newPosition.row]) { newPosition.col = computedRows.value[newPosition.row] - 1; } else { newPosition.col = holdColumnPerRow ? currentPosition.value.col : positions.value[newPosition.row]; } break; case KeyboardEnum.Left: if (invertAxis) { newPosition.row--; newPosition.col = holdColumnPerRow ? currentPosition.value.col : positions.value[newPosition.row]; break; } newPosition.col--; positions.value[newPosition.row] = newPosition.col; if (newPosition.col < 0) { if (cyclic) { if (autoNextRow) { newPosition.row = newPosition.row > 0 ? newPosition.row - 1 : computedRows.value.length - 1; } newPosition.col = computedRows.value[newPosition.row] - 1; } else { onColumnStart === null || onColumnStart === void 0 ? void 0 : onColumnStart(); return; } } break; case KeyboardEnum.Right: if (invertAxis) { newPosition.row++; newPosition.col = holdColumnPerRow ? currentPosition.value.col : positions.value[newPosition.row]; break; } newPosition.col++; positions.value[newPosition.row] = newPosition.col; if (newPosition.col >= computedRows.value[currentPosition.value.row]) { if (cyclic) { if (autoNextRow) { newPosition.row = newPosition.row < computedRows.value.length - 1 ? newPosition.row + 1 : 0; } newPosition.col = 0; } else { onColumnEnd === null || onColumnEnd === void 0 ? void 0 : onColumnEnd(); return; } } break; case KeyboardEnum.Enter: onEnter === null || onEnter === void 0 ? void 0 : onEnter(currentPosition.value); return; case KeyboardEnum.Back: onReturn === null || onReturn === void 0 ? void 0 : onReturn(currentPosition.value); return; default: console.warn("Unhandled Key:", event.code); return; } if (isValidPosition(newPosition)) { currentPosition.value = newPosition; } else { console.error("Position Not Valid. returning to default:", newPosition); currentPosition.value = { col: 0, row: 0, }; } } catch (error) { console.error("Error in handleKeyNavigation:", error); } }; const stopWatchPosition = watch(currentPosition, (newPosition) => { if (!isDisabled.value && !isProcessing) { const element = getFocusableElement(newPosition); focusElement(element); } }); // Check for disabled value, remove listeners if is disabled const stopWatchIsDisabled = watch(isDisabled, (newValue) => { var _a, _b; if (newValue) { (_a = lastFocusedElement.value) === null || _a === void 0 ? void 0 : _a.blur(); (_b = lastFocusedElement.value) === null || _b === void 0 ? void 0 : _b.classList.remove(focusClass); removeEventListeners(); } else { if (autofocus && !isDisabled.value) { const element = getFocusableElement(currentPosition.value); focusElement(element); } addEventListeners(); } }); const addEventListeners = () => { window.addEventListener("keydown", handleKeyDown); }; const removeEventListeners = () => { window.removeEventListener("keydown", handleKeyDown); }; // Lifecycle hooks onMounted(() => { if (!isDisabled.value) { addEventListeners(); if (autofocus) { const element = getFocusableElement(currentPosition.value); focusElement(element); } } }); onUnmounted(() => { stopWatchIsDisabled(); stopWatchPosition(); }); return { position: currentPosition, currentRow: positions.value[invertAxis ? currentPosition.value.col : currentPosition.value.row], currentElement: lastFocusedElement.value, isDisabled, setRowPosition, toggleDisabled, isValidPosition, focusElement, getCurrentFocusedElement: () => lastFocusedElement.value, debug: { focusableElements: findFocusableElements(), isHookDisabled: isDisabled.value, }, }; } function useNavigationX({ columns, initialPosition = 0, disabled = ref(false), focusableSelector = "[data-focusable]", autofocus = true, focusClass = "focused", cyclic = false, onEnter, onReturn, onColumnEnd, onColumnStart, onDown, onUp, }) { const computedCols = computed(() => unref(columns)); const currentPosition = ref(initialPosition); const lastFocusedElement = ref(null); let isProcessing = false; // Disabling the hook const isDisabled = computed(() => unref(disabled)); const isDisabledWatcher = watch(isDisabled, (newValue) => { var _a, _b; if (newValue) { (_a = lastFocusedElement.value) === null || _a === void 0 ? void 0 : _a.blur(); (_b = lastFocusedElement.value) === null || _b === void 0 ? void 0 : _b.classList.remove(focusClass); window.removeEventListener("keydown", handleKeyDown); } else { if (autofocus && !isDisabled.value) { const element = getFocusableElement(currentPosition.value); focusElement(element); } window.addEventListener("keydown", handleKeyDown); } }); const toggleDisabled = (value) => { if (value !== undefined) { if (isRef(disabled)) { disabled.value = value; } else { disabled = value; } } else { if (isRef(disabled)) { disabled.value = !disabled.value; } else { disabled = !disabled; } } }; function setPosition(pos) { return (currentPosition.value = pos); } const findFocusableElements = () => { return Array.from(document.querySelectorAll(focusableSelector)); }; const isValidPosition = (col) => { return col >= 0 && col < computedCols.value; }; const getFocusableElement = (pos) => { const elements = findFocusableElements(); let currentCol = 0; for (const element of elements) { if (currentCol === pos) { return element; } currentCol++; } return null; }; const focusElement = (element) => { if (isProcessing || !element) return; isProcessing = true; try { if (lastFocusedElement.value) { lastFocusedElement.value.classList.remove(focusClass); lastFocusedElement.value.blur(); } element.classList.add(focusClass); if (autofocus) { element.focus(); } lastFocusedElement.value = element; } catch (error) { console.error("Focus error:", error); } finally { isProcessing = false; } }; const handleKeyDown = (event) => { if (isDisabled.value || isProcessing) return; let newPosition = currentPosition.value; switch (event.key) { case KeyboardEnum.Left: newPosition--; if (newPosition < 0) { if (cyclic) { newPosition = computedCols.value - 1; } else { onColumnStart === null || onColumnStart === void 0 ? void 0 : onColumnStart(); return; } } break; case KeyboardEnum.Right: newPosition++; if (newPosition >= computedCols.value) { if (cyclic) { newPosition = 0; } else { onColumnEnd === null || onColumnEnd === void 0 ? void 0 : onColumnEnd(); return; } } break; case KeyboardEnum.Up: onUp === null || onUp === void 0 ? void 0 : onUp(); break; case KeyboardEnum.Down: onDown === null || onDown === void 0 ? void 0 : onDown(); break; case KeyboardEnum.Enter: onEnter === null || onEnter === void 0 ? void 0 : onEnter(currentPosition.value); return; case KeyboardEnum.Back: onReturn === null || onReturn === void 0 ? void 0 : onReturn(currentPosition.value); return; default: console.warn("Unhandled Key:", event.code); return; } if (isValidPosition(newPosition)) { currentPosition.value = newPosition; } else { console.error("Position Not Valid:", newPosition); } }; const currentPositionWatcher = watch(currentPosition, (newPosition) => { if (!isDisabled.value && !isProcessing) { const element = getFocusableElement(newPosition); focusElement(element); } }); // Lifecycle hooks onMounted(() => { if (isDisabled.value) { window.removeEventListener("keydown", handleKeyDown); } else { window.addEventListener("keydown", handleKeyDown); } if (autofocus && !isDisabled.value) { const element = getFocusableElement(currentPosition.value); focusElement(element); } }); onUnmounted(() => { window.removeEventListener("keydown", handleKeyDown); if (lastFocusedElement.value) { lastFocusedElement.value.classList.remove(focusClass); } isDisabledWatcher(); currentPositionWatcher(); }); return { position: currentPosition, currentElement: lastFocusedElement.value, setPosition, toggleDisabled, isValidPosition, focusElement, getCurrentFocusedElement: () => lastFocusedElement.value, }; } function useNavigationY({ rows, initialPosition = 0, disabled = ref(false), focusableSelector = "[data-focusable]", autofocus = true, focusClass = "focused", cyclic = false, onRowStart, onRowEnd, onEnter, onReturn, onRight, onLeft, }) { const computedRows = ref(unref(rows)); const currentPosition = ref(initialPosition); const lastFocusedElement = ref(null); let isProcessing = false; // Disabling the hook const isDisabled = computed(() => unref(disabled)); const isDisabledWatcher = watch(isDisabled, (newValue) => { var _a, _b; if (newValue) { (_a = lastFocusedElement.value) === null || _a === void 0 ? void 0 : _a.blur(); (_b = lastFocusedElement.value) === null || _b === void 0 ? void 0 : _b.classList.remove(focusClass); window.removeEventListener("keydown", handleKeyDown); } else { if (autofocus && !isDisabled.value) { const element = getFocusableElement(currentPosition.value); focusElement(element); } window.addEventListener("keydown", handleKeyDown); } }); const toggleDisabled = (value) => { if (value !== undefined) { if (isRef(disabled)) { disabled.value = value; } else { disabled = value; } } else { if (isRef(disabled)) { disabled.value = !disabled.value; } else { disabled = !disabled; } } }; function setPosition(pos) { return (currentPosition.value = pos); } const findFocusableElements = () => { return Array.from(document.querySelectorAll(focusableSelector)); }; const isValidPosition = (row) => { return row >= 0 && row < computedRows.value; }; const getFocusableElement = (pos) => { const elements = findFocusableElements(); let currentRow = 0; for (const element of elements) { if (currentRow === pos) { return element; } currentRow++; } return null; }; const focusElement = (element) => { if (isProcessing || !element) return; isProcessing = true; try { if (lastFocusedElement.value) { lastFocusedElement.value.classList.remove(focusClass); lastFocusedElement.value.blur(); } element.classList.add(focusClass); if (autofocus) { element.focus(); } lastFocusedElement.value = element; } catch (error) { console.error("Focus error:", error); } finally { isProcessing = false; } }; const handleKeyDown = (event) => { if (isDisabled.value || isProcessing) return; let newPosition = currentPosition.value; switch (event.key) { case KeyboardEnum.Up: newPosition--; if (newPosition < 0) { if (cyclic) { newPosition = computedRows.value - 1; } else { onRowStart === null || onRowStart === void 0 ? void 0 : onRowStart(); return; } } break; case KeyboardEnum.Down: newPosition++; if (newPosition >= computedRows.value) { if (cyclic) { newPosition = 0; } else { onRowEnd === null || onRowEnd === void 0 ? void 0 : onRowEnd(); return; } } break; case KeyboardEnum.Left: onLeft === null || onLeft === void 0 ? void 0 : onLeft(); break; case KeyboardEnum.Right: onRight === null || onRight === void 0 ? void 0 : onRight(); break; case KeyboardEnum.Enter: onEnter === null || onEnter === void 0 ? void 0 : onEnter(currentPosition.value); break; case KeyboardEnum.Back: onReturn === null || onReturn === void 0 ? void 0 : onReturn(currentPosition.value); break; default: console.warn("Unhandled Key:", event.code); return; } if (isValidPosition(newPosition)) { currentPosition.value = newPosition; } else { console.error("Position Not Valid:", newPosition); } }; const currentPositionWatcher = watch(currentPosition, (newPosition) => { if (!isDisabled.value && !isProcessing) { const element = getFocusableElement(newPosition); focusElement(element); } }); // Lifecycle hooks onMounted(() => { if (isDisabled.value) { window.removeEventListener("keydown", handleKeyDown); } else { window.addEventListener("keydown", handleKeyDown); } if (autofocus && !isDisabled.value) { const element = getFocusableElement(currentPosition.value); focusElement(element); } }); onUnmounted(() => { window.removeEventListener("keydown", handleKeyDown); if (lastFocusedElement.value) { lastFocusedElement.value.classList.remove(focusClass); } isDisabledWatcher(); currentPositionWatcher(); }); return { position: currentPosition, currentElement: lastFocusedElement.value, setPosition, toggleDisabled, isValidPosition, focusElement, getCurrentFocusedElement: () => lastFocusedElement.value, }; } function useNavigationReturn({ onReturn, disabled, }) { // Disabling the hook const isDisabled = ref(unref(disabled)); const toggleDisabled = (value) => { if (value !== undefined) { isDisabled.value = value; } else { isDisabled.value = !isDisabled.value; } }; const handleKeyDown = (event) => { if (event.key === KeyboardEnum.Back) { onReturn(); } }; // Check for disabled value, remove listeners if is disabled watch(isDisabled, (newValue) => { if (newValue) { window.removeEventListener("keydown", handleKeyDown); } else { window.addEventListener("keydown", handleKeyDown); } }); onMounted(() => { window.addEventListener("keydown", handleKeyDown); }); onUnmounted(() => { window.removeEventListener("keydown", handleKeyDown); }); return { toggleDisabled }; } function useScrollIntoFocus({ position, selectedElement, behavior = "smooth", delay = 1000, parentSelector = "[data-parent]", buffer = 180, bufferX, bufferY, suppressLogs = true, scrollType = "throttle", }) { let timeout = null; let lastCall = 0; const currElement = ref(null); const parentContainer = ref(null); const setParentContainer = () => { parentContainer.value = document.querySelector(parentSelector); if (!parentContainer.value) { console.warn("Parent container not found!"); } }; const isElementFullyVisible = (element, parent) => { const elementRect = element.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect(); return { isFullyVisible: elementRect.left >= parentRect.left && elementRect.right <= parentRect.right && elementRect.top >= parentRect.top && elementRect.bottom <= parentRect.bottom, horizontalOverflow: elementRect.right - parentRect.right, verticalOverflow: elementRect.bottom - parentRect.bottom, topOverflow: parentRect.top - elementRect.top, leftOverflow: parentRect.left - elementRect.left, }; }; const scrollWindow = () => { const element = unref(currElement); const parent = unref(parentContainer); if (element && parent) { const { isFullyVisible, horizontalOverflow, verticalOverflow, topOverflow, leftOverflow, } = isElementFullyVisible(element, parent); if (!isFullyVisible) { // Scroll Down if (verticalOverflow > 0) { parent.scrollTo({ top: parent.scrollTop + verticalOverflow + (bufferY !== null && bufferY !== void 0 ? bufferY : buffer), behavior, }); } // Scroll Up (Element is above the viewport) if (topOverflow > 0) { parent.scrollTo({ top: parent.scrollTop - topOverflow - (bufferY !== null && bufferY !== void 0 ? bufferY : buffer), behavior, }); } // Scroll Right if (horizontalOverflow > 0) { parent.scrollTo({ left: parent.scrollLeft + horizontalOverflow + (bufferX !== null && bufferX !== void 0 ? bufferX : buffer), behavior, }); } // Scroll Left (Element is off-screen to the left) if (leftOverflow > 0) { parent.scrollTo({ left: parent.scrollLeft - leftOverflow - (bufferX !== null && bufferX !== void 0 ? bufferX : buffer), behavior, }); } } } }; nextTick(setParentContainer); watch(selectedElement, (newVal) => { if (newVal) { nextTick(() => { currElement.value = newVal; }); } }); watch(position, async () => { if (!parentContainer.value) { await nextTick(setParentContainer); } await nextTick(); currElement.value = unref(selectedElement); if (scrollType === "throttle") { const now = new Date().getTime(); if (now - lastCall >= delay) { lastCall = now; scrollWindow(); } } else if (scrollType === "debounce") { if (timeout) { clearTimeout(timeout); } timeout = setTimeout(scrollWindow, delay); } }); } export { useNavigation, useNavigationReturn, useNavigationX, useNavigationY, useScrollIntoFocus };