Skip to content
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

Fixes #481 Spotlight | Custom scroll targets #482

Merged
merged 7 commits into from
Oct 24, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,7 @@ exports[`Button shows LeftIconContainer when isProcessing 1`] = `
viewBox="0 0 24 24"
>
<style>
@keyframes spin { to { transform: rotate(360deg) } }
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
</style>
<g
style="animation: spin linear 1s infinite; transform-origin: center;"
Expand Down
164 changes: 147 additions & 17 deletions src/components/Spotlight/Spotlight.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { Story, Meta } from '@storybook/react';
import { mdiArrowRightBold, mdiCheckBold, mdiChevronDoubleRight, mdiDotsVertical } from '@mdi/js';
import styled from 'styled-components';
Expand All @@ -13,8 +13,14 @@ const Header = styled(Card.NoPaddingHeader)`
`;
const Container = styled(Card.Container)`
max-width: 35rem;
`;
const ScrollingWindowContainer = styled(Container)`
margin-top: 10rem;
`;
const CustomScrollBody = styled(Card.Body)`
overflow-y: auto;
max-height: 70vh;
`;

const Annotation = styled(Spotlight.Annotation)`
display: flex;
Expand All @@ -27,11 +33,11 @@ const NextButtonContainer = styled(Button.Container)`
justify-content: center;
`;

