UNPKG

react-aria

Version:
405 lines (398 loc) • 19.2 kB
import {getEventTarget as $d8ac7ed472840322$export$e58f029f0fbfdb29, nodeContains as $d8ac7ed472840322$export$4282f70798064fe0} from "../utils/shadowdom/DOMFunctions.js"; import {useLayoutEffect as $53fed047b798be36$export$e5c5a5f917a5871c} from "../utils/useLayoutEffect.js"; import {useState as $cSnI5$useState, useCallback as $cSnI5$useCallback, useEffect as $cSnI5$useEffect} from "react"; import {useSyncExternalStore as $cSnI5$useSyncExternalStore} from "use-sync-external-store/shim/index.js"; /* * Copyright 2022 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ // Increment this version number whenever the // LandmarkManagerApi or Landmark interfaces change. const $6ea972a5373aaa15$var$LANDMARK_API_VERSION = 1; // Symbol under which the singleton landmark manager instance is attached to the document. const $6ea972a5373aaa15$var$landmarkSymbol = Symbol.for('react-aria-landmark-manager'); function $6ea972a5373aaa15$var$subscribe(fn) { document.addEventListener('react-aria-landmark-manager-change', fn); return ()=>document.removeEventListener('react-aria-landmark-manager-change', fn); } function $6ea972a5373aaa15$var$getLandmarkManager() { if (typeof document === 'undefined') return null; // Reuse an existing instance if it has the same or greater version. let instance = document[$6ea972a5373aaa15$var$landmarkSymbol]; if (instance && instance.version >= $6ea972a5373aaa15$var$LANDMARK_API_VERSION) return instance; // Otherwise, create a new instance and dispatch an event so anything using the existing // instance updates and re-registers their landmarks with the new one. document[$6ea972a5373aaa15$var$landmarkSymbol] = new $6ea972a5373aaa15$var$LandmarkManager(); document.dispatchEvent(new CustomEvent('react-aria-landmark-manager-change')); return document[$6ea972a5373aaa15$var$landmarkSymbol]; } // Subscribes a React component to the current landmark manager instance. function $6ea972a5373aaa15$var$useLandmarkManager() { return (0, $cSnI5$useSyncExternalStore)($6ea972a5373aaa15$var$subscribe, $6ea972a5373aaa15$var$getLandmarkManager, $6ea972a5373aaa15$var$getLandmarkManager); } class $6ea972a5373aaa15$var$LandmarkManager { setupIfNeeded() { if (this.isListening) return; document.addEventListener('keydown', this.f6Handler, { capture: true }); document.addEventListener('focusin', this.focusinHandler, { capture: true }); document.addEventListener('focusout', this.focusoutHandler, { capture: true }); this.isListening = true; } teardownIfNeeded() { if (!this.isListening || this.landmarks.length > 0 || this.refCount > 0) return; document.removeEventListener('keydown', this.f6Handler, { capture: true }); document.removeEventListener('focusin', this.focusinHandler, { capture: true }); document.removeEventListener('focusout', this.focusoutHandler, { capture: true }); this.isListening = false; } focusLandmark(landmark, direction) { var _this_landmarks_find_focus, _this_landmarks_find; (_this_landmarks_find = this.landmarks.find((l)=>l.ref.current === landmark)) === null || _this_landmarks_find === void 0 ? void 0 : (_this_landmarks_find_focus = _this_landmarks_find.focus) === null || _this_landmarks_find_focus === void 0 ? void 0 : _this_landmarks_find_focus.call(_this_landmarks_find, direction); } /** * Return set of landmarks with a specific role. */ getLandmarksByRole(role) { return new Set(this.landmarks.filter((l)=>l.role === role)); } /** * Return first landmark with a specific role. */ getLandmarkByRole(role) { return this.landmarks.find((l)=>l.role === role); } addLandmark(newLandmark) { this.setupIfNeeded(); if (this.landmarks.find((landmark)=>landmark.ref === newLandmark.ref) || !newLandmark.ref.current) return; if (this.landmarks.filter((landmark)=>landmark.role === 'main').length > 1 && process.env.NODE_ENV !== 'production') console.error('Page can contain no more than one landmark with the role "main".'); if (this.landmarks.length === 0) { this.landmarks = [ newLandmark ]; this.checkLabels(newLandmark.role); return; } // Binary search to insert new landmark based on position in document relative to existing landmarks. // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition let start = 0; let end = this.landmarks.length - 1; while(start <= end){ let mid = Math.floor((start + end) / 2); let comparedPosition = newLandmark.ref.current.compareDocumentPosition(this.landmarks[mid].ref.current); let isNewAfterExisting = Boolean(comparedPosition & Node.DOCUMENT_POSITION_PRECEDING || comparedPosition & Node.DOCUMENT_POSITION_CONTAINS); if (isNewAfterExisting) start = mid + 1; else end = mid - 1; } this.landmarks.splice(start, 0, newLandmark); this.checkLabels(newLandmark.role); } updateLandmark(landmark) { let index = this.landmarks.findIndex((l)=>l.ref === landmark.ref); if (index >= 0) { this.landmarks[index] = { ...this.landmarks[index], ...landmark }; this.checkLabels(this.landmarks[index].role); } } removeLandmark(ref) { this.landmarks = this.landmarks.filter((landmark)=>landmark.ref !== ref); this.teardownIfNeeded(); } /** * Warn if there are 2+ landmarks with the same role but no label. * Labels for landmarks with the same role must also be unique. * * See https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/. */ checkLabels(role) { let landmarksWithRole = this.getLandmarksByRole(role); if (landmarksWithRole.size > 1) { let duplicatesWithoutLabel = [ ...landmarksWithRole ].filter((landmark)=>!landmark.label); if (duplicatesWithoutLabel.length > 0 && process.env.NODE_ENV !== 'production') console.warn(`Page contains more than one landmark with the '${role}' role. If two or more landmarks on a page share the same role, all must be labeled with an aria-label or aria-labelledby attribute: `, duplicatesWithoutLabel.map((landmark)=>landmark.ref.current)); else if (process.env.NODE_ENV !== 'production') { let labels = [ ...landmarksWithRole ].map((landmark)=>landmark.label); let duplicateLabels = labels.filter((item, index)=>labels.indexOf(item) !== index); duplicateLabels.forEach((label)=>{ console.warn(`Page contains more than one landmark with the '${role}' role and '${label}' label. If two or more landmarks on a page share the same role, they must have unique labels: `, [ ...landmarksWithRole ].filter((landmark)=>landmark.label === label).map((landmark)=>landmark.ref.current)); }); } } } /** * Get the landmark that is the closest parent in the DOM. * Returns undefined if no parent is a landmark. */ closestLandmark(element) { let landmarkMap = new Map(this.landmarks.map((l)=>[ l.ref.current, l ])); let currentElement = element; while(currentElement && !landmarkMap.has(currentElement) && currentElement !== document.body && currentElement.parentElement)currentElement = currentElement.parentElement; return landmarkMap.get(currentElement); } /** * Gets the next landmark, in DOM focus order, or previous if backwards is specified. * If last landmark, next should be the first landmark. * If not inside a landmark, will return first landmark. * Returns undefined if there are no landmarks. */ getNextLandmark(element, { backward: backward }) { var _this_landmarks_nextLandmarkIndex_ref_current; let currentLandmark = this.closestLandmark(element); let nextLandmarkIndex = backward ? this.landmarks.length - 1 : 0; if (currentLandmark) nextLandmarkIndex = this.landmarks.indexOf(currentLandmark) + (backward ? -1 : 1); let wrapIfNeeded = ()=>{ // When we reach the end of the landmark sequence, fire a custom event that can be listened for by applications. // If this event is canceled, we return immediately. This can be used to implement landmark navigation across iframes. if (nextLandmarkIndex < 0) { if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', { detail: { direction: 'backward' }, bubbles: true, cancelable: true }))) return true; nextLandmarkIndex = this.landmarks.length - 1; } else if (nextLandmarkIndex >= this.landmarks.length) { if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', { detail: { direction: 'forward' }, bubbles: true, cancelable: true }))) return true; nextLandmarkIndex = 0; } if (nextLandmarkIndex < 0 || nextLandmarkIndex >= this.landmarks.length) return true; return false; }; if (wrapIfNeeded()) return undefined; // Skip over hidden landmarks. let i = nextLandmarkIndex; while((_this_landmarks_nextLandmarkIndex_ref_current = this.landmarks[nextLandmarkIndex].ref.current) === null || _this_landmarks_nextLandmarkIndex_ref_current === void 0 ? void 0 : _this_landmarks_nextLandmarkIndex_ref_current.closest('[aria-hidden=true]')){ nextLandmarkIndex += backward ? -1 : 1; if (wrapIfNeeded()) return undefined; if (nextLandmarkIndex === i) break; } return this.landmarks[nextLandmarkIndex]; } /** * Look at next landmark. If an element was previously focused inside, restore focus there. * If not, focus the landmark itself. * If no landmarks at all, or none with focusable elements, don't move focus. */ f6Handler(e) { if (e.key === 'F6') { // If alt key pressed, focus main landmark, otherwise navigate forward or backward based on shift key. let handled = e.altKey ? this.focusMain() : this.navigate((0, $d8ac7ed472840322$export$e58f029f0fbfdb29)(e), e.shiftKey); if (handled) { e.preventDefault(); e.stopPropagation(); } } } focusMain() { let main = this.getLandmarkByRole('main'); if (main && main.ref.current && main.ref.current.isConnected) { this.focusLandmark(main.ref.current, 'forward'); return true; } return false; } navigate(from, backward) { let nextLandmark = this.getNextLandmark(from, { backward: backward }); if (!nextLandmark) return false; // If something was previously focused in the next landmark, then return focus to it if (nextLandmark.lastFocused) { let lastFocused = nextLandmark.lastFocused; if ((0, $d8ac7ed472840322$export$4282f70798064fe0)(document.body, lastFocused)) { lastFocused.focus(); return true; } } // Otherwise, focus the landmark itself if (nextLandmark.ref.current && nextLandmark.ref.current.isConnected) { this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); return true; } return false; } /** * Sets lastFocused for a landmark, if focus is moved within that landmark. * Lets the last focused landmark know it was blurred if something else is focused. */ focusinHandler(e) { let currentLandmark = this.closestLandmark((0, $d8ac7ed472840322$export$e58f029f0fbfdb29)(e)); if (currentLandmark && currentLandmark.ref.current !== (0, $d8ac7ed472840322$export$e58f029f0fbfdb29)(e)) this.updateLandmark({ ref: currentLandmark.ref, lastFocused: (0, $d8ac7ed472840322$export$e58f029f0fbfdb29)(e) }); let previousFocusedElement = e.relatedTarget; if (previousFocusedElement) { let closestPreviousLandmark = this.closestLandmark(previousFocusedElement); if (closestPreviousLandmark && closestPreviousLandmark.ref.current === previousFocusedElement) closestPreviousLandmark.blur(); } } /** * Track if the focus is lost to the body. If it is, do cleanup on the landmark that last had * focus. */ focusoutHandler(e) { let previousFocusedElement = (0, $d8ac7ed472840322$export$e58f029f0fbfdb29)(e); let nextFocusedElement = e.relatedTarget; // the === document seems to be a jest thing for focus to go there on generic blur event such as landmark.blur(); // browsers appear to send focus instead to document.body and the relatedTarget is null when that happens if (!nextFocusedElement || nextFocusedElement === document) { let closestPreviousLandmark = this.closestLandmark(previousFocusedElement); if (closestPreviousLandmark && closestPreviousLandmark.ref.current === previousFocusedElement) closestPreviousLandmark.blur(); } } createLandmarkController() { let instance = this; instance.refCount++; instance.setupIfNeeded(); return { navigate (direction, opts) { let element = (opts === null || opts === void 0 ? void 0 : opts.from) || document.activeElement; return instance.navigate(element, direction === 'backward'); }, focusNext (opts) { let element = (opts === null || opts === void 0 ? void 0 : opts.from) || document.activeElement; return instance.navigate(element, false); }, focusPrevious (opts) { let element = (opts === null || opts === void 0 ? void 0 : opts.from) || document.activeElement; return instance.navigate(element, true); }, focusMain () { return instance.focusMain(); }, dispose () { if (instance) { instance.refCount--; instance.teardownIfNeeded(); instance = null; } } }; } registerLandmark(landmark) { if (this.landmarks.find((l)=>l.ref === landmark.ref)) this.updateLandmark(landmark); else this.addLandmark(landmark); return ()=>this.removeLandmark(landmark.ref); } constructor(){ this.landmarks = []; this.isListening = false; this.refCount = 0; this.version = $6ea972a5373aaa15$var$LANDMARK_API_VERSION; this.f6Handler = this.f6Handler.bind(this); this.focusinHandler = this.focusinHandler.bind(this); this.focusoutHandler = this.focusoutHandler.bind(this); } } function $6ea972a5373aaa15$export$a8e2debc6521490c() { // Get the current landmark manager and create a controller using it. let instance = $6ea972a5373aaa15$var$getLandmarkManager(); let controller = instance === null || instance === void 0 ? void 0 : instance.createLandmarkController(); let unsubscribe = $6ea972a5373aaa15$var$subscribe(()=>{ // If the landmark manager changes, dispose the old // controller and create a new one. controller === null || controller === void 0 ? void 0 : controller.dispose(); instance = $6ea972a5373aaa15$var$getLandmarkManager(); controller = instance === null || instance === void 0 ? void 0 : instance.createLandmarkController(); }); // Return a wrapper that proxies requests to the current controller instance. return { navigate (direction, opts) { return controller.navigate(direction, opts); }, focusNext (opts) { return controller.focusNext(opts); }, focusPrevious (opts) { return controller.focusPrevious(opts); }, focusMain () { return controller.focusMain(); }, dispose () { controller === null || controller === void 0 ? void 0 : controller.dispose(); unsubscribe(); controller = undefined; instance = null; } }; } function $6ea972a5373aaa15$export$4cc632584fd87fae(props, ref) { const { role: role, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, focus: focus } = props; let manager = $6ea972a5373aaa15$var$useLandmarkManager(); let label = ariaLabel || ariaLabelledby; let [isLandmarkFocused, setIsLandmarkFocused] = (0, $cSnI5$useState)(false); let defaultFocus = (0, $cSnI5$useCallback)(()=>{ setIsLandmarkFocused(true); }, [ setIsLandmarkFocused ]); let blur = (0, $cSnI5$useCallback)(()=>{ setIsLandmarkFocused(false); }, [ setIsLandmarkFocused ]); (0, $53fed047b798be36$export$e5c5a5f917a5871c)(()=>{ if (manager) return manager.registerLandmark({ ref: ref, label: label, role: role, focus: focus || defaultFocus, blur: blur }); }, [ manager, label, ref, role, focus, defaultFocus, blur ]); (0, $cSnI5$useEffect)(()=>{ var _ref_current; if (isLandmarkFocused) (_ref_current = ref.current) === null || _ref_current === void 0 ? void 0 : _ref_current.focus(); }, [ isLandmarkFocused, ref ]); return { landmarkProps: { role: role, tabIndex: isLandmarkFocused ? -1 : undefined, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby } }; } export {$6ea972a5373aaa15$export$a8e2debc6521490c as UNSTABLE_createLandmarkController, $6ea972a5373aaa15$export$4cc632584fd87fae as useLandmark}; //# sourceMappingURL=useLandmark.js.map