Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ac8b368
Toast
devongovett Dec 2, 2022
335d3ff
Only render one toast provider
devongovett Dec 3, 2022
6b8411e
Make it work with dialogs
devongovett Dec 3, 2022
6a84bfd
todos
devongovett Dec 3, 2022
3dff319
Improve focus management
devongovett Dec 12, 2022
9f59895
Support programmatically closing toasts
devongovett Dec 13, 2022
ea014f0
Fix focus ring
devongovett Dec 13, 2022
a38ddb9
Hide toasts that are animating out so VoiceOver doesn't announce them.
devongovett Dec 13, 2022
2156b26
Fix
devongovett Dec 14, 2022
044495c
Fix 16
devongovett Dec 14, 2022
fd9d4ff
Merge branch 'main' of github.com:adobe/react-spectrum into toast
devongovett Dec 16, 2022
a90dea1
Refactor to use global store outside React tree
devongovett Dec 20, 2022
8848f4e
Docs
devongovett Dec 20, 2022
155076c
Split into ToastContainer and ToastQueue
devongovett Dec 20, 2022
1d7c00d
Cleanup
devongovett Dec 20, 2022
a105fdf
Add aria docs
devongovett Dec 21, 2022
6f4de0b
Stately docs
devongovett Dec 21, 2022
1378c2a
Fix ariaHideOutside when added node includes a top layer element
devongovett Jan 6, 2023
aa07403
Skip hidden landmarks and fire a custom event when wrapping
devongovett Jan 6, 2023
e7f13b1
Add example of landmark navigation with iframe
devongovett Jan 6, 2023
41d49a6
Add iframe example with toasts
devongovett Jan 6, 2023
8607ba1
Fix iframe URL in CI
devongovett Jan 6, 2023
ffbd657
Address review comments
devongovett Jan 10, 2023
92a5e97
Remove toast position from storybook
devongovett Jan 11, 2023
84ac898
Use screen width to determine toast position rather than scale
devongovett Jan 11, 2023
b8372de
Fix centering of x in button
devongovett Jan 11, 2023
6ce0e63
Update packages/@react-aria/landmark/test/useLandmark.test.tsx
devongovett Jan 12, 2023
7c4d830
Apply suggestions from code review
devongovett Jan 12, 2023
2536854
Merge branch 'main' into toast
LFDanLu Jan 12, 2023
7b783d2
Merge branch 'toast' of github.com:adobe/react-spectrum into landmark…
devongovett Jan 12, 2023
7e0b922
Merge branch 'main' of github.com:adobe/react-spectrum into landmark-…
devongovett Jan 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .storybook/custom-addons/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
70 changes: 51 additions & 19 deletions packages/@react-aria/landmark/src/useLandmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,7 +30,7 @@ type Landmark = {
role: AriaLandmarkRole,
label?: string,
lastFocused?: FocusableElement,
focus: () => void,
focus: (direction: 'forward' | 'backward') => void,
blur: () => void
};

Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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];
Expand All @@ -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});

Expand All @@ -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;
}
Expand All @@ -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');
}
}
}
Expand Down Expand Up @@ -292,13 +323,14 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
const {
role,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby
'aria-labelledby': ariaLabelledby,
focus
} = props;
let manager = LandmarkManager.getInstance();
let label = ariaLabel || ariaLabelledby;
let [isLandmarkFocused, setIsLandmarkFocused] = useState(false);

let focus = useCallback(() => {
let defaultFocus = useCallback(() => {
setIsLandmarkFocused(true);
}, [setIsLandmarkFocused]);

Expand All @@ -316,7 +348,7 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
}, []);

useLayoutEffect(() => {
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]);

Expand Down
104 changes: 102 additions & 2 deletions packages/@react-aria/landmark/stories/Landmark.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,7 +69,7 @@ function Search(props) {
let {styleProps} = useStyleProps(props);
let {landmarkProps} = useLandmark({...props, role: 'search'}, ref);
return (
<form aria-label="Magic seeing eye" ref={ref} {...props} {...landmarkProps} {...styleProps}>
<form aria-label="Magic seeing eye" ref={ref} {...props} {...landmarkProps} {...styleProps} className={classNames(styles, 'landmark')}>
<SearchField label="Search" />
</form>
);
Expand Down Expand Up @@ -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',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The story we load in already has a main, I think there can only be one main per application. Right now Option+CMD+F6 for go to main is a little strange because it'll move through 3 different elements.

It's not pressing, just if we want to make the example more like how we'd expect people to implement it. Not that we can really do anything about enforcing it with iframes. Same thing with id's inside the iframe.

Maybe nothing to do for code, just need to make sure we state these things in the documentation.

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 (
<div className={classNames(styles, 'application')}>
<Region UNSAFE_className={classNames(styles, 'globalnav')}>
<Flex justifyContent="space-between">
<Link><a href="//react-spectrum.com">React Spectrum</a></Link>
<Search />
</Flex>
</Region>
<Navigation UNSAFE_className={classNames(styles, 'navigation')} aria-label="Site Nav">
<ActionGroup orientation="vertical">
<Item>One</Item>
<Item>Two</Item>
<Item>Three</Item>
</ActionGroup>
</Navigation>
<iframe
ref={ref}
{...landmarkProps}
title="iframe"
style={{width: '100%', height: '100%'}}
src="iframe.html?providerSwitcher-express=false&providerSwitcher-toastPosition=bottom&providerSwitcher-locale=&providerSwitcher-theme=&providerSwitcher-scale=&args=&id=landmark--application-with-landmarks&viewMode=story"
onLoad={onLoad}
tabIndex={-1} />
</div>
);
}

export const FlatLandmarks = Template().bind({});
FlatLandmarks.args = {};

Expand All @@ -339,3 +437,5 @@ OneWithNoFocusableChildren.args = {};

export const AllWithNoFocusableChildren = AllWithNoFocusableChildrenExampleTemplate().bind({});
AllWithNoFocusableChildren.args = {};

export {IframeExample};
18 changes: 18 additions & 0 deletions packages/@react-aria/landmark/stories/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,40 @@
"globalnav globalnav globalnav"
"navigation content navcontent";
}

.landmark {
outline: none;
position: relative;
&:focus:after {
content: '';
position: absolute;
inset: 1px;
border: 2px solid black;
z-index: 1000;
box-shadow: 0 0 0 1px white, inset 0 0 0 1px white;
}
}

.globalnav {
composes: landmark;
grid-area: globalnav;
padding: 10px;
background: var(--spectrum-alias-categorical-color-6);
}
.navigation {
composes: landmark;
grid-area: navigation;
padding: 10px;
background: var(--spectrum-alias-categorical-color-2);
}
.main {
composes: landmark;
grid-area: content;
padding: 10px;
background: var(--spectrum-alias-categorical-color-3);
}
.navigation-content {
composes: landmark;
grid-area: navcontent;
padding: 10px;
background: var(--spectrum-alias-categorical-color-4);
Expand Down
Loading