export const AnimatedSpotlight: Story = (args: Partial<SpotlightProps>) => {
export const Default: Story = (args: Partial<SpotlightProps>) => {
const [currStep, setStep] = useState<number>(0);
const [buttonRef, setButtonRef] = useState<HTMLElement>();
const [cardRef, setCardRef] = useState<HTMLElement>();
const [menuRef, setMenuRef] = useState<HTMLElement>();
const buttonRef = useRef<HTMLButtonElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLButtonElement>(null);
const [tourStarted, setTour] = useState<boolean>(false);

const stepOptions = [null, menuRef, cardRef, buttonRef];
Expand Down Expand Up @@ -61,15 +67,11 @@ export const AnimatedSpotlight: Story = (args: Partial<SpotlightProps>) => {
<>
<Card
StyledHeader={Header}
StyledContainer={Container}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - our ref types don't like getting a set state dispatch function
containerRef={setCardRef}
StyledContainer={ScrollingWindowContainer}
containerRef={cardRef}
header={
<Button
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - our ref types don't like getting a set state dispatch function
containerRef={setMenuRef}
containerRef={menuRef}
iconSuffix={mdiDotsVertical}
variant={variants.text}
color="black"
Expand All @@ -80,9 +82,7 @@ export const AnimatedSpotlight: Story = (args: Partial<SpotlightProps>) => {
onClick={() => {
setTour(true);
}}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - our ref types don't like getting a set state dispatch function
containerRef={setButtonRef}
containerRef={buttonRef}
color={colors.tertiary}
>
Start tour
Expand All @@ -109,7 +109,137 @@ export const AnimatedSpotlight: Story = (args: Partial<SpotlightProps>) => {
</p>
</Card>
{tourStarted && (
<Spotlight {...args} StyledAnnotation={Annotation} targetElement={stepOptions[currStep]}>
<Spotlight
{...args}
StyledAnnotation={Annotation}
targetElement={stepOptions[currStep]?.current as HTMLElement}
>
<Text color="white" containerProps={{ as: 'h1', style: { fontSize: '2em' } }}>
{messages[currStep].title}
</Text>
<Text color="white" containerProps={{ style: { fontWeight: '700' } }}>
{messages[currStep].subtitle}
</Text>
<br />
<br />
<Button
color={colors.background}
iconSuffix={mdiChevronDoubleRight}
variant={variants.outline}
onClick={() => {
setStep(0);
setTour(false);
}}
>
Skip
</Button>
&nbsp;
<Button
StyledContainer={NextButtonContainer}
iconSuffix={lastStep ? mdiCheckBold : mdiArrowRightBold}
elevation={1}
color={lastStep ? colors.secondaryDark : colors.primaryDark}
onClick={() => {
goNext();
}}
>
{lastStep ? 'I got it' : 'Next'}
</Button>
</Spotlight>
)}
</>
);
};
Default.args = {
padding: 12,
shape: 'rounded box',
animateTargetChanges: true,
backgroundDarkness: 0.3,
backgroundBlur: '0.25rem',
cornerRadius: 12,
};

export const CustomScrollWindow: Story = (args: Partial<SpotlightProps>) => {
const [currStep, setStep] = useState<number>(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLButtonElement>(null);
const [tourStarted, setTour] = useState<boolean>(false);

const stepOptions = [null, menuRef, cardRef, buttonRef];
const messages = [
{
title: 'Welcome to the tutorial!',
subtitle: 'targetElement is null for this part of the tour, so nothing is highlighted!',
},
{ title: 'This is a kebab menu.', subtitle: '' },
{ title: 'This is the whole card', subtitle: '' },
{ title: 'Press this button to restart the tour!', subtitle: '(you already knew that though)' },
aVileBroker marked this conversation as resolved.
Show resolved Hide resolved
];

const goNext = () => {
setStep(step => {
if (step >= stepOptions.length - 1) {
setTour(false);
return 0;
}
return step + 1;
});
};

const lastStep = currStep === stepOptions.length - 1;

return (
<>
<Card
StyledHeader={Header}
StyledContainer={Container}
StyledBody={CustomScrollBody}
bodyRef={bodyRef}
containerRef={cardRef}
>
<Button
containerRef={menuRef}
iconSuffix={mdiDotsVertical}
variant={variants.text}
color="black"
/>
<p>There are a few items in this card we can talk about!</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ultricies mi eget mauris pharetra et ultrices neque
ornare aenean. Vitae purus faucibus ornare suspendisse sed nisi lacus sed viverra.
Ullamcorper dignissim cras tincidunt lobortis feugiat vivamus at. Mauris a diam maecenas
sed enim ut sem viverra aliquet. Ultrices in iaculis nunc sed augue lacus viverra. Sodales
neque sodales ut etiam. Pulvinar neque laoreet suspendisse interdum consectetur libero id
faucibus nisl. Ut placerat orci nulla pellentesque dignissim enim sit amet venenatis.
Vivamus arcu felis bibendum ut tristique. Netus et malesuada fames ac turpis egestas.
Porttitor rhoncus dolor purus non enim. Proin nibh nisl condimentum id. Aliquam ultrices
sagittis orci a scelerisque purus semper. Faucibus purus in massa tempor nec feugiat. Et
netus et malesuada fames. Cras pulvinar mattis nunc sed blandit libero. Nisi lacus sed
viverra tellus in. Tellus rutrum tellus pellentesque eu tincidunt tortor aliquam nulla. Id
porta nibh venenatis cras sed felis eget velit. Eros in cursus turpis massa tincidunt dui
ut ornare. Proin fermentum leo vel orci porta non. Quisque non tellus orci ac auctor
augue.
</p>
<Button
onClick={() => {
setTour(true);
}}
containerRef={buttonRef}
color={colors.tertiary}
>
Start tour
</Button>
</Card>
{tourStarted && (
<Spotlight
{...args}
// scrollingParentElement={bodyRef.current as HTMLElement}
StyledAnnotation={Annotation}
targetElement={stepOptions[currStep]?.current as HTMLElement}
>
<Text color="white" containerProps={{ as: 'h1', style: { fontSize: '2em' } }}>
{messages[currStep].title}
</Text>
Expand Down Expand Up @@ -146,7 +276,7 @@ export const AnimatedSpotlight: Story = (args: Partial<SpotlightProps>) => {
</>
);
};
AnimatedSpotlight.args = {
CustomScrollWindow.args = {
padding: 12,
shape: 'rounded box',
animateTargetChanges: true,
Expand Down
66 changes: 58 additions & 8 deletions src/components/Spotlight/Spotlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ export enum SpotlightShapes {
'roundedBox' = 'rounded box',
}

// Recursively crawl the dom to find the nearest scrolling parent of the target element
const findNearestScrollingParent = (el: HTMLElement | Element): HTMLElement | Element | null => {
const parent = el.parentElement;

if (parent && parent.tagName !== 'HTML') {
if (parent?.scrollHeight > parent?.clientHeight) {
// found it!
return parent;
}
return findNearestScrollingParent(parent);
}

// passing it back down
return null;
};

export type SpotlightProps = {
StyledContainer?: StyledSubcomponentType;
containerProps?: SubcomponentPropsType;
Expand All @@ -65,6 +81,7 @@ export type SpotlightProps = {

children?: ReactNode;
targetElement?: HTMLElement | Element;
scrollingParentElement?: HTMLElement | Element;
backgroundBlur?: string;
backgroundDarkness?: number;
shape?: SpotlightShapes;
Expand All @@ -90,6 +107,7 @@ const Spotlight = ({

children,
targetElement,
scrollingParentElement, // this will get automatically picked if not defined
backgroundBlur = '0.25rem',
backgroundDarkness = 0.3,
shape = SpotlightShapes.circular,
Expand All @@ -103,12 +121,18 @@ const Spotlight = ({
scrollUpdateInterval = 0,
}: SpotlightProps): JSX.Element | null => {
const handleEventWithAnalytics = useAnalytics();
const scrollTarget = useRef(scrollingParentElement || null);

const {
width: windowWidth,
height: windowHeight,
isResizing,
} = useWindowSizeObserver(resizeUpdateInterval);
const { scrollY, isScrolling } = useScrollObserver(scrollUpdateInterval);
} = useWindowSizeObserver(resizeUpdateInterval, 50);

const { scrollY, isScrolling } = useScrollObserver(scrollUpdateInterval, 50, {
target: scrollTarget.current || undefined,
});

const {
performanceInfo: { tier: gpuTier },
accessibilityPreferences: { prefersReducedMotion },
Expand All @@ -120,6 +144,14 @@ const Spotlight = ({

const [isAutoScrolling, setIsAutoScrolling] = useState(false);

useEffect(() => {
if (targetElement && !scrollTarget.current) {
scrollTarget.current = findNearestScrollingParent(targetElement);
}
}, [targetElement]);

console.log(scrollTarget.current);
aVileBroker marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
if (targetElement) {
const newTargetTop = targetElement?.getBoundingClientRect().top ?? 0;
Expand All @@ -129,15 +161,33 @@ const Spotlight = ({
// (if the annotation is below the target, this should be an addition, not subtraction)
const offset = annotationHeight ? newTargetTop - annotationHeight : newTargetTop;

window.scrollBy({
top: offset,
left: 0,
behavior: !animateTargetChanges || prefersReducedMotion ? 'auto' : 'smooth',
});
if (scrollTarget.current) {
// TODO:
aVileBroker marked this conversation as resolved.
Show resolved Hide resolved
// compare offset with the scrollTarget scrollHeight,
// if it's larger, subtract them and scroll the window next

scrollTarget.current.scrollTo({
top: offset,
left: 0,
behavior: !animateTargetChanges || prefersReducedMotion ? 'auto' : 'smooth',
});
} else {
window.scrollBy({
top: offset,
left: 0,
behavior: !animateTargetChanges || prefersReducedMotion ? 'auto' : 'smooth',
});
}

setIsAutoScrolling(true);
}
}, [animateTargetChanges, internalAnnotationRef, prefersReducedMotion, targetElement]);
}, [
animateTargetChanges,
internalAnnotationRef,
prefersReducedMotion,
scrollTarget,
targetElement,
]);

useEffect(() => {
// check if auto scroll has completed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ exports[`Tag shows LeftIconContainer when isProcessing 1`] = `
viewBox="0 0 24 24"
>
<style>
@keyframes spin { to { transform: rotate(360deg) } }
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
</style>
<g
style="animation: spin linear 1s infinite; transform-origin: center;"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ exports[`Text matches snapshot with isProcessing 1`] = `
viewBox="0 0 24 24"
>
<style>
@keyframes spin { to { transform: rotate(360deg) } }
@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
</style>
<g
style="animation: spin linear 1s infinite; transform-origin: center;"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ exports[`Toggle Accessibility Tests Should pass accessibility test with default
class=" c3"
role="switch"
type="checkbox"
value=""
/>
</label>
</div>
Expand Down
Loading
Loading