Skip to content

[WIP] feat: Coachmark native popover #8095

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e4522ee
GW coachmark
jluyau Dec 19, 2024
d42a8ee
fix TS errors
jluyau Jan 9, 2025
fb42457
Merge branch 'main' of github.com:adobe/react-spectrum into coachmarks
jluyau Jan 9, 2025
3b7f7bf
remove unrelated files, update yarn.lock
jluyau Jan 9, 2025
a104309
remove unneeded import
jluyau Jan 9, 2025
1162a44
lint fix
jluyau Jan 9, 2025
8cbcadf
more lint fixes
jluyau Jan 9, 2025
a33a533
remove old stories
jluyau Jan 9, 2025
6c01220
remove unused import
jluyau Jan 9, 2025
0512bee
Merge branch 'main' into coachmarks
snowystinger Jan 15, 2025
e0eca9e
Merge branch 'main' into coachmarks
snowystinger Mar 10, 2025
3601365
fix yarn lock
snowystinger Mar 11, 2025
5c252ef
Merge branch 'main' into coachmarks
snowystinger Mar 20, 2025
7fb0ea7
API changes to bring in line with other s2 components
snowystinger Mar 20, 2025
be9343b
Cleanup to get merged
snowystinger Apr 10, 2025
7f0f308
fix lint and tests
snowystinger Apr 10, 2025
109d95f
Merge branch 'main' into coachmarks
snowystinger Apr 10, 2025
68ac81a
Merge branch 'main' into coachmarks
snowystinger Apr 11, 2025
6cc2a37
feat: CoachMark native popover
snowystinger Apr 14, 2025
6aad43c
fix story
snowystinger Apr 14, 2025
5b6ae36
fix lint
snowystinger Apr 14, 2025
3e4d2e1
fix tests
snowystinger Apr 14, 2025
78eca49
Remove card to just examples
snowystinger Apr 15, 2025
8b42b17
fix types, combine stories, add esc close
snowystinger Apr 15, 2025
ef0fb53
fix lint
snowystinger Apr 15, 2025
b7f9e10
Merge branch 'main' into coachmarks
snowystinger Apr 15, 2025
4bbc550
Merge branch 'coachmarks' into coachmark-native-popover
snowystinger Apr 15, 2025
59a180c
fix menu
snowystinger Apr 15, 2025
c4571a5
fix style call
snowystinger Apr 15, 2025
65c4add
fix test
snowystinger Apr 15, 2025
03d2fa3
undo theme change
snowystinger Apr 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/@react-spectrum/s2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const borderRadius = {
}
} as const;

let card = style({
export const card = style({
display: 'flex',
flexDirection: 'column',
position: 'relative',
Expand Down
48 changes: 48 additions & 0 deletions packages/@react-spectrum/s2/src/CoachMark.module.css
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should work to put this in the style macro as a condition?

let s = style({
  translateY: {
    default: 0,
    '@starting-style': 4
  }
});

.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;
}
}
}
307 changes: 307 additions & 0 deletions packages/@react-spectrum/s2/src/CoachMark.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>}>({});
// 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<AriaDialogTriggerProps, 'defaultOpen'> {
}

/**
* 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<HTMLDivElement>(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 (
<Provider
values={[
[OverlayTriggerStateContext, state],
[InternalCoachMarkContext, {triggerRef}] // valid to pass triggerRef?
]}>
<CoachMarkIndicator ref={triggerRef} isActive={state.isOpen}>
{props.children}
</CoachMarkIndicator>
</Provider>
);
}

export interface CoachMarkProps extends Omit<PopoverProps, 'children' | 'arrowBoundaryOffset' | 'isKeyboardDismissDisabled' | 'isNonModal' | 'isEntering' | 'isExiting' | 'scrollRef' | 'shouldCloseOnInteractOutside' | 'trigger' | 'triggerRef'>, 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<ContextValue<CoachMarkProps, HTMLDivElement>>({});

export const CoachMark = forwardRef((props: CoachMarkProps, ref: ForwardedRef<HTMLDivElement>) => {
[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<HTMLElement>(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<HTMLDivElement>(null);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could remove this if we say menus aren't allowed in coachmarks, would need more examples and somewhere to put a 'X' button maybe?


return (
<div
popover="manual"
role="dialog"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dialog? alert dialog? something else?

ref={popoverRef}
data-placement={placement}
{...keyboardProps}
style={{
...UNSAFE_style,
...overlayProps.style,
// Override default z-index from useOverlayPosition. We use isolation: isolate instead.
zIndex: undefined
}}
className={UNSAFE_className + ' ' + coachmarkCss['coach-mark'] + popover}>
{/* }// Reset OverlayTriggerStateContext so the buttons inside the dialog don't retain their hover state. */}
<OverlayTriggerStateContext.Provider value={null}>
<UNSAFE_PortalProvider getContainer={() => internalContainer.current}>
{props.children}
</UNSAFE_PortalProvider>
<div
popover="manual"
ref={internalContainer}
className={style({
position: 'fixed',
top: 0,
left: 0,
width: 'full',
height: 'full',
borderStyle: 'none',
backgroundColor: 'transparent',
pointerEvents: 'none',
padding: 0,
margin: 0
})} />
</OverlayTriggerStateContext.Provider>
</div>
);
});

// 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<HTMLDivElement>) {
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 (
<div ref={objRef} className={indicator({isActive}) + ' ' + (isActive ? pulse : '')}>
<Provider
values={[
[ButtonContext, {
// @ts-ignore
'data-trigger': 'button'
}],
[CheckboxContext, {
// @ts-ignore
'data-trigger': 'checkbox'
}],
[SliderContext, {
// @ts-ignore
'data-trigger': 'slider'
}]
]}>
{children}
</Provider>
</div>
);
});
1 change: 1 addition & 0 deletions packages/@react-spectrum/s2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading