diff --git a/.storybook/custom-addons/provider/index.js b/.storybook/custom-addons/provider/index.js index 5c8ea217863..5403b97bbfa 100644 --- a/.storybook/custom-addons/provider/index.js +++ b/.storybook/custom-addons/provider/index.js @@ -18,7 +18,7 @@ function ProviderUpdater(props) { let [themeValue, setTheme] = useState(providerValuesFromUrl.theme || undefined); let [scaleValue, setScale] = useState(providerValuesFromUrl.scale || undefined); let [expressValue, setExpress] = useState(providerValuesFromUrl.express === 'true'); - let [storyReady, setStoryReady] = useState(window.parent === window); // reduce content flash because it takes a moment to get the provider details + let [storyReady, setStoryReady] = useState(window.parent === window || window.parent !== window.top); // reduce content flash because it takes a moment to get the provider details // Typically themes are provided with both light + dark, and both scales. // To build our selector to see all themes, we need to hack it a bit. let theme = (expressValue ? expressThemes : themes)[themeValue || 'light'] || defaultTheme; diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index 4f835a6a1a4..43823aa4140 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -17,7 +17,8 @@ import {useLayoutEffect} from '@react-aria/utils'; export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary'; export interface AriaLandmarkProps extends AriaLabelingProps { - role: AriaLandmarkRole + role: AriaLandmarkRole, + focus?: (direction: 'forward' | 'backward') => void } export interface LandmarkAria { @@ -29,7 +30,7 @@ type Landmark = { role: AriaLandmarkRole, label?: string, lastFocused?: FocusableElement, - focus: () => void, + focus: (direction: 'forward' | 'backward') => void, blur: () => void }; @@ -66,8 +67,8 @@ class LandmarkManager { this.isListening = false; } - private focusLandmark(landmark: Element) { - this.landmarks.find(l => l.ref.current === landmark)?.focus(); + private focusLandmark(landmark: Element, direction: 'forward' | 'backward') { + this.landmarks.find(l => l.ref.current === landmark)?.focus(direction); } /** @@ -190,16 +191,46 @@ class LandmarkManager { } let currentLandmark = this.closestLandmark(element); - let nextLandmarkIndex = backward ? -1 : 0; + let nextLandmarkIndex = backward ? this.landmarks.length - 1 : 0; if (currentLandmark) { - nextLandmarkIndex = this.landmarks.findIndex(landmark => landmark === currentLandmark) + (backward ? -1 : 1); + nextLandmarkIndex = this.landmarks.indexOf(currentLandmark) + (backward ? -1 : 1); } - // Wrap if necessary - if (nextLandmarkIndex < 0) { - nextLandmarkIndex = this.landmarks.length - 1; - } else if (nextLandmarkIndex >= this.landmarks.length) { - nextLandmarkIndex = 0; + 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; + } + + return false; + }; + + if (wrapIfNeeded()) { + return undefined; + } + + // Skip over hidden landmarks. + let i = nextLandmarkIndex; + while (this.landmarks[nextLandmarkIndex].ref.current.closest('[aria-hidden]')) { + nextLandmarkIndex += backward ? -1 : 1; + if (wrapIfNeeded()) { + return undefined; + } + + if (nextLandmarkIndex === i) { + break; + } } return this.landmarks[nextLandmarkIndex]; @@ -212,9 +243,6 @@ class LandmarkManager { */ public f6Handler(e: KeyboardEvent) { if (e.key === 'F6') { - e.preventDefault(); - e.stopPropagation(); - let backward = e.shiftKey; let nextLandmark = this.getNextLandmark(e.target as Element, {backward}); @@ -223,11 +251,14 @@ class LandmarkManager { return; } + e.preventDefault(); + e.stopPropagation(); + // If alt key pressed, focus main landmark if (e.altKey) { let main = this.getLandmarkByRole('main'); if (main && document.contains(main.ref.current)) { - this.focusLandmark(main.ref.current); + this.focusLandmark(main.ref.current, 'forward'); } return; } @@ -243,7 +274,7 @@ class LandmarkManager { // Otherwise, focus the landmark itself if (document.contains(nextLandmark.ref.current)) { - this.focusLandmark(nextLandmark.ref.current); + this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); } } } @@ -292,13 +323,14 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject { + let defaultFocus = useCallback(() => { setIsLandmarkFocused(true); }, [setIsLandmarkFocused]); @@ -316,7 +348,7 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject { - manager.updateLandmark({ref, label, role, focus, blur}); + manager.updateLandmark({ref, label, role, focus: focus || defaultFocus, blur}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [label, ref, role]); diff --git a/packages/@react-aria/landmark/stories/Landmark.stories.tsx b/packages/@react-aria/landmark/stories/Landmark.stories.tsx index 8615322cbfe..7ffc8a49070 100644 --- a/packages/@react-aria/landmark/stories/Landmark.stories.tsx +++ b/packages/@react-aria/landmark/stories/Landmark.stories.tsx @@ -17,7 +17,7 @@ import {classNames, useStyleProps} from '@react-spectrum/utils'; import {Flex} from '@react-spectrum/layout'; import {Link} from '@react-spectrum/link'; import {Meta, Story} from '@storybook/react'; -import React, {useRef} from 'react'; +import React, {SyntheticEvent, useEffect, useRef} from 'react'; import {SearchField} from '@react-spectrum/searchfield'; import styles from './index.css'; import {TextField} from '@react-spectrum/textfield'; @@ -69,7 +69,7 @@ function Search(props) { let {styleProps} = useStyleProps(props); let {landmarkProps} = useLandmark({...props, role: 'search'}, ref); return ( -
+ ); @@ -313,6 +313,104 @@ function ApplicationExample() { ); } +function IframeExample() { + let onLoad = (e: SyntheticEvent) => { + let iframe = e.target as HTMLIFrameElement; + let window = iframe.contentWindow; + let document = window.document; + + let prevFocusedElement = null; + window.addEventListener('react-aria-landmark-navigation', (e: CustomEvent) => { + e.preventDefault(); + let el = document.activeElement; + if (el !== document.body) { + prevFocusedElement = el; + } + + // Prevent focus scope from stealing focus back when we move focus to the iframe. + document.body.setAttribute('data-react-aria-top-layer', 'true'); + + window.parent.postMessage({ + type: 'landmark-navigation', + direction: e.detail.direction + }); + + setTimeout(() => { + document.body.removeAttribute('data-react-aria-top-layer'); + }, 100); + }); + + // When the iframe is re-focused, restore focus back inside where it was before. + window.addEventListener('focus', () => { + if (prevFocusedElement) { + prevFocusedElement.focus(); + prevFocusedElement = null; + } + }); + + // Move focus to first or last landmark when we receive a message from the parent page. + window.addEventListener('message', e => { + if (e.data.type === 'landmark-navigation') { + document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 'F6', shiftKey: e.data.direction === 'backward', bubbles: true})); + } + }); + }; + + useEffect(() => { + let onMessage = (e: MessageEvent) => { + let iframe = ref.current; + if (e.data.type === 'landmark-navigation') { + // Move focus to the iframe so that when focus is restored there, and we can redirect it back inside (below). + iframe.focus(); + + // Now re-dispatch the keyboard event so landmark navigation outside the iframe picks it up. + iframe.dispatchEvent(new KeyboardEvent('keydown', {key: 'F6', shiftKey: e.data.direction === 'backward', bubbles: true})); + } + }; + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, []); + + let ref = useRef(null); + let {landmarkProps} = useLandmark({ + role: 'main', + focus(direction) { + // when iframe landmark receives focus via landmark navigation, go to first/last landmark inside iframe. + ref.current.contentWindow.postMessage({ + type: 'landmark-navigation', + direction + }); + } + }, ref); + + return ( +
+ + + React Spectrum + + + + + + One + Two + Three + + +