|
import { getFocusableElements } from './get-focusable-elements'; |
|
import { getNextFocusableElement } from './get-next-focusable-element'; |
|
import { getPreviousFocusableElement } from './get-previous-focusable-element'; |
|
|
|
export const DIRECTIONS = { |
|
NEXT: 'next', |
|
PREVIOUS: 'previous', |
|
} as const; |
|
|
|
export const ORIENTATION = { |
|
HORIZONTAL: 'horizontal', |
|
VERTICAL: 'vertical', |
|
BOTH: 'both', |
|
} as const; |
|
|
|
export const KEYS = { |
|
ARROW_UP: 'ArrowUp', |
|
ARROW_DOWN: 'ArrowDown', |
|
ARROW_LEFT: 'ArrowLeft', |
|
ARROW_RIGHT: 'ArrowRight', |
|
HOME: 'Home', |
|
END: 'End', |
|
} as const; |
|
|
|
const EVENTS = { |
|
KEYDOWN: 'keydown', |
|
KEYUP: 'keyup', |
|
} as const; |
|
|
|
export type Direction = (typeof DIRECTIONS)[keyof typeof DIRECTIONS] | undefined; |
|
export type Orientation = (typeof ORIENTATION)[keyof typeof ORIENTATION]; |
|
export type Keys = (typeof KEYS)[keyof typeof KEYS]; |
|
export type KeyboardEventType = (typeof EVENTS)[keyof typeof EVENTS]; |
|
|
|
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; |
|
|
|
/** |
|
* 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) => { |
|
const wrapOption = options?.wrap ?? true; |
|
const orientationOption = options?.orientation ?? ORIENTATION.BOTH; |
|
const eventOption = options?.event ?? EVENTS.KEYDOWN; |
|
const useMemoryOption = options?.useMemory ?? true; |
|
const skipDisabledOption = options?.skipDisabled ?? true; |
|
const elements = getFocusableElements(parentElement, options?.customSelector); |
|
|
|
const handleKeyDown = (event: KeyboardEvent, index: number) => { |
|
let elementToFocus: HTMLElement | null = null; |
|
let getNextOrPrevious: Direction = undefined; |
|
|
|
switch (orientationOption) { |
|
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 (skipDisabledOption) { |
|
for (let i = 0; i < elements.length; i++) { |
|
if (!elements[i].hasAttribute('disabled')) { |
|
elementToFocus = elements[i]; |
|
break; |
|
} |
|
} |
|
} else { |
|
elementToFocus = elements[0]; |
|
} |
|
} else if (event.key === KEYS.END) { |
|
if (skipDisabledOption) { |
|
for (let i = elements.length - 1; i >= 0; i--) { |
|
if (!elements[i].hasAttribute('disabled')) { |
|
elementToFocus = elements[i]; |
|
break; |
|
} |
|
} |
|
} else { |
|
elementToFocus = elements[elements.length - 1]; |
|
} |
|
} else if (getNextOrPrevious) { |
|
elementToFocus = |
|
getNextOrPrevious === DIRECTIONS.NEXT |
|
? getNextFocusableElement(elements, index, { |
|
wrap: wrapOption, |
|
skipDisabled: skipDisabledOption, |
|
}) |
|
: getPreviousFocusableElement(elements, index, { |
|
wrap: wrapOption, |
|
skipDisabled: skipDisabledOption, |
|
}); |
|
} else { |
|
return; |
|
} |
|
|
|
elementToFocus?.focus(); |
|
|
|
if (useMemoryOption) { |
|
elements.forEach((el) => el.setAttribute('tabindex', '-1')); |
|
elementToFocus?.setAttribute('tabindex', '0'); |
|
} |
|
}; |
|
|
|
let firstElementSet = false; |
|
|
|
elements.forEach((el, index) => { |
|
const isDisabled = skipDisabledOption && el.hasAttribute('disabled'); |
|
if (!firstElementSet && !isDisabled) { |
|
el.setAttribute('tabindex', '0'); |
|
firstElementSet = true; |
|
} else { |
|
el.setAttribute('tabindex', '-1'); |
|
} |
|
|
|
el.addEventListener(eventOption, (event) => handleKeyDown(event as KeyboardEvent, index)); |
|
}); |
|
|
|
return () => { |
|
elements.forEach((el, index) => |
|
el.removeEventListener(eventOption, (event) => handleKeyDown(event as KeyboardEvent, index)), |
|
); |
|
}; |
|
}; |