|
import type { Directions, Orientation, KeyboardEventType } from './index.types'; |
|
|
|
import { getFocusableElements } from './get-focusable-elements'; |
|
import { ATTRIBUTES, DIRECTIONS, EVENTS, KEYS, ORIENTATION } from './index.constants'; |
|
import { getRelativeFocusableElement } from './get-relative-focusable-element'; |
|
|
|
export type HandleRovingIndexOptions = { |
|
/** @desc Orientation of the roving index */ |
|
orientation?: Orientation; |
|
/** @desc Whether to wrap around when reaching the end of the list */ |
|
wrap?: boolean; |
|
/** @desc Custom selector to use for focusable elements */ |
|
customSelector?: string; |
|
/** @desc Whether to use memory to track the last focused element */ |
|
useMemory?: boolean; |
|
/** @desc Which event to use */ |
|
event?: KeyboardEventType; |
|
/** @desc Whether to skip disabled elements */ |
|
skipDisabled?: boolean; |
|
}; |
|
|
|
export type HandleRovingIndexReturn = () => void; |
|
|
|
export type HandleRovingIndex = ( |
|
parentElement: HTMLElement, |
|
options?: HandleRovingIndexOptions, |
|
) => HandleRovingIndexReturn; |
|
|
|
export const defaultOptions: HandleRovingIndexOptions = { |
|
orientation: ORIENTATION.BOTH, |
|
wrap: true, |
|
useMemory: true, |
|
event: EVENTS.KEYDOWN, |
|
skipDisabled: true, |
|
}; |
|
|
|
/** |
|
* Adds roving tabindex functionality to a list of elements. |
|
* |
|
* @example |
|
* const container = document.getElementById('my-container'); |
|
* handleRovingIndex(container, { orientation: 'horizontal', wrap: true }); |
|
* |
|
* @param {HTMLElement} parentElement - The parent element containing focusable children. |
|
* @param {HandleRovingIndexOptions} options - Configuration options for roving index behavior. |
|
* @return A cleanup function to remove the event listeners when no longer needed. |
|
*/ |
|
export const handleRovingIndex: HandleRovingIndex = (parentElement, options) => { |
|
options = { ...defaultOptions, ...options }; |
|
|
|
const elements = getFocusableElements(parentElement, options?.customSelector); |
|
|
|
const handleKeyDown = (event: KeyboardEvent, index: number) => { |
|
let elementToFocus: HTMLElement | null = null; |
|
let getNextOrPrevious: Directions = undefined; |
|
|
|
switch (options.orientation) { |
|
case ORIENTATION.HORIZONTAL: |
|
if (event.key === KEYS.ARROW_RIGHT) { |
|
event.preventDefault(); |
|
getNextOrPrevious = DIRECTIONS.NEXT; |
|
} else if (event.key === KEYS.ARROW_LEFT) { |
|
event.preventDefault(); |
|
getNextOrPrevious = DIRECTIONS.PREVIOUS; |
|
} |
|
break; |
|
case ORIENTATION.VERTICAL: |
|
if (event.key === KEYS.ARROW_DOWN) { |
|
event.preventDefault(); |
|
getNextOrPrevious = DIRECTIONS.NEXT; |
|
} else if (event.key === KEYS.ARROW_UP) { |
|
event.preventDefault(); |
|
getNextOrPrevious = DIRECTIONS.PREVIOUS; |
|
} |
|
break; |
|
case ORIENTATION.BOTH: |
|
default: |
|
if (event.key === KEYS.ARROW_RIGHT || event.key === KEYS.ARROW_DOWN) { |
|
event.preventDefault(); |
|
getNextOrPrevious = DIRECTIONS.NEXT; |
|
} else if (event.key === KEYS.ARROW_LEFT || event.key === KEYS.ARROW_UP) { |
|
event.preventDefault(); |
|
getNextOrPrevious = DIRECTIONS.PREVIOUS; |
|
} |
|
break; |
|
} |
|
|
|
if (event.key === KEYS.HOME) { |
|
if (options.skipDisabled) { |
|
for (let i = 0; i < elements.length; i++) { |
|
if (!elements[i].hasAttribute(ATTRIBUTES.DISABLED)) { |
|
elementToFocus = elements[i]; |
|
break; |
|
} |
|
} |
|
} else { |
|
elementToFocus = elements[0]; |
|
} |
|
} else if (event.key === KEYS.END) { |
|
if (options.skipDisabled) { |
|
for (let i = elements.length - 1; i >= 0; i--) { |
|
if (!elements[i].hasAttribute(ATTRIBUTES.DISABLED)) { |
|
elementToFocus = elements[i]; |
|
break; |
|
} |
|
} |
|
} else { |
|
elementToFocus = elements[elements.length - 1]; |
|
} |
|
} else if (getNextOrPrevious) { |
|
elementToFocus = getRelativeFocusableElement(elements, index, getNextOrPrevious, { |
|
skipDisabled: options.skipDisabled, |
|
wrap: options.wrap, |
|
}); |
|
} else { |
|
return; |
|
} |
|
|
|
elementToFocus?.focus(); |
|
|
|
if (options.useMemory) { |
|
elements.forEach((el) => el.setAttribute(ATTRIBUTES.TABINDEX, '-1')); |
|
elementToFocus?.setAttribute(ATTRIBUTES.TABINDEX, '0'); |
|
} |
|
}; |
|
|
|
let firstElementSet = false; |
|
|
|
elements.forEach((el, index) => { |
|
const isDisabled = options.skipDisabled && el.hasAttribute(ATTRIBUTES.DISABLED); |
|
if (!firstElementSet && !isDisabled) { |
|
el.setAttribute(ATTRIBUTES.TABINDEX, '0'); |
|
firstElementSet = true; |
|
} else { |
|
el.setAttribute(ATTRIBUTES.TABINDEX, '-1'); |
|
} |
|
|
|
el.addEventListener(options.event || EVENTS.KEYDOWN, (event) => |
|
handleKeyDown(event as KeyboardEvent, index), |
|
); |
|
}); |
|
|
|
return () => { |
|
elements.forEach((el, index) => |
|
el.removeEventListener(options.event || EVENTS.KEYDOWN, (event) => |
|
handleKeyDown(event as KeyboardEvent, index), |
|
), |
|
); |
|
}; |
|
}; |