Skip to content

Commit a006743

Browse files
authored
feat: Support custom dialog layouts in S2 (#7352)
* Support avatars in S2 ActionButton * Support nesting menus inside dialog popovers * Split Dialog into Dialog, StandardDialog, and FullscreenDialog * lint * update chromatic stories (more later) * more chromatic * ts strict * Rename StandardDialog back to Dialog, split out Popover, add CustomDialog * Fix test * Make ContextualHelpTrigger contain focus * Close root menu when clicking on an item outside a MenuTrigger * Reduce props on CloseButton * Add additional dismiss button on mobile * Update padding API
1 parent 22fb5ba commit a006743

32 files changed

+972
-399
lines changed

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[] | null>, restoreFocus?: b
608608
}
609609

610610
let focusedElement = ownerDocument.activeElement as FocusableElement;
611-
if (!isElementInScope(focusedElement, scopeRef.current)) {
611+
if (!isElementInChildScope(focusedElement, scopeRef) || !shouldRestoreFocus(scopeRef)) {
612612
return;
613613
}
614614
let treeNode = focusScopeTree.getTreeNode(scopeRef);
@@ -631,13 +631,13 @@ function useRestoreFocus(scopeRef: RefObject<Element[] | null>, restoreFocus?: b
631631

632632
// If there is no next element, or it is outside the current scope, move focus to the
633633
// next element after the node to restore to instead.
634-
if ((!nextElement || !isElementInScope(nextElement, scopeRef.current)) && nodeToRestore) {
634+
if ((!nextElement || !isElementInChildScope(nextElement, scopeRef)) && nodeToRestore) {
635635
walker.currentNode = nodeToRestore;
636636

637637
// Skip over elements within the scope, in case the scope immediately follows the node to restore.
638638
do {
639639
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
640-
} while (isElementInScope(nextElement, scopeRef.current));
640+
} while (isElementInChildScope(nextElement, scopeRef));
641641

642642
e.preventDefault();
643643
e.stopPropagation();
@@ -693,7 +693,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[] | null>, restoreFocus?: b
693693
&& nodeToRestore
694694
&& (
695695
// eslint-disable-next-line react-hooks/exhaustive-deps
696-
(isElementInScope(ownerDocument.activeElement, scopeRef.current) || (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef)))
696+
((ownerDocument.activeElement && isElementInChildScope(ownerDocument.activeElement, scopeRef)) || (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef)))
697697
)
698698
) {
699699
// freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it

packages/@react-aria/menu/src/useMenuItem.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
125125
} = props;
126126

