diff --git a/packages/@react-spectrum/s2/chromatic/ActionButton.stories.tsx b/packages/@react-spectrum/s2/chromatic/ActionButton.stories.tsx
index 6e9c5f2be8e..fa75a34da09 100644
--- a/packages/@react-spectrum/s2/chromatic/ActionButton.stories.tsx
+++ b/packages/@react-spectrum/s2/chromatic/ActionButton.stories.tsx
@@ -10,11 +10,11 @@
* governing permissions and limitations under the License.
*/
-import {ActionButton, ActionButtonProps} from '../src/ActionButton';
-
+import {ActionButton, ActionButtonContext, ActionButtonProps} from '../src/ActionButton';
import {Avatar} from '../src/Avatar';
import BellIcon from '../s2wf-icons/S2_Icon_Bell_20_N.svg';
import CommentIcon from '../s2wf-icons/S2_Icon_Comment_20_N.svg';
+import Cut from '../s2wf-icons/S2_Icon_Cut_20_N.svg';
import {Fonts, NotificationBadges, UnsafeClassName} from '../stories/ActionButton.stories';
import {generatePowerset} from '@react-spectrum/story-utils';
import type {Meta, StoryObj} from '@storybook/react';
@@ -133,6 +133,41 @@ export const AvatarOnly: ActionButtonStory = {
export {Fonts, UnsafeClassName, NotificationBadges};
+const sizes = ['XS', 'S', 'M', 'L', 'XL'] as const;
+
+export const HoldAffordance: ActionButtonStory = {
+ render: () => (
+
+
+ {sizes.map(size => (
+
+
+
+
+
+ Cut
+
+
+
+ Cut
+
+
+
+
+
+ Cut
+
+
+
+ Cut
+
+
+ ))}
+
+
+ )
+};
+
export const NotificationBadgesCustomWidth: ActionButtonStory = {
render: args => (
diff --git a/packages/@react-spectrum/s2/chromatic/ToggleButton.stories.tsx b/packages/@react-spectrum/s2/chromatic/ToggleButton.stories.tsx
index 952a5def7fe..0064f0bcb80 100644
--- a/packages/@react-spectrum/s2/chromatic/ToggleButton.stories.tsx
+++ b/packages/@react-spectrum/s2/chromatic/ToggleButton.stories.tsx
@@ -16,6 +16,7 @@ import {
StaticColorDecorator,
StaticColorProvider
} from '../stories/utils';
+import Cut from '../s2wf-icons/S2_Icon_Cut_20_N.svg';
import {generatePowerset} from '@react-spectrum/story-utils';
import type {Meta, StoryObj} from '@storybook/react';
import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg';
@@ -23,7 +24,7 @@ import {ReactElement} from 'react';
import {shortName} from './utils';
import {style} from '../style' with {type: 'macro'};
import {Text} from '../src/Content';
-import {ToggleButton, ToggleButtonProps} from '../src/ToggleButton';
+import {ToggleButton, ToggleButtonContext, ToggleButtonProps} from '../src/ToggleButton';
const events = ['onPress', 'onPressChange', 'onPressEnd', 'onPressStart', 'onPressUp', 'onChange'];
@@ -128,3 +129,38 @@ export const Truncate: StoryObj = {
)
};
+
+const sizes = ['XS', 'S', 'M', 'L', 'XL'] as const;
+
+export const HoldAffordance: StoryObj = {
+ render: () => (
+
+
+ {sizes.map(size => (
+
+
+
+
+
+ Cut
+
+
+
+ Cut
+
+
+
+
+
+ Cut
+
+
+
+ Cut
+
+
+ ))}
+
+
+ )
+};
diff --git a/packages/@react-spectrum/s2/src/ActionButton.tsx b/packages/@react-spectrum/s2/src/ActionButton.tsx
index 0f5b6245add..dc8176a3958 100644
--- a/packages/@react-spectrum/s2/src/ActionButton.tsx
+++ b/packages/@react-spectrum/s2/src/ActionButton.tsx
@@ -12,7 +12,14 @@
import {ActionButtonGroupContext} from './ActionButtonGroup';
import {AvatarContext} from './Avatar';
-import {baseColor, focusRing, fontRelative, lightDark, style} from '../style' with {type: 'macro'};
+import {
+ baseColor,
+ focusRing,
+ fontRelative,
+ lightDark,
+ space,
+ style
+} from '../style' with {type: 'macro'};
import {ButtonProps, ButtonRenderProps, Button as RACButton} from 'react-aria-components/Button';
import {centerBaseline} from './CenterBaseline';
import {ContextValue, Provider, useSlottedContext} from 'react-aria-components/slots';
@@ -22,12 +29,13 @@ import {
staticColor,
StyleProps
} from './style-utils' with {type: 'macro'};
+import CornerTriangle from '../ui-icons/CornerTriangle';
import {createContext, forwardRef, ReactNode, useContext} from 'react';
import {FocusableRef, FocusableRefValue, GlobalDOMAttributes} from '@react-types/shared';
import {IconContext} from './Icon';
import {ImageContext} from './Image';
-import intlMessages from '../intl/*.json';
// @ts-ignore
+import intlMessages from '../intl/*.json';
import {NotificationBadgeContext} from './NotificationBadge';
import {OverlayTriggerStateContext} from 'react-aria-components/Dialog';
import {pressScale} from './pressScale';
@@ -36,6 +44,7 @@ import {SkeletonContext} from './Skeleton';
import {Text, TextContext} from './Content';
import {useFocusableRef} from './useDOMRef';
import {useFormProps} from './Form';
+import {useLocale} from 'react-aria/I18nProvider';
import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter';
import {usePendingState} from './Button';
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -307,10 +316,12 @@ const avatarSize: Record, number> =
XL: 26
} as const;
+interface ActionButtonContextProps extends Partial {
+ holdAffordance?: boolean;
+}
+
export const ActionButtonContext =
- createContext, FocusableRefValue>>(
- null
- );
+ createContext>>(null);
/**
* ActionButtons allow users to perform an action. They're used for similar, task-based options
@@ -324,7 +335,7 @@ export const ActionButton = forwardRef(function ActionButton(
[props, ref] = useSpectrumContextProps(props, ref, ActionButtonContext);
props = useFormProps(props as any);
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
- let {isPending = false} = props;
+ let {isPending = false, holdAffordance} = props as ActionButtonContextProps;
let domRef = useFocusableRef(ref);
let overlayTriggerState = useContext(OverlayTriggerStateContext);
let ctx = useSlottedContext(ActionButtonGroupContext);
@@ -340,6 +351,7 @@ export const ActionButton = forwardRef(function ActionButton(
} = ctx || {};
let {isProgressVisible} = usePendingState(isPending);
+ let {direction} = useLocale();
return (
)}
+ {holdAffordance && (
+
+ )}
>
)}
diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx
index 01a17b56cfd..34585c51a1b 100644
--- a/packages/@react-spectrum/s2/src/Menu.tsx
+++ b/packages/@react-spectrum/s2/src/Menu.tsx
@@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/
+import {ActionButtonContext} from './ActionButton';
import {
Menu as AriaMenu,
MenuItem as AriaMenuItem,
@@ -23,7 +24,6 @@ import {
SubmenuTriggerProps as AriaSubmenuTriggerProps,
MenuItemRenderProps
} from 'react-aria-components/Menu';
-
import {
baseColor,
centerPadding,
@@ -61,17 +61,18 @@ import {edgeToText} from '../style/spectrum-theme' with {type: 'macro'};
import {forwardRefType} from './types';
import {HeaderContext, HeadingContext, KeyboardContext, Text, TextContext} from './Content';
import {IconContext} from './Icon';
-import {ImageContext} from './Image';
-import InfoCircleIcon from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg'; // chevron right removed??
+import {ImageContext} from './Image'; // chevron right removed??
+import InfoCircleIcon from '../s2wf-icons/S2_Icon_InfoCircle_20_N.svg';
import {InPopoverContext, Popover, PopoverContext} from './Popover';
-import intlMessages from '../intl/*.json';
// @ts-ignore
+import intlMessages from '../intl/*.json';
import LinkOutIcon from '../ui-icons/LinkOut';
import {mergeStyles} from '../style/runtime';
import {Placement} from 'react-aria/useOverlayPosition';
import {PressResponder} from 'react-aria/private/interactions/PressResponder';
import {pressScale} from './pressScale';
import {Separator, SeparatorProps} from 'react-aria-components/Separator';
+import {ToggleButtonContext} from './ToggleButton';
import {useGlobalListeners} from 'react-aria/private/utils/useGlobalListeners';
import {useId} from 'react-aria/useId';
import {useLocale} from 'react-aria/I18nProvider';
@@ -736,7 +737,7 @@ function MenuTrigger(props: MenuTriggerProps): ReactNode {
);
};
- let {align = 'start', direction = 'bottom', shouldFlip} = props;
+ let {align = 'start', direction = 'bottom', shouldFlip, trigger = 'press'} = props;
let placement: Placement;
switch (direction) {
case 'left':
@@ -750,25 +751,32 @@ function MenuTrigger(props: MenuTriggerProps): ReactNode {
default:
placement = `${direction} ${align}` as Placement;
}
+ let holdAffordance = trigger === 'longPress';
return (
-
-
-
-
-
- {props.children}
-
-
-
-
-
+
+
+
+
+
+
+ {props.children}
+
+
+
+
+
+
);
}
diff --git a/packages/@react-spectrum/s2/src/ToggleButton.tsx b/packages/@react-spectrum/s2/src/ToggleButton.tsx
index 9e90ffc3713..b9bc5bfaf2f 100644
--- a/packages/@react-spectrum/s2/src/ToggleButton.tsx
+++ b/packages/@react-spectrum/s2/src/ToggleButton.tsx
@@ -13,9 +13,10 @@
import {ActionButtonStyleProps, btnStyles} from './ActionButton';
import {centerBaseline} from './CenterBaseline';
import {ContextValue, Provider, useSlottedContext} from 'react-aria-components/slots';
+import CornerTriangle from '../ui-icons/CornerTriangle';
import {createContext, forwardRef, ReactNode} from 'react';
import {FocusableRef, FocusableRefValue, GlobalDOMAttributes} from '@react-types/shared';
-import {fontRelative, style} from '../style' with {type: 'macro'};
+import {fontRelative, space, style} from '../style' with {type: 'macro'};
import {IconContext} from './Icon';
import {pressScale} from './pressScale';
import {
@@ -28,6 +29,7 @@ import {Text, TextContext} from './Content';
import {ToggleButtonGroupContext} from './ToggleButtonGroup';
import {useFocusableRef} from './useDOMRef';
import {useFormProps} from './Form';
+import {useLocale} from 'react-aria/I18nProvider';
import {useSpectrumContextProps} from './useSpectrumContextProps';
export interface ToggleButtonProps
@@ -56,10 +58,12 @@ export interface ToggleButtonProps
isEmphasized?: boolean;
}
+interface ToggleButtonContextProps extends Partial {
+ holdAffordance?: boolean;
+}
+
export const ToggleButtonContext =
- createContext, FocusableRefValue>>(
- null
- );
+ createContext>>(null);
/**
* ToggleButtons allow users to toggle a selection on or off, for example
@@ -84,6 +88,8 @@ export const ToggleButton = forwardRef(function ToggleButton(
size = props.size || 'M',
isDisabled = props.isDisabled
} = ctx || {};
+ let {holdAffordance} = props as ToggleButtonContextProps;
+ let {direction} = useLocale();
return (
{typeof props.children === 'string' ? {props.children} : props.children}
+ {holdAffordance && (
+
+ )}
);
});
diff --git a/packages/@react-spectrum/s2/stories/Menu.stories.tsx b/packages/@react-spectrum/s2/stories/Menu.stories.tsx
index 7ca50c777f9..d80f78d5d8d 100644
--- a/packages/@react-spectrum/s2/stories/Menu.stories.tsx
+++ b/packages/@react-spectrum/s2/stories/Menu.stories.tsx
@@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/
+import {ActionButton} from '../src/ActionButton';
import AlignLeft from '../s2wf-icons/S2_Icon_TextAlignLeft_20_N.svg';
import AlignMiddle from '../s2wf-icons/S2_Icon_TextAlignCenter_20_N.svg';
import AlignRight from '../s2wf-icons/S2_Icon_TextAlignRight_20_N.svg';
@@ -19,12 +20,12 @@ import {categorizeArgTypes, getActionArgs} from './utils';
import ClockPendingIcon from '../s2wf-icons/S2_Icon_ClockPending_20_N.svg';
import {CombinedMenu} from '../src/Menu';
import CommentTextIcon from '../s2wf-icons/S2_Icon_CommentText_20_N.svg';
-
import CommunityIcon from '../s2wf-icons/S2_Icon_Community_20_N.svg';
-
import {Content, Footer, Header, Heading, Keyboard, Text} from '../src/Content';
import {ContextualHelpPopover} from '../src/ContextualHelp';
import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg';
+import Crop from '../s2wf-icons/S2_Icon_Crop_20_N.svg';
+import CropRotate from '../s2wf-icons/S2_Icon_CropRotate_20_N.svg';
import Cut from '../s2wf-icons/S2_Icon_Cut_20_N.svg';
import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg';
import DeviceTabletIcon from '../s2wf-icons/S2_Icon_DeviceTablet_20_N.svg';
@@ -47,7 +48,9 @@ import NewIcon from '../s2wf-icons/S2_Icon_New_20_N.svg';
import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg';
import {ReactElement, useState} from 'react';
import {Selection} from '@react-types/shared';
+import StampClone from '../s2wf-icons/S2_Icon_StampClone_20_N.svg';
import TextIcon from '../s2wf-icons/S2_Icon_Text_20_N.svg';
+import {ToggleButton} from '../src/ToggleButton';
import Underline from '../s2wf-icons/S2_Icon_TextUnderline_20_N.svg';
const events = ['onAction', 'onClose', 'onOpenChange', 'onScroll', 'onSelectionChange'];
@@ -398,3 +401,37 @@ export const UnavailableMenuItem: Story = {
);
}
};
+
+export const HoldAffordance: Story = {
+ render: args => (
+
+
+ Copy
+
+
+
+
+
+
+
+
+
+ )
+};
diff --git a/packages/@react-spectrum/s2/test/Menu.test.tsx b/packages/@react-spectrum/s2/test/Menu.test.tsx
index 688d3fd46af..1d6a4706bad 100644
--- a/packages/@react-spectrum/s2/test/Menu.test.tsx
+++ b/packages/@react-spectrum/s2/test/Menu.test.tsx
@@ -10,12 +10,19 @@
* governing permissions and limitations under the License.
*/
+import {
+ act,
+ installPointerEvent,
+ pointerMap,
+ render,
+ User
+} from '@react-spectrum/test-utils-internal';
+import {ActionButton} from '../src/ActionButton';
import {AriaMenuTests} from '../../../react-aria-components/test/AriaMenu.test-util';
import {Button} from '../src/Button';
import {Collection} from 'react-aria/Collection';
import {Content, Header, Heading} from '../src/Content';
import {ContextualHelpPopover} from '../src/ContextualHelp';
-
import {
Menu,
MenuItem,
@@ -24,11 +31,9 @@ import {
SubmenuTrigger,
UnavailableMenuItemTrigger
} from '../src/Menu';
-
-import {pointerMap} from '@react-aria/test-utils';
import React from 'react';
-import {render} from '@react-spectrum/test-utils-internal';
import {Selection} from '@react-types/shared';
+import {ToggleButton} from '../src/ToggleButton';
import userEvent from '@testing-library/user-event';
// better to accept items from the test? or just have the test have a requirement that you render a certain-ish structure?
@@ -140,6 +145,63 @@ describe('Menu unavailable', () => {
});
});
+describe('long press support', function () {
+ let testUtilUser = new User({advanceTimer: jest.advanceTimersByTime});
+ let user;
+ installPointerEvent();
+
+ beforeAll(function () {
+ user = userEvent.setup({delay: null, pointerMap});
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ act(() => jest.runAllTimers());
+ jest.clearAllMocks();
+ });
+
+ it('should open the menu on longPress (ActionButton)', async function () {
+ let {getByRole} = render(
+
+ Menu Button
+
+
+ );
+
+ let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')});
+ await user.click(menuTester.trigger);
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(menuTester.menu).toBeFalsy();
+ await menuTester.open({needsLongPress: true});
+ expect(menuTester.menu).toBeTruthy();
+ });
+
+ it('should open the menu on longPress (ToggleButton)', async function () {
+ let {getByRole} = render(
+
+ Menu Button
+
+
+ );
+
+ let menuTester = testUtilUser.createTester('Menu', {root: getByRole('button')});
+ await user.click(menuTester.trigger);
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(menuTester.menu).toBeFalsy();
+ expect(menuTester.trigger).toHaveAttribute('data-selected', 'true');
+ await menuTester.open({needsLongPress: true});
+ expect(menuTester.menu).toBeTruthy();
+ });
+});
+
AriaMenuTests({
prefix: 'spectrum2-static',
renderers: {