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/Card.tsx b/packages/@react-spectrum/s2/src/Card.tsx index 751388b4b2a..c289b8dc614 100644 --- a/packages/@react-spectrum/s2/src/Card.tsx +++ b/packages/@react-spectrum/s2/src/Card.tsx @@ -65,7 +65,7 @@ const borderRadius = { } } as const; -let card = style({ +export const card = style({ display: 'flex', flexDirection: 'column', position: 'relative', diff --git a/packages/@react-spectrum/s2/src/CoachMark.module.css b/packages/@react-spectrum/s2/src/CoachMark.module.css new file mode 100644 index 00000000000..6d48bc984f4 --- /dev/null +++ b/packages/@react-spectrum/s2/src/CoachMark.module.css @@ -0,0 +1,48 @@ +/* spectrum theme doesn't support starting style yet, so use css modules. Also, support for transition behavior isn't implemented yet. */ +.coach-mark { + /* must have overlay transition to prevent layout shift when closing */ + transition: display allow-discrete 200ms, overlay allow-discrete 200ms, opacity 200ms, transform 200ms; + will-change: opacity, transform; + opacity: 0; + animation-direction: normal; + animation-timing-function: in; + + &[data-placement="bottom"] { + transform: translateX(0px) translateY(-4px); + } + &[data-placement="top"] { + transform: translateX(0px) translateY(4px); + } + &[data-placement="left"] { + transform: translateX(-4px) translateY(0px); + } + &[data-placement="right"] { + transform: translateX(4px) translateY(0px); + } + + &:popover-open { + transform: translateX(0px) translateY(0px); + opacity: 1; + animation-direction: reverse; + } +} + +@starting-style { + .coach-mark { + &:popover-open { + &[data-placement="bottom"] { + transform: translateX(0px) translateY(-4px); + } + &[data-placement="top"] { + transform: translateX(0px) translateY(4px); + } + &[data-placement="left"] { + transform: translateX(-4px) translateY(0px); + } + &[data-placement="right"] { + transform: translateX(4px) translateY(0px); + } + opacity: 0; + } + } +} diff --git a/packages/@react-spectrum/s2/src/CoachMark.tsx b/packages/@react-spectrum/s2/src/CoachMark.tsx new file mode 100644 index 00000000000..f4c04711c9a --- /dev/null +++ b/packages/@react-spectrum/s2/src/CoachMark.tsx @@ -0,0 +1,307 @@ + +/* + * 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 { + DialogTriggerProps as AriaDialogTriggerProps, + ContextValue, + OverlayTriggerStateContext, + PopoverProps, + Provider, + useContextProps +} from 'react-aria-components'; +import {ButtonContext} from './Button'; +import {CheckboxContext} from './Checkbox'; +import coachmarkCss from './CoachMark.module.css'; +import { + createContext, + ForwardedRef, + forwardRef, + ReactNode, + RefObject, + useContext, + useRef +} from 'react'; +import {forwardRefType} from './types'; +import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {keyframes, raw} from '../style/style-macro' with {type: 'macro'}; +import {SliderContext} from './Slider'; +import {style} from '../style' with {type: 'macro'}; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; +import {useId, useKeyboard, useObjectRef, useOverlayPosition, useOverlayTrigger} from 'react-aria'; +import {useLayoutEffect} from '@react-aria/utils'; +import {useMenuTriggerState} from '@react-stately/menu'; + +const InternalCoachMarkContext = createContext<{triggerRef?: RefObject}>({}); +// TODO: decide on props +// defaultOpen, I don't think we should use it because multiple coachmarks shouldn't be open at once and it'd be too easy. +export interface CoachMarkTriggerProps extends Omit { +} + +/** + * 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; + delete triggerProps.onPress; + + return ( + + + {props.children} + + + ); +} + +export interface CoachMarkProps extends Omit, StyleProps { + /** The children of the coach mark. */ + children: ReactNode +} + +let popover = style({ + '--s2-container-bg': { + type: 'backgroundColor', + value: 'layer-2' + }, + backgroundColor: '--s2-container-bg', + borderRadius: 'lg', + // Use box-shadow instead of filter when an arrow is not shown. + // This fixes the shadow stacking problem with submenus. + boxShadow: 'elevated', + borderStyle: 'solid', + borderWidth: 1, + height: 'fit', + width: 'fit', + // Should we overflow? or let users decide? + overflow: 'auto', + padding: 0, + margin: 0, + borderColor: { + default: 'gray-200', + forcedColors: 'ButtonBorder' + }, + boxSizing: 'border-box', + willChange: '[opacity, transform]', + isolation: 'isolate' +}, getAllowedOverrides()); + +export const CoachMarkContext = createContext>({}); + +export const CoachMark = forwardRef((props: CoachMarkProps, ref: ForwardedRef) => { + [props, ref] = useContextProps(props, ref, CoachMarkContext); + let {UNSAFE_style, UNSAFE_className = ''} = props; + let popoverRef = useObjectRef(ref); + let state = useContext(OverlayTriggerStateContext); + let {triggerRef} = useContext(InternalCoachMarkContext); + let fallbackTriggerRef = useObjectRef(useRef(null)); + triggerRef = triggerRef ?? fallbackTriggerRef; + + let {overlayProps, placement} = useOverlayPosition({ + targetRef: triggerRef, + overlayRef: popoverRef, + placement: props.placement, + offset: 16, + crossOffset: -18 // made up + }); + + let prevOpen = useRef(false); + useLayoutEffect(() => { + if (state?.isOpen && !prevOpen.current) { + popoverRef.current?.showPopover(); + internalContainer.current?.showPopover(); + } else if (!state?.isOpen && prevOpen.current) { + popoverRef.current?.hidePopover(); + internalContainer.current?.hidePopover(); + } + prevOpen.current = state?.isOpen ?? false; + }, [state?.isOpen]); + + let {keyboardProps} = useKeyboard({ + onKeyDown: (e) => { + if (e.key === 'Escape') { + state?.close(); + return; + } + e.continuePropagation(); + } + }); + // Have to put the portal in a div with popover="manual" so it is also in the top layer and renders on top of the coachmark + // Note, this isn't great because I'm not honestly sure what would happen if the page scrolled or their is a parent which affects that placement, is + // top-layer unaffected by parent stacking context? scroll parent? + let internalContainer = useRef(null); + + return ( +
+ {/* }// Reset OverlayTriggerStateContext so the buttons inside the dialog don't retain their hover state. */} + + internalContainer.current}> + {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 + // can't go searching for the first child because it's not always the first child, or in the case of slider, there are no border radius until the thumb and we + // definitely don't want that rounding. There may also be contextual help which would have a border radius + 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/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 8203f0ef23d..9980a927d8e 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -27,6 +27,7 @@ export {CardView, CardViewContext} from './CardView'; export {Checkbox, CheckboxContext} from './Checkbox'; export {CheckboxGroup, CheckboxGroupContext} from './CheckboxGroup'; export {CloseButton} from './CloseButton'; +export {CoachMark as UNSTABLE_CoachMark, CoachMarkTrigger as UNSTABLE_CoachMarkTrigger} from './CoachMark'; export {ColorArea, ColorAreaContext} from './ColorArea'; export {ColorField, ColorFieldContext} from './ColorField'; export {ColorSlider, ColorSliderContext} from './ColorSlider'; 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..61b54619e36 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/CoachMark.stories.tsx @@ -0,0 +1,306 @@ +/* + * 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, + ActionMenuContext, + Button, + ButtonGroup, + CardPreview, + Checkbox, + UNSTABLE_CoachMark as CoachMark, + UNSTABLE_CoachMarkTrigger as CoachMarkTrigger, + Content, + ContentContext, + DividerContext, + Footer, + FooterContext, + Image, + ImageContext, + ImageCoordinator, + Keyboard, + KeyboardContext, + MenuItem, + Slider, + Text, + TextContext +} from '../src'; +import {card} from '../src/Card'; +import {CoachMarkProps, CoachMarkTriggerProps} from '../src/CoachMark'; +import {DEFAULT_SLOT, Provider} from 'react-aria-components'; +import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg'; +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactNode, useState} from 'react'; +import {space, style} from '../style' with {type: 'macro'}; + +interface CoachMarkStoryProps extends CoachMarkProps { + coachMarkTriggerProps: CoachMarkTriggerProps, + triggerChildren: ReactNode +} + +const meta: Meta = { + component: CoachMark, + parameters: { + layout: 'centered' + }, + argTypes: { + placement: { + control: 'radio', + options: ['top', 'left', 'left top', 'right', 'right top', 'bottom'] + }, + triggerChildren: { + table: {disable: true} + } + }, + title: 'CoachMark' +}; + +export default meta; +type Story = StoryObj; + +function ControlledCoachMark(props: CoachMarkStoryProps) { + let {coachMarkTriggerProps, triggerChildren, ...coachMarkProps} = props; + let [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + {triggerChildren} + + + + + + + Hello + + Skip tour + Restart tour + + Command + B + This is the description + +
+ + + + +
+
+
+
+ +
+ ); +} + +export const CoachMarkExample: Story = { + render: (args) => ( + + ), + args: { + triggerChildren: Sync with CC + }, + parameters: { + docs: { + disable: true + } + } +}; + +export const CoachMarkSlider: Story = { + render: (args) => ( + + ), + args: { + triggerChildren: + }, + parameters: { + docs: { + disable: true + } + } +}; + +export const CoachMarkButton: Story = { + render: (args) => ( + + ), + args: { + triggerChildren: ( + + + + ) + }, + parameters: { + docs: { + disable: true + } + } +}; + +function CoachMarkCard(props) { + let {size = 'M'} = props; + return ( +
+ + + {props.children} + + +
+ ); +} + + +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; 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..081339cdc90 --- /dev/null +++ b/packages/@react-spectrum/s2/test/CoachMark.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 { + Button, + Checkbox, + UNSTABLE_CoachMark as CoachMark, + UNSTABLE_CoachMarkTrigger as CoachMarkTrigger +} from '../src'; +import React, {createRef, useState} 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();}); + }); + + function CoachMarkTest(props) { + let [isOpen, setIsOpen] = useState(false); + return ( +
+ + + Sync with CC + +
+ + +
+
+
+
+ ); + } + + it('renders a coachmark', async () => { + let coachmarkRef = createRef(); + let {getAllByRole} = render( + + ); + let popovers = document.querySelectorAll('[popover="manual"]'); + for (let popover of popovers) { + // @ts-ignore + popover.showPopover = jest.fn(); + // @ts-ignore + popover.hidePopover = jest.fn(); + } + + let startButton = getAllByRole('button')[0]; + await user?.click(startButton); + act(() => {jest.runAllTimers();}); + expect(coachmarkRef.current!.showPopover).toHaveBeenCalled(); + expect(getAllByRole('button').length).toBe(3); // start, previous, next + await user?.click(getAllByRole('button')[2]); + expect(coachmarkRef.current!.hidePopover).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"