127127
let isTrigger = !!hasPopup;
128+
let isTriggerExpanded = isTrigger && props['aria-expanded'] === 'true';
128129
let isDisabled = props.isDisabled ?? selectionManager.isDisabled(key);
129130
let isSelected = props.isSelected ?? selectionManager.isSelected(key);
130131
let data = menuData.get(state);
@@ -233,7 +234,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
233234
let {hoverProps} = useHover({
234235
isDisabled,
235236
onHoverStart(e) {
236-
if (!isFocusVisible()) {
237+
// Hovering over an already expanded sub dialog trigger should keep focus in the dialog.
238+
if (!isFocusVisible() && !(isTriggerExpanded && hasPopup === 'dialog')) {
237239
selectionManager.setFocused(true);
238240
selectionManager.setFocusedKey(key);
239241
}
@@ -285,7 +287,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
285287
menuItemProps: {
286288
...ariaProps,
287289
...mergeProps(domProps, linkProps, isTrigger ? {onFocus: itemProps.onFocus, 'data-key': itemProps['data-key']} : itemProps, pressProps, hoverProps, keyboardProps, focusProps),
288-
tabIndex: itemProps.tabIndex != null ? -1 : undefined
290+
// If a submenu is expanded, set the tabIndex to -1 so that shift tabbing goes out of the menu instead of the parent menu item.
291+
tabIndex: itemProps.tabIndex != null && isTriggerExpanded ? -1 : itemProps.tabIndex
289292
},
290293
labelProps: {
291294
id: labelId

packages/@react-aria/overlays/src/usePopover.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export function usePopover(props: AriaPopoverProps, state: OverlayTriggerState):
8282

8383
let {overlayProps, underlayProps} = useOverlay(
8484
{
85-
isOpen: state.isOpen,
85+
// If popover is in the top layer, it should not prevent other popovers from being dismissed.
86+
isOpen: state.isOpen && !otherProps['data-react-aria-top-layer'],
8687
onClose: state.close,
8788
shouldCloseOnBlur: true,
8889
isDismissable: !isNonModal,

packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem
151151
containerPadding={0}
152152
hideArrow
153153
enableBothDismissButtons>
154-
<FocusScope restoreFocus>
154+
<FocusScope restoreFocus contain>
155155
{content}
156156
</FocusScope>
157157
</Popover>

packages/@react-spectrum/menu/test/Menu.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ describe('Menu', function () {
158158
let selectedItem = menuItems[3];
159159
expect(selectedItem).toBe(document.activeElement);
160160
expect(selectedItem).toHaveAttribute('aria-checked', 'true');
161-
expect(selectedItem).toHaveAttribute('tabindex', '-1');
161+
expect(selectedItem).toHaveAttribute('tabindex', '0');
162162
let itemText = within(selectedItem).getByText('Blah');
163163
expect(itemText).toBeTruthy();
164164
let checkmark = within(selectedItem).getByRole('img', {hidden: true});
@@ -193,7 +193,7 @@ describe('Menu', function () {
193193
let selectedItem = menuItems[3];
194194
expect(selectedItem).toBe(document.activeElement);
195195
expect(selectedItem).toHaveAttribute('aria-checked', 'true');
196-
expect(selectedItem).toHaveAttribute('tabindex', '-1');
196+
expect(selectedItem).toHaveAttribute('tabindex', '0');
197197
let itemText = within(selectedItem).getByText('Blah');
198198
expect(itemText).toBeTruthy();
199199
let checkmark = within(selectedItem).getByRole('img', {hidden: true});

packages/@react-spectrum/menu/test/MenuTrigger.test.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -649,13 +649,11 @@ describe('MenuTrigger', function () {
649649

650650
await user.tab();
651651
act(() => {jest.runAllTimers();});
652-
expect(dialog).not.toBeInTheDocument();
653-
expect(menu).not.toBeInTheDocument();
654-
let input = tree.getByTestId('next');
655-
expect(document.activeElement).toBe(input);
652+
expect(dialog).toBeInTheDocument();
653+
expect(document.activeElement).toBe(link);
656654
});
657655

658-
it('will close everything if the user shift tabs out of the subdialog', async function () {
656+
it('will contain focus when shift tabbing in the subdialog', async function () {
659657
renderTree();
660658
let menu = await openMenu();
661659
let menuItems = within(menu).getAllByRole('menuitem');
@@ -673,9 +671,8 @@ describe('MenuTrigger', function () {
673671
await user.tab({shift: true});
674672
act(() => {jest.runAllTimers();});
675673
act(() => {jest.runAllTimers();});
676-
expect(dialog).not.toBeInTheDocument();
677-
let input = tree.getByTestId('previous');
678-
expect(document.activeElement).toBe(input);
674+
expect(dialog).toBeInTheDocument();
675+
expect(document.activeElement).toBe(within(dialog).getByRole('link'));
679676
});
680677

681678
it('will close everything if the user shift tabs out of the subdialog', async function () {

packages/@react-spectrum/s2/chromatic/DialogRTL.stories.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
*/
1212

1313
import {Dialog} from '../src';
14-
1514
import type {Meta} from '@storybook/react';
1615

1716
const meta: Meta<typeof Dialog> = {

packages/@react-spectrum/s2/src/ActionButton.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import {ActionButtonGroupContext} from './ActionButtonGroup';
14+
import {AvatarContext} from './Avatar';
1415
import {baseColor, focusRing, fontRelative, style} from '../style' with { type: 'macro' };
1516
import {ButtonProps, ButtonRenderProps, ContextValue, OverlayTriggerStateContext, Provider, Button as RACButton, useSlottedContext} from 'react-aria-components';
1617
import {centerBaseline} from './CenterBaseline';
@@ -57,7 +58,7 @@ export interface ActionButtonProps extends Omit<ButtonProps, 'className' | 'styl
5758
}
5859

5960
// These styles handle both ActionButton and ToggleButton
60-
const iconOnly = ':has([slot=icon]):not(:has([data-rsp-slot=text]))';
61+
const iconOnly = ':has([slot=icon], [slot=avatar]):not(:has([data-rsp-slot=text]))';
6162
export const btnStyles = style<ButtonRenderProps & ActionButtonStyleProps & ToggleButtonStyleProps & ActionGroupItemStyleProps & {isInGroup: boolean}>({
6263
...focusRing(),
6364
display: 'flex',
@@ -243,6 +244,15 @@ export const btnStyles = style<ButtonRenderProps & ActionButtonStyleProps & Togg
243244
disableTapHighlight: true
244245
}, getAllowedOverrides());
245246

247+
// Matching icon sizes. TBD.
248+
const avatarSize = {
249+
XS: 14,
250+
S: 16,
251+
M: 20,
252+
L: 22,
253+
X: 26
254+
} as const;
255+
246256
export const ActionButtonContext = createContext<ContextValue<ActionButtonProps, FocusableRefValue<HTMLButtonElement>>>(null);
247257

248258
function ActionButton(props: ActionButtonProps, ref: FocusableRef<HTMLButtonElement>) {
@@ -287,6 +297,10 @@ function ActionButton(props: ActionButtonProps, ref: FocusableRef<HTMLButtonElem
287297
[IconContext, {
288298
render: centerBaseline({slot: 'icon', styles: style({order: 0})}),
289299
styles: style({size: fontRelative(20), marginStart: '--iconMargin', flexShrink: 0})
300+
}],
301+
[AvatarContext, {
302+
size: avatarSize[size],
303+
styles: style({marginStart: '--iconMargin', flexShrink: 0, order: 0})
290304
}]
291305
]}>
292306
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}

packages/@react-spectrum/s2/src/Avatar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {ContextValue} from 'react-aria-components';
13+
import {ContextValue, SlotProps} from 'react-aria-components';
1414
import {createContext, forwardRef} from 'react';
1515
import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared';
1616
import {filterDOMProps} from '@react-aria/utils';
@@ -20,7 +20,7 @@ import {style} from '../style' with { type: 'macro' };
2020
import {useDOMRef} from '@react-spectrum/utils';
2121
import {useSpectrumContextProps} from './useSpectrumContextProps';
2222

23-
export interface AvatarProps extends UnsafeStyles, DOMProps {
23+
export interface AvatarProps extends UnsafeStyles, DOMProps, SlotProps {
2424
/** Text description of the avatar. */
2525
alt?: string,
2626
/** The image URL for the avatar. */
@@ -65,6 +65,7 @@ function Avatar(props: AvatarProps, ref: DOMRef<HTMLImageElement>) {
6565
UNSAFE_className = '',
6666
size = 24,
6767
isOverBackground,
68+
slot = 'avatar',
6869
...otherProps
6970
} = props;
7071
const domProps = filterDOMProps(otherProps);
@@ -75,6 +76,7 @@ function Avatar(props: AvatarProps, ref: DOMRef<HTMLImageElement>) {
7576
<Image
7677
{...domProps}
7778
ref={domRef}
79+
slot={slot}
7880
alt={alt}
7981
UNSAFE_style={{
8082
...UNSAFE_style,

packages/@react-spectrum/s2/src/CloseButton.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@
1111
*/
1212

1313
import {baseColor, focusRing, style} from '../style' with {type: 'macro'};
14-
import {Button, ButtonProps} from 'react-aria-components';
14+
import {Button, ButtonProps, ContextValue} from 'react-aria-components';
15+
import {createContext, forwardRef} from 'react';
1516
import CrossIcon from '../ui-icons/Cross';
16-
import {FocusableRef} from '@react-types/shared';
17-
import {forwardRef} from 'react';
17+
import {FocusableRef, FocusableRefValue} from '@react-types/shared';
1818
import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
19+
// @ts-ignore
20+
import intlMessages from '../intl/*.json';
1921
import {pressScale} from './pressScale';
2022
import {useFocusableRef} from '@react-spectrum/utils';
23+
import {useLocalizedStringFormatter} from '@react-aria/i18n';
24+
import {useSpectrumContextProps} from './useSpectrumContextProps';
2125

22-
interface CloseButtonProps extends Omit<ButtonProps, 'className' | 'style' | 'children'>, StyleProps {
26+
export interface CloseButtonProps extends Pick<ButtonProps, 'isDisabled'>, StyleProps {
2327
/**
2428
* The size of the CloseButton.
2529
*
@@ -44,6 +48,7 @@ const styles = style({
4448
alignItems: 'center',
4549
justifyContent: 'center',
4650
size: 'control',
51+
flexShrink: 0,
4752
borderRadius: 'full',
4853
padding: 0,
4954
borderStyle: 'none',
@@ -81,19 +86,28 @@ const styles = style({
8186
}
8287
}, getAllowedOverrides());
8388

89+
export const CloseButtonContext = createContext<ContextValue<CloseButtonProps, FocusableRefValue<HTMLButtonElement>>>(null);
90+
8491
function CloseButton(props: CloseButtonProps, ref: FocusableRef<HTMLButtonElement>) {
92+
[props, ref] = useSpectrumContextProps(props, ref, CloseButtonContext);
8593
let {UNSAFE_style, UNSAFE_className = ''} = props;
8694
let domRef = useFocusableRef(ref);
95+
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
8796
return (
8897
<Button
8998
{...props}
9099
ref={domRef}
100+
slot="close"
101+
aria-label={stringFormatter.format('dialog.dismiss')}
91102
style={pressScale(domRef, UNSAFE_style)}
92-
className={renderProps => UNSAFE_className + styles(renderProps, props.styles)}>
103+
className={renderProps => UNSAFE_className + styles({...renderProps, staticColor: props.staticColor}, props.styles)}>
93104
<CrossIcon size={({S: 'L', M: 'XL', L: 'XXL', XL: 'XXXL'} as const)[props.size || 'M']} />
94105
</Button>
95106
);
96107
}
97108

109+
/**
110+
* A CloseButton allows a user to dismiss a dialog.
111+
*/
98112
let _CloseButton = forwardRef(CloseButton);
99113
export {_CloseButton as CloseButton};

0 commit comments

Comments
 (0)