diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index 25c7e00c451..c631e2beaf8 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -135,9 +135,11 @@ "@react-aria/i18n": "^3.12.8", "@react-aria/interactions": "^3.25.0", "@react-aria/live-announcer": "^3.4.2", + "@react-aria/overlays": "^3.27.0", "@react-aria/utils": "^3.28.2", "@react-spectrum/utils": "^3.12.4", "@react-stately/layout": "^4.2.2", + "@react-stately/menu": "^3.9.3", "@react-stately/utils": "^3.10.6", "@react-types/dialog": "^3.5.17", "@react-types/grid": "^3.3.1", diff --git a/packages/@react-spectrum/s2/src/CoachMark.tsx b/packages/@react-spectrum/s2/src/CoachMark.tsx new file mode 100644 index 00000000000..4edb0a55c84 --- /dev/null +++ b/packages/@react-spectrum/s2/src/CoachMark.tsx @@ -0,0 +1,530 @@ + +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import {ActionMenuContext} from './ActionMenu'; +import { + DialogTriggerProps as AriaDialogTriggerProps, + Popover as AriaPopover, + ContextValue, + DEFAULT_SLOT, + DialogContext, + OverlayTriggerStateContext, + PopoverContext, + PopoverProps, + Provider, + RootMenuTriggerStateContext, + useContextProps +} from 'react-aria-components'; +import {ButtonContext} from './Button'; +import {Card} from './Card'; +import {CheckboxContext} from './Checkbox'; +import {colorScheme, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {ColorSchemeContext} from './Provider'; +import {ContentContext, FooterContext, KeyboardContext, TextContext} from './Content'; +import { + createContext, + ForwardedRef, + forwardRef, + ReactNode, + useContext, + useRef +} from 'react'; +import {DividerContext} from './Divider'; +import {forwardRefType} from './types'; +import {ImageContext} from './Image'; +import {ImageCoordinator} from './ImageCoordinator'; +import {keyframes, raw} from '../style/style-macro' with {type: 'macro'}; +import {mergeStyles} from '../style/runtime'; +import {PressResponder} from '@react-aria/interactions'; +import {SliderContext} from './Slider'; +import {space, style} from '../style' with {type: 'macro'}; +import {useId, useObjectRef, useOverlayTrigger} from 'react-aria'; +import {useLayoutEffect} from '@react-aria/utils'; +import {useMenuTriggerState} from '@react-stately/menu'; + +export interface CoachMarkProps extends Omit, StyleProps { + /** The children of the coach mark. */ + children: ReactNode, + + size?: 'S' | 'M' | 'L' | 'XL' +} + +const fadeKeyframes = keyframes(` + from { + opacity: 0; + } + + to { + opacity: 1; + } +`); +const slideUpKeyframes = keyframes(` + from { + transform: translateY(-4px); + } + + to { + transform: translateY(0); + } +`); +const slideDownKeyframes = keyframes(` + from { + transform: translateY(4px); + } + + to { + transform: translateY(0); + } +`); +const slideRightKeyframes = keyframes(` + from { + transform: translateX(4px); + } + + to { + transform: translateX(0); + } +`); +const slideLeftKeyframes = keyframes(` + from { + transform: translateX(-4px); + } + + to { + transform: translateX(0); + } +`); + +let popover = style({ + ...colorScheme(), + '--s2-container-bg': { + type: 'backgroundColor', + value: 'layer-2' + }, + backgroundColor: '--s2-container-bg', + borderRadius: 'lg', + filter: { + isArrowShown: 'elevated' + }, + // Use box-shadow instead of filter when an arrow is not shown. + // This fixes the shadow stacking problem with submenus. + boxShadow: { + default: 'elevated', + isArrowShown: 'none' + }, + borderStyle: 'solid', + borderWidth: 1, + borderColor: { + default: 'gray-200', + forcedColors: 'ButtonBorder' + }, + width: { + size: { + // Copied from designs, not sure if correct. + S: 336, + M: 416, + L: 576 + } + }, + // Don't be larger than full screen minus 2 * containerPadding + maxWidth: '[calc(100vw - 24px)]', + boxSizing: 'border-box', + translateY: { + placement: { + bottom: { + isArrowShown: 8 // TODO: not defined yet should this change with font size? need boolean support for 'hideArrow' prop + }, + top: { + isArrowShown: -8 + } + } + }, + translateX: { + placement: { + left: { + isArrowShown: -8 + }, + right: { + isArrowShown: 8 + } + } + }, + animation: { + placement: { + top: { + isEntering: `${slideDownKeyframes}, ${fadeKeyframes}`, + isExiting: `${slideDownKeyframes}, ${fadeKeyframes}` + }, + bottom: { + isEntering: `${slideUpKeyframes}, ${fadeKeyframes}`, + isExiting: `${slideUpKeyframes}, ${fadeKeyframes}` + }, + left: { + isEntering: `${slideRightKeyframes}, ${fadeKeyframes}`, + isExiting: `${slideRightKeyframes}, ${fadeKeyframes}` + }, + right: { + isEntering: `${slideLeftKeyframes}, ${fadeKeyframes}`, + isExiting: `${slideLeftKeyframes}, ${fadeKeyframes}` + } + }, + isSubmenu: { + isEntering: fadeKeyframes, + isExiting: fadeKeyframes + } + }, + animationDuration: { + isEntering: 200, + isExiting: 200 + }, + animationDirection: { + isEntering: 'normal', + isExiting: 'reverse' + }, + animationTimingFunction: { + isExiting: 'in' + }, + transition: '[opacity, transform]', + willChange: '[opacity, transform]', + isolation: 'isolate', + pointerEvents: { + isExiting: 'none' + } +}, getAllowedOverrides()); + +const image = style({ + width: 'full', + aspectRatio: '[3/2]', + objectFit: 'cover', + userSelect: 'none', + pointerEvents: 'none' +}); + +let title = style({ + font: 'title', + fontSize: { + size: { + XS: 'title-xs', + S: 'title-xs', + M: 'title-sm', + L: 'title', + XL: 'title-lg' + } + }, + lineClamp: 3, + gridArea: 'title' +}); + +let description = style({ + font: 'body', + fontSize: { + size: { + XS: 'body-2xs', + S: 'body-2xs', + M: 'body-xs', + L: 'body-sm', + XL: 'body' + } + }, + lineClamp: 3, + gridArea: 'description' +}); + +let keyboard = style({ + gridArea: 'keyboard', + font: 'ui', + fontWeight: 'light', + color: 'gray-600', + background: 'gray-25', + unicodeBidi: 'plaintext' +}); + +let steps = style({ + font: 'detail', + fontSize: 'detail-sm', + alignSelf: 'center' +}); + +let content = style({ + display: 'grid', + // By default, all elements are displayed in a stack. + // If an action menu is present, place it next to the title. + gridTemplateColumns: { + default: ['1fr'], + ':has([data-slot=menu])': ['minmax(0, 1fr)', 'auto'] + }, + gridTemplateAreas: { + default: [ + 'title keyboard', + 'description keyboard' + ], + ':has([data-slot=menu])': [ + 'title menu', + 'keyboard keyboard', + 'description description' + ] + }, + columnGap: 4, + flexGrow: 1, + alignItems: 'baseline', + alignContent: 'space-between', + rowGap: { + size: { + XS: 4, + S: 4, + M: space(6), + L: space(6), + XL: 8 + } + }, + paddingTop: { + default: '--card-spacing', + ':first-child': 0 + }, + paddingBottom: { + default: '[calc(var(--card-spacing) * 1.5 / 2)]', + ':last-child': 0 + } +}); + +let actionMenu = style({ + gridArea: 'menu', + // Don't cause the row to expand, preserve gap between title and description text. + // Would use -100% here but it doesn't work in Firefox. + marginY: '[calc(-1 * self(height))]' +}); + +let footer = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'end', + justifyContent: 'space-between', + gap: 8, + paddingTop: '[calc(var(--card-spacing) * 1.5 / 2)]' +}); + +const actionButtonSize = { + XS: 'XS', + S: 'XS', + M: 'S', + L: 'M', + XL: 'L' +} as const; + +export const CoachMarkContext = createContext>({}); + +export const CoachMark = forwardRef((props: CoachMarkProps, ref: ForwardedRef) => { + let colorScheme = useContext(ColorSchemeContext); + [props, ref] = useContextProps(props, ref, CoachMarkContext); + let {UNSAFE_style} = props; + let {size = 'M'} = props; + let popoverRef = useObjectRef(ref); + + let children = ( + + + {props.children} + + + ); + + return ( + mergeStyles(popover({...renderProps, colorScheme}))}> + + {/* }// Reset OverlayTriggerStateContext so the buttons inside the dialog don't retain their hover state. */} + + {children} + + + + ); +}); + + +export interface CoachMarkTriggerProps extends AriaDialogTriggerProps { +} + +/** + * DialogTrigger serves as a wrapper around a Dialog and its associated trigger, linking the Dialog's + * open state with the trigger's press state. Additionally, it allows you to customize the type and + * positioning of the Dialog. + */ +export function CoachMarkTrigger(props: CoachMarkTriggerProps): ReactNode { + let triggerRef = useRef(null); + // Use useMenuTriggerState instead of useOverlayTriggerState in case a menu is embedded in the dialog. + // This is needed to handle submenus. + let state = useMenuTriggerState(props); + + let {triggerProps, overlayProps} = useOverlayTrigger({type: 'dialog'}, state, triggerRef); + + // Label dialog by the trigger as a fallback if there is no title slot. + // This is done in RAC instead of hooks because otherwise we cannot distinguish + // between context and props. Normally aria-labelledby overrides the title + // but when sent by context we want the title to win. + triggerProps.id = useId(); + overlayProps['aria-labelledby'] = triggerProps.id; + + + return ( + + + + {props.children} + + + + ); +} + + +// TODO better way to calculate 4px transform? (not 4%?) +const pulseAnimation = keyframes(` + 0% { + box-shadow: 0 0 0 4px rgba(20, 115, 230, 0.40); + transform: scale(calc(100%)); + } + 50% { + box-shadow: 0 0 0 10px rgba(20, 115, 230, 0.20); + transform: scale(104%); + } + 100% { + box-shadow: 0 0 0 4px rgba(20, 115, 230, 0.40); + transform: scale(calc(100%)); + } +`); + + +const indicator = style({ + animationDuration: 1000, + animationIterationCount: 'infinite', + animationFillMode: 'forwards', + animationTimingFunction: 'in-out', + position: 'relative', + '--activeElement': { + type: 'outlineColor', + value: { + default: 'focus-ring', + forcedColors: 'Highlight' + } + }, + '--borderOffset': { + type: 'top', + value: { + default: '[-2px]', + ':has([data-trigger=checkbox])': '[-6px]', + ':has([data-trigger=slider])': '[-8px]', + offset: { + M: '[-6px]', + L: '[-8px]' + } + } + }, + '--ringRadius': { + type: 'top', // is there a generic for pixel values? + value: { + default: '[10px]', + ':has([data-trigger=button])': '[18px]', + ':has([data-trigger=checkbox])': '[6px]' + } + } +}); + +const pulse = raw(`&:before { content: ""; display: inline-block; position: absolute; top: var(--borderOffset); bottom: var(--borderOffset); left: var(--borderOffset); right: var(--borderOffset); border-radius: var(--ringRadius); outline-style: solid; outline-color: var(--activeElement); outline-width: 4px; animation-duration: 2s; animation-iteration-count: infinite; animation-timing-function: ease-in-out; animation-fill-mode: forwards; animation-name: ${pulseAnimation}}`); + +interface CoachMarkIndicatorProps { + children: ReactNode, + isActive?: boolean +} +export const CoachMarkIndicator = /*#__PURE__*/ (forwardRef as forwardRefType)(function CoachMarkIndicator(props: CoachMarkIndicatorProps, ref: ForwardedRef) { + const {children, isActive} = props; + let objRef = useObjectRef(ref); + + // This is very silly... better ways? can't use display: contents because it breaks positioning + // this will break if there is a resize or different styles + useLayoutEffect(() => { + if (objRef.current) { + let styles = getComputedStyle(objRef.current.children[0]); + let childDisplay = styles.getPropertyValue('display'); + let childMaxWidth = styles.getPropertyValue('max-width'); + let childMaxHeight = styles.getPropertyValue('max-height'); + let childWidth = styles.getPropertyValue('width'); + let childHeight = styles.getPropertyValue('height'); + let childMinWidth = styles.getPropertyValue('min-width'); + let childMinHeight = styles.getPropertyValue('min-height'); + objRef.current.style.display = childDisplay; + objRef.current.style.maxWidth = childMaxWidth; + objRef.current.style.maxHeight = childMaxHeight; + objRef.current.style.width = childWidth; + objRef.current.style.height = childHeight; + objRef.current.style.minWidth = childMinWidth; + objRef.current.style.minHeight = childMinHeight; + } + }, [children]); + + return ( +
+ + {children} + +
+ ); +}); diff --git a/packages/@react-spectrum/s2/stories/CoachMark.stories.tsx b/packages/@react-spectrum/s2/stories/CoachMark.stories.tsx new file mode 100644 index 00000000000..3c213163383 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/CoachMark.stories.tsx @@ -0,0 +1,201 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { + ActionButton, + ActionMenu, + Button, + CardPreview, + Checkbox, + Content, + Footer, + Image, + Keyboard, + MenuItem, + Slider, + Text +} from '../src'; +import {CoachMark, CoachMarkTrigger} from '../src/CoachMark'; +import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; +import type {Meta, StoryObj} from '@storybook/react'; +import {style} from '../style' with {type: 'macro'}; +import {useState} from 'react'; + +const meta: Meta = { + component: CoachMark, + parameters: { + layout: 'centered' + }, + argTypes: { + placement: { + control: 'radio', + options: ['top', 'left', 'left top', 'right', 'right top', 'bottom'] + } + }, + title: 'CoachMark' +}; + +export default meta; +type Story = StoryObj; + +export const CoachMarkExample: Story = { + render: (args) => ( +
+ + + Sync with CC + + + + + + Hello + + Skip tour + Restart tour + + Command + B + This is the description + +
+ 1 of 10 + + +
+
+
+ +
+ ), + parameters: { + docs: { + disable: true + } + } +}; + +function ControlledCoachMark(args) { + let [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + Sync with CC + + + + + + Hello + + Skip tour + Restart tour + + Command + B + This is the description + +
+ 1 of 10 + + +
+
+
+ +
+ ); +} + +export const CoachMarkRestartable: Story = { + render: (args) => ( + + ), + parameters: { + docs: { + disable: true + } + } +}; + +export const CoachMarkSlider: Story = { + render: (args) => ( +
+ + + + + + + + + Hello + + Skip tour + Restart tour + + Command + B + This is the description + +
+ 1 of 10 + + +
+
+
+ +
+ ), + parameters: { + docs: { + disable: true + } + } +}; + +export const CoachMarkButton: Story = { + render: (args) => ( +
+ + + + + + + + + + + Hello + + Skip tour + Restart tour + + Command + B + This is the description + +
+ 1 of 10 + + +
+
+
+ +
+ ), + parameters: { + docs: { + disable: true + } + } +}; diff --git a/packages/@react-spectrum/s2/test/CoachMark.test.tsx b/packages/@react-spectrum/s2/test/CoachMark.test.tsx new file mode 100644 index 00000000000..6814ac9d717 --- /dev/null +++ b/packages/@react-spectrum/s2/test/CoachMark.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import { + ActionMenu, + Button, + CardPreview, + Checkbox, + Content, + Footer, + Image, + Keyboard, + MenuItem, + Text +} from '../src'; +import {CoachMark, CoachMarkTrigger} from '../src/CoachMark'; +import React from 'react'; +import userEvent, {UserEvent} from '@testing-library/user-event'; + +const mockAnimations = () => { + Element.prototype.animate = jest.fn().mockImplementation(() => ({finished: Promise.resolve()})); +}; + +describe('CoachMark', () => { + let user: UserEvent | null = null; + beforeAll(() => { + jest.useFakeTimers(); + mockAnimations(); + }); + beforeEach(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + afterAll(() => { + act(() => {jest.runAllTimers();}); + }); + + it('renders a coachmark', async () => { + let onPress = jest.fn(); + let {getAllByRole} = render( + + Sync with CC + + + + + + Hello + + Skip tour + Restart tour + + Command + B + This is the description + + + + + ); + act(() => {jest.runAllTimers();}); + expect(getAllByRole('button').length).toBe(4); // 2 Dismiss + 2 actions + await user?.click(getAllByRole('button')[2]); + expect(onPress).toHaveBeenCalled(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1908468e5ff..6dae49af9c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7943,10 +7943,12 @@ __metadata: "@react-aria/i18n": "npm:^3.12.8" "@react-aria/interactions": "npm:^3.25.0" "@react-aria/live-announcer": "npm:^3.4.2" + "@react-aria/overlays": "npm:^3.27.0" "@react-aria/test-utils": "npm:1.0.0-alpha.3" "@react-aria/utils": "npm:^3.28.2" "@react-spectrum/utils": "npm:^3.12.4" "@react-stately/layout": "npm:^4.2.2" + "@react-stately/menu": "npm:^3.9.3" "@react-stately/utils": "npm:^3.10.6" "@react-types/dialog": "npm:^3.5.17" "@react-types/grid": "npm:^3.3.1"