UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

490 lines 21.2 kB
import { defineService } from '@furystack/inject'; import { ObservableValue } from '@furystack/utils'; const DEFAULT_FOCUSABLE_SELECTOR = [ '[tabindex]:not([tabindex="-1"])', '[data-spatial-nav-target]', 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', ].join(', '); const INPUT_PASSTHROUGH_TAGS = new Set(['TEXTAREA', 'SELECT']); /** * Input types treated as "text-like" for Enter suppression and Escape blur. * `number` is included here even though it also appears in * INPUT_VERTICAL_ONLY_PASSTHROUGH_TYPES — the overlap is intentional: * isTextInput gates Enter suppression and Escape-blur behavior, while * the vertical-only set gates which arrow keys pass through. */ const INPUT_PASSTHROUGH_TYPES = new Set([ 'text', 'password', 'email', 'number', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'time', 'week', ]); /** * Input types that always pass through all arrow keys because they use * arrows for built-in value manipulation (radio group cycling). */ const INPUT_FULL_ARROW_PASSTHROUGH_TYPES = new Set(['radio']); /** * Input types where only Up/Down arrows should pass through when * selectionStart is unavailable (e.g. number inputs use Up/Down * for increment/decrement but Left/Right have no useful behavior). */ const INPUT_VERTICAL_ONLY_PASSTHROUGH_TYPES = new Set(['number']); /** * Input types where only Left/Right arrows should pass through * (e.g. horizontal range sliders use Left/Right to adjust value * but Up/Down can be used for spatial navigation). */ const INPUT_HORIZONTAL_ONLY_PASSTHROUGH_TYPES = new Set(['range']); const getElementCenter = (rect) => ({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }); const PERPENDICULAR_WEIGHT = 3; /** * Weighted distance that penalizes perpendicular displacement. * For horizontal navigation (left/right), vertical offset is weighted 3x. * For vertical navigation (up/down), horizontal offset is weighted 3x. * This ensures elements aligned on the movement axis are strongly preferred. */ const spatialDistance = (a, b, direction) => { const dx = Math.abs(a.x - b.x); const dy = Math.abs(a.y - b.y); const isHorizontal = direction === 'left' || direction === 'right'; const primary = isHorizontal ? dx : dy; const perpendicular = isHorizontal ? dy : dx; return Math.sqrt(primary ** 2 + (perpendicular * PERPENDICULAR_WEIGHT) ** 2); }; const isInDirection = (current, candidate, direction) => { const currentCenter = getElementCenter(current); const candidateCenter = getElementCenter(candidate); switch (direction) { case 'right': return candidateCenter.x > currentCenter.x; case 'left': return candidateCenter.x < currentCenter.x; case 'down': return candidateCenter.y > currentCenter.y; case 'up': return candidateCenter.y < currentCenter.y; default: return false; } }; /** * Walks up the DOM checking the `contentEditable` property rather than * using `.closest('[contenteditable="true"]')` — the attribute selector * is unreliable in jsdom when contentEditable is set via the IDL property. */ const isInsideContentEditable = (element) => { let current = element; while (current) { if (current.contentEditable === 'true') return true; current = current.parentElement; } return false; }; const isTextInput = (element) => { if (INPUT_PASSTHROUGH_TAGS.has(element.tagName)) { return true; } if (element.tagName === 'INPUT') { const type = element.type?.toLowerCase() || 'text'; return INPUT_PASSTHROUGH_TYPES.has(type); } if (isInsideContentEditable(element)) { return true; } return false; }; const isInsidePassthrough = (element) => { return !!element.closest('[data-spatial-nav-passthrough]'); }; const escapeCssString = (value) => typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/[^\w-]/g, (ch) => `\\${ch}`); const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']); /** * Returns true when the focused element is an input-like control that * passes through arrow keys internally, meaning the user cannot escape * it via arrow keys alone (e.g. range, radio, date, time, textarea). * In these cases Escape should blur the element to resume spatial navigation. */ const shouldEscapeBlurElement = (element) => { if (isTextInput(element)) return true; if (element.tagName === 'INPUT') { const type = element.type?.toLowerCase() || 'text'; if (INPUT_FULL_ARROW_PASSTHROUGH_TYPES.has(type)) return true; if (INPUT_HORIZONTAL_ONLY_PASSTHROUGH_TYPES.has(type)) return true; } return false; }; /** * Determines whether an arrow key should be passed through to an input element. * Returns false (allowing spatial nav to take over) when the key press would * have no useful effect within the field: * - For text-like inputs: at cursor boundaries (start/end of text) * - For radio inputs: always pass through (built-in arrow key behavior) * - For range inputs: pass through Left/Right (slider adjustment), intercept Up/Down * - For number inputs: pass through Up/Down (increment/decrement), intercept Left/Right * - For date/time inputs: always pass through (internal segment navigation) */ const shouldPassthroughArrowKeys = (element, key) => { if (!ARROW_KEYS.has(key)) return false; if (element.tagName === 'INPUT') { const type = element.type?.toLowerCase() || 'text'; if (INPUT_FULL_ARROW_PASSTHROUGH_TYPES.has(type)) return true; if (INPUT_HORIZONTAL_ONLY_PASSTHROUGH_TYPES.has(type)) { return key === 'ArrowLeft' || key === 'ArrowRight'; } } if (!isTextInput(element)) return false; // Textareas are multi-line editing areas; arrow keys always serve // editing purposes. Users exit via Tab or Escape. if (element.tagName === 'TEXTAREA') return true; const el = element; if (typeof el.selectionStart !== 'number' || typeof el.selectionEnd !== 'number') { if (element.tagName === 'INPUT') { const type = element.type?.toLowerCase() || 'text'; if (INPUT_VERTICAL_ONLY_PASSTHROUGH_TYPES.has(type)) { return key === 'ArrowUp' || key === 'ArrowDown'; } } return true; } const hasSelection = el.selectionStart !== el.selectionEnd; if (hasSelection) return true; const cursor = el.selectionStart; const length = el.value?.length ?? 0; if (key === 'ArrowUp' || key === 'ArrowLeft') { return cursor > 0; } if (key === 'ArrowDown' || key === 'ArrowRight') { return cursor < length; } return false; }; /** * Configuration token for {@link SpatialNavigationService}. Override via * {@link configureSpatialNavigation} before the service is first resolved. */ export const SpatialNavigationSettings = defineService({ name: '@furystack/shades/SpatialNavigationSettings', lifetime: 'singleton', factory: () => ({}), }); export const SpatialNavigationService = defineService({ name: '@furystack/shades/SpatialNavigationService', lifetime: 'singleton', factory: ({ inject, onDispose }) => { const options = inject(SpatialNavigationSettings); const enabled = new ObservableValue(options.initiallyEnabled ?? true); const activeSection = new ObservableValue(null); const focusMemory = new Map(); const focusTrapStack = []; const focusableSelector = options.focusableSelector ?? DEFAULT_FOCUSABLE_SELECTOR; const crossSectionNavigation = options.crossSectionNavigation ?? true; const backspaceGoesBack = options.backspaceGoesBack ?? false; const escapeGoesToParentSection = options.escapeGoesToParentSection ?? false; const getActiveTrap = () => focusTrapStack[focusTrapStack.length - 1] ?? null; const pushFocusTrap = (sectionName) => { focusTrapStack.push(sectionName); activeSection.setValue(sectionName); }; const popFocusTrap = (sectionName, previousSection) => { const idx = focusTrapStack.lastIndexOf(sectionName); if (idx !== -1) { focusTrapStack.splice(idx, 1); } const top = focusTrapStack[focusTrapStack.length - 1]; activeSection.setValue(top ?? previousSection ?? null); }; const findContainingSection = (element) => element.closest('[data-nav-section]'); const isVisibleInScrollContainers = (el, rect) => { const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const hasOverflow = (val) => val !== '' && val !== 'visible'; let ancestor = el.parentElement; while (ancestor) { const style = getComputedStyle(ancestor); if (hasOverflow(style.overflow) || hasOverflow(style.overflowX) || hasOverflow(style.overflowY)) { const containerRect = ancestor.getBoundingClientRect(); if (centerX < containerRect.left || centerX > containerRect.right || centerY < containerRect.top || centerY > containerRect.bottom) { return false; } } ancestor = ancestor.parentElement; } return true; }; const getFocusableCandidates = (root, exclude, candidateOptions) => { return Array.from(root.querySelectorAll(focusableSelector)).filter((el) => { if (el === exclude) return false; if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]')) return false; const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return false; if (candidateOptions?.skipScrollVisibility) return true; return isVisibleInScrollContainers(el, rect); }); }; const findNearestInDirection = (currentRect, candidates, direction) => { const currentCenter = getElementCenter(currentRect); let nearest = null; let nearestDistance = Infinity; for (const candidate of candidates) { const candidateRect = candidate.getBoundingClientRect(); if (!isInDirection(currentRect, candidateRect, direction)) continue; const candidateCenter = getElementCenter(candidateRect); const distance = spatialDistance(currentCenter, candidateCenter, direction); if (distance < nearestDistance) { nearestDistance = distance; nearest = candidate; } } return nearest; }; const storeFocusMemory = (sectionName, element) => { if (sectionName) { focusMemory.set(sectionName, new WeakRef(element)); } }; const navigateCrossSection = (activeElement, currentSection, direction) => { const currentSectionName = currentSection.getAttribute('data-nav-section'); const allFocusable = Array.from(document.querySelectorAll(focusableSelector)).filter((el) => { if (el === activeElement) return false; if (currentSection.contains(el)) return false; if (!el.hasAttribute('data-spatial-nav-target') && el.closest('[data-spatial-nav-target]')) return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && isVisibleInScrollContainers(el, rect); }); const currentRect = activeElement.getBoundingClientRect(); const nearest = findNearestInDirection(currentRect, allFocusable, direction); if (nearest) { storeFocusMemory(currentSectionName, activeElement); const targetSection = findContainingSection(nearest); const targetSectionName = targetSection?.getAttribute('data-nav-section') ?? null; const remembered = targetSectionName ? focusMemory.get(targetSectionName)?.deref() : null; if (remembered && remembered !== activeElement && targetSection?.contains(remembered) && remembered.matches(focusableSelector)) { const rememberedRect = remembered.getBoundingClientRect(); if (rememberedRect.width > 0 && rememberedRect.height > 0 && isVisibleInScrollContainers(remembered, rememberedRect)) { ; remembered.focus(); remembered.scrollIntoView({ block: 'nearest', inline: 'nearest' }); activeSection.setValue(targetSectionName); return; } } nearest.focus(); nearest.scrollIntoView({ block: 'nearest', inline: 'nearest' }); activeSection.setValue(targetSectionName); } }; const focusFirstElement = () => { const trap = getActiveTrap(); if (trap) { const trapSection = document.querySelector(`[data-nav-section="${escapeCssString(trap)}"]`); if (trapSection) { const firstFocusable = trapSection.querySelector(focusableSelector); if (firstFocusable) { ; firstFocusable.focus(); activeSection.setValue(trap); return; } } } const sections = document.querySelectorAll('[data-nav-section]'); if (sections.length > 0) { const firstFocusable = sections[0].querySelector(focusableSelector); if (firstFocusable) { ; firstFocusable.focus(); activeSection.setValue(sections[0].getAttribute('data-nav-section')); return; } } const firstFocusable = document.querySelector(focusableSelector); if (firstFocusable) { ; firstFocusable.focus(); } }; const moveFocus = (direction) => { const activeElement = document.activeElement; if (!activeElement || activeElement === document.body) { focusFirstElement(); return; } const currentSection = findContainingSection(activeElement); const currentSectionName = currentSection?.getAttribute('data-nav-section') ?? null; activeSection.setValue(currentSectionName); const searchRoot = currentSection ?? document; const candidates = getFocusableCandidates(searchRoot, activeElement); const currentRect = activeElement.getBoundingClientRect(); let target = findNearestInDirection(currentRect, candidates, direction); if (!target) { const relaxedCandidates = getFocusableCandidates(searchRoot, activeElement, { skipScrollVisibility: true, }); target = findNearestInDirection(currentRect, relaxedCandidates, direction); } if (target) { storeFocusMemory(currentSectionName, activeElement); target.focus(); target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); const targetSection = findContainingSection(target); activeSection.setValue(targetSection?.getAttribute('data-nav-section') ?? null); return; } if (crossSectionNavigation && currentSection && !getActiveTrap()) { navigateCrossSection(activeElement, currentSection, direction); } }; const activateFocused = () => { const { activeElement } = document; if (activeElement && activeElement !== document.body) { ; activeElement.click(); } }; const moveToParentSection = () => { const { activeElement } = document; if (!activeElement || activeElement === document.body) return; const currentSection = findContainingSection(activeElement); if (!currentSection) return; const parentSection = currentSection.parentElement?.closest('[data-nav-section]'); if (!parentSection) return; const firstFocusable = parentSection.querySelector(focusableSelector); if (firstFocusable) { ; firstFocusable.focus(); activeSection.setValue(parentSection.getAttribute('data-nav-section')); } }; const handleKeyDown = (ev) => { if (!enabled.getValue()) return; if (ev.defaultPrevented) return; const { activeElement } = document; if (activeElement && isInsidePassthrough(activeElement)) return; if (activeElement && shouldPassthroughArrowKeys(activeElement, ev.key)) { return; } switch (ev.key) { case 'ArrowUp': ev.preventDefault(); moveFocus('up'); break; case 'ArrowDown': ev.preventDefault(); moveFocus('down'); break; case 'ArrowLeft': ev.preventDefault(); moveFocus('left'); break; case 'ArrowRight': ev.preventDefault(); moveFocus('right'); break; case 'Enter': if (activeElement && isTextInput(activeElement)) break; ev.preventDefault(); activateFocused(); break; case 'Backspace': if (backspaceGoesBack && !(activeElement && isTextInput(activeElement))) { ev.preventDefault(); history.back(); } break; case 'Escape': if (activeElement && activeElement !== document.body && shouldEscapeBlurElement(activeElement)) { ev.preventDefault(); activeElement.blur(); break; } if (escapeGoesToParentSection) { moveToParentSection(); } break; default: break; } }; window.addEventListener('keydown', handleKeyDown); onDispose(() => { window.removeEventListener('keydown', handleKeyDown); // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook. enabled[Symbol.dispose](); // eslint-disable-next-line furystack/prefer-using-wrapper -- Disposal is deferred to the injector's onDispose hook. activeSection[Symbol.dispose](); focusMemory.clear(); focusTrapStack.length = 0; }); return { enabled, activeSection, pushFocusTrap, popFocusTrap, moveFocus, activateFocused, }; }, }); /** * Configures spatial navigation options before the service is first instantiated. * Rebinds {@link SpatialNavigationSettings} and invalidates the cached * {@link SpatialNavigationService}. Must be called **before** the service is * first resolved — calling afterwards drops the cached instance without * disposing it (listeners leak until the injector is disposed). * @param injector The root injector. * @param options Configuration options for spatial navigation. */ export const configureSpatialNavigation = (injector, options) => { injector.bind(SpatialNavigationSettings, () => options); injector.invalidate(SpatialNavigationSettings); injector.invalidate(SpatialNavigationService); }; //# sourceMappingURL=spatial-navigation-service.js.map