-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: LinkPreview component #10243
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
base: main
Are you sure you want to change the base?
feat: LinkPreview component #10243
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| /* | ||
| * 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 {AriaLinkPreviewProps, useLinkPreviewTrigger} from 'react-aria/useLinkPreviewTrigger'; | ||
| import {FocusableElement} from '@react-types/shared'; | ||
| import {FocusableProvider} from 'react-aria/private/interactions/useFocusable'; | ||
| import {OverlayTriggerState} from 'react-stately/useOverlayTriggerState'; | ||
| import {OverlayTriggerStateContext} from './Dialog'; | ||
| import {PopoverContext} from './Popover'; | ||
| import {Provider} from './utils'; | ||
| import React, {JSX, ReactNode, useMemo, useRef} from 'react'; | ||
| import {useTooltipTriggerState} from 'react-stately/useTooltipTriggerState'; | ||
|
|
||
| export interface LinkPreviewProps extends AriaLinkPreviewProps { | ||
| /** The Link and Popover that make up the link preview. */ | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| /** | ||
| * A LinkPreview wraps a Link and a Popover to display a non-modal preview of the link's content | ||
| * on hover, focus, or long press. Unlike a tooltip, the popover may contain interactive content. | ||
| */ | ||
| export function LinkPreview(props: LinkPreviewProps): JSX.Element { | ||
| let state = useTooltipTriggerState({ | ||
| ...props, | ||
| delay: props.delay ?? 600, | ||
| closeDelay: props.closeDelay ?? 200 | ||
| }); | ||
| let triggerRef = useRef<FocusableElement>(null); | ||
| let popoverRef = useRef<HTMLElement>(null); | ||
| let {triggerProps, popoverProps} = useLinkPreviewTrigger( | ||
| {...props, triggerRef, popoverRef}, | ||
| state | ||
| ); | ||
|
|
||
| // The Popover and usePopover expect an OverlayTriggerState. Adapt the TooltipTriggerState (which | ||
| // provides the warmup/cooldown delay behavior) to that interface. | ||
| let overlayState = useMemo<OverlayTriggerState>( | ||
| () => ({ | ||
| isOpen: state.isOpen, | ||
| open: () => state.open(), | ||
| close: () => state.close(), | ||
| setOpen: isOpen => (isOpen ? state.open() : state.close()), | ||
| toggle: () => (state.isOpen ? state.close() : state.open()) | ||
| }), | ||
| [state] | ||
| ); | ||
|
|
||
| return ( | ||
| <Provider | ||
| values={[ | ||
| [OverlayTriggerStateContext, overlayState], | ||
| [ | ||
| PopoverContext, | ||
| { | ||
| trigger: 'LinkPreview', | ||
| triggerRef, | ||
| ref: popoverRef, | ||
| isNonModal: true, | ||
| // Skip enter/exit animations when swapping between previews during the warmup period. | ||
| isInstant: state.isInstant, | ||
| ...popoverProps | ||
| } | ||
| ] | ||
| ]}> | ||
| <FocusableProvider {...triggerProps} ref={triggerRef}> | ||
| {props.children} | ||
| </FocusableProvider> | ||
| </Provider> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,12 @@ export interface PopoverProps | |
| * Whether the popover is currently performing an exit animation. | ||
| */ | ||
| isExiting?: boolean; | ||
| /** | ||
| * Whether the popover should appear and disappear without an entry or exit animation. This is | ||
| * used by components such as LinkPreview to skip animations when quickly swapping between | ||
| * overlays. | ||
| */ | ||
| isInstant?: boolean; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure about this name.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I would prefer |
||
| /** | ||
| * The container element in which the overlay portal will be placed. This may have unknown | ||
| * behavior depending on where it is portalled to. | ||
|
|
@@ -161,7 +167,10 @@ export const Popover = /*#__PURE__*/ (forwardRef as forwardRefType)(function Pop | |
| let localState = useOverlayTriggerState(props); | ||
| let state = | ||
| props.isOpen != null || props.defaultOpen != null || !contextState ? localState : contextState; | ||
| let isExiting = useExitAnimation(ref, state.isOpen) || props.isExiting || false; | ||
| // Skip the automatic exit animation when closing instantly (e.g. swapping between link previews | ||
| // during warmup). An explicitly provided isExiting prop still takes precedence. | ||
| let exitAnimation = useExitAnimation(ref, state.isOpen); | ||
| let isExiting = props.isExiting || (!props.isInstant && exitAnimation) || false; | ||
| let isHidden = useIsHidden(); | ||
| let {direction} = useLocale(); | ||
|
|
||
|
|
@@ -201,6 +210,7 @@ interface PopoverInnerProps extends AriaPopoverProps, RenderProps<PopoverRenderP | |
| state: OverlayTriggerState; | ||
| isEntering?: boolean; | ||
| isExiting: boolean; | ||
| isInstant?: boolean; | ||
| UNSTABLE_portalContainer?: Element; | ||
| trigger?: string; | ||
| dir?: 'ltr' | 'rtl'; | ||
|
|
@@ -234,7 +244,10 @@ function PopoverInner({ | |
| ); | ||
|
|
||
| let ref = props.popoverRef as RefObject<HTMLDivElement | null>; | ||
| let isEntering = useEnterAnimation(ref, !!placement) || props.isEntering || false; | ||
| // Skip the automatic entry animation when opening instantly (e.g. swapping between link previews | ||
| // during warmup). An explicitly provided isEntering prop still takes precedence. | ||
| let enterAnimation = useEnterAnimation(ref, !!placement); | ||
| let isEntering = props.isEntering || (!props.isInstant && enterAnimation) || false; | ||
| let renderProps = useRenderProps({ | ||
| ...props, | ||
| defaultClassName: 'react-aria-Popover', | ||
|
|
@@ -248,8 +261,9 @@ function PopoverInner({ | |
|
|
||
| // Automatically render Popover with role=dialog except when isNonModal is true, | ||
| // or a dialog is already nested inside the popover. | ||
| let shouldBeDialog = !props.isNonModal || props.trigger === 'SubmenuTrigger'; | ||
| let [isDialog, setDialog] = useState(false); | ||
| let shouldBeDialog = | ||
| !props.isNonModal || props.trigger === 'SubmenuTrigger' || props.trigger === 'LinkPreview'; | ||
| let [isDialog, setDialog] = useState(props.trigger === 'LinkPreview'); | ||
| useLayoutEffect(() => { | ||
| if (ref.current) { | ||
| setDialog(shouldBeDialog && !ref.current.querySelector('[role=dialog]')); | ||
|
|
@@ -261,6 +275,7 @@ function PopoverInner({ | |
| useEffect(() => { | ||
| if ( | ||
| isDialog && | ||
| props.trigger !== 'LinkPreview' && | ||
| (props.trigger !== 'SubmenuTrigger' || getInteractionModality() !== 'pointer') && | ||
| ref.current && | ||
| !isFocusWithin(ref.current) | ||
|
|
@@ -330,7 +345,7 @@ function PopoverInner({ | |
| return ( | ||
| <Overlay | ||
| {...props} | ||
| shouldContainFocus={isDialog} | ||
| shouldContainFocus={isDialog && props.trigger !== 'LinkPreview'} | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All these exceptions are pretty gross. We should discuss a general cleanup to popovers. |
||
| isExiting={isExiting} | ||
| portalContainer={UNSTABLE_portalContainer}> | ||
| {!props.isNonModal && state.isOpen && ( | ||
|
|
@@ -349,7 +364,7 @@ function PopoverInner({ | |
| return ( | ||
| <Overlay | ||
| {...props} | ||
| shouldContainFocus={isDialog} | ||
| shouldContainFocus={isDialog && props.trigger !== 'LinkPreview'} | ||
| isExiting={isExiting} | ||
| portalContainer={UNSTABLE_portalContainer ?? groupCtx?.current ?? undefined}> | ||
| {overlay} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| /* | ||
| * 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 {Button} from 'vanilla-starter/Button'; | ||
| import {Link} from 'vanilla-starter/Link'; | ||
| import {LinkPreview} from '../src/LinkPreview'; | ||
| import {Meta, StoryObj} from '@storybook/react'; | ||
| import {Popover} from 'vanilla-starter/Popover'; | ||
| import React, {JSX, ReactNode} from 'react'; | ||
|
|
||
| export default { | ||
| title: 'React Aria Components/LinkPreview', | ||
| component: LinkPreview | ||
| } as Meta<typeof LinkPreview>; | ||
|
|
||
| export type LinkPreviewStory = StoryObj<typeof LinkPreview>; | ||
|
|
||
| interface PreviewLinkProps { | ||
| href: string; | ||
| title: string; | ||
| description: string; | ||
| children: ReactNode; | ||
| delay?: number; | ||
| closeDelay?: number; | ||
| } | ||
|
|
||
| function PreviewLink({ | ||
| href, | ||
| title, | ||
| description, | ||
| children, | ||
| delay, | ||
| closeDelay | ||
| }: PreviewLinkProps): JSX.Element { | ||
| return ( | ||
| <LinkPreview delay={delay} closeDelay={closeDelay}> | ||
| <Link href={href} target="_blank"> | ||
| {children} | ||
| </Link> | ||
| <Popover offset={4} style={{maxWidth: 260}}> | ||
| <div style={{display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'start'}}> | ||
| <strong>{title}</strong> | ||
| <span style={{fontSize: 13}}>{description}</span> | ||
| <Button onPress={() => window.open(href, '_blank', 'noopener')}>Open</Button> | ||
| </div> | ||
| </Popover> | ||
| </LinkPreview> | ||
| ); | ||
| } | ||
|
|
||
| function Example(props: {delay?: number; closeDelay?: number}): JSX.Element { | ||
| let {delay, closeDelay} = props; | ||
| return ( | ||
| <p style={{maxWidth: 500}}> | ||
| The React Spectrum project includes{' '} | ||
| <PreviewLink | ||
| href="https://react-spectrum.adobe.com/react-aria/" | ||
| title="React Aria" | ||
| description="A library of unstyled React components and hooks that provides accessible UI primitives for your design system." | ||
| delay={delay} | ||
| closeDelay={closeDelay}> | ||
| React Aria | ||
| </PreviewLink> | ||
| ,{' '} | ||
| <PreviewLink | ||
| href="https://react-spectrum.adobe.com/react-spectrum/" | ||
| title="React Spectrum" | ||
| description="A React implementation of Spectrum, Adobe's design system." | ||
| delay={delay} | ||
| closeDelay={closeDelay}> | ||
| React Spectrum | ||
| </PreviewLink> | ||
| , and{' '} | ||
| <PreviewLink | ||
| href="https://react-spectrum.adobe.com/internationalized/" | ||
| title="Internationalized" | ||
| description="A collection of framework-agnostic libraries for handling dates, numbers, and strings across locales." | ||
| delay={delay} | ||
| closeDelay={closeDelay}> | ||
| Internationalized | ||
| </PreviewLink> | ||
| . Hover one preview, then quickly hover the next to see the shared warmup timer make | ||
| subsequent previews open instantly. | ||
| </p> | ||
| ); | ||
| } | ||
|
|
||
| export const Default: LinkPreviewStory = { | ||
| render: () => <Example /> | ||
| }; | ||
|
|
||
| export const WithDelays: LinkPreviewStory = { | ||
| render: args => <Example delay={args.delay} closeDelay={args.closeDelay} />, | ||
| args: { | ||
| delay: 700, | ||
| closeDelay: 500 | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Name? Should it be LinkPreviewTrigger to match other trigger components? Is this too specific to this one use case? Are there other use cases that this would cover?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO it doesn't seem specific to links, could see this being used for cards/other pieces of content that may want to display meta data + actions/interactive content on hover or focus. That being said, I can't find an actual example of that so far haha
This is like HoverCard in Radix, so maybe something similar? PreviewTrigger?