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 + + Copy as plain text + Copy as rich text + Copy URL + + + + + + + + + + Crop Rotate + + + + Clone Stamp + + + +
+ ) +}; 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 + + Cut + + + ); + + 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 + + Cut + + + ); + + 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: {