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

Feat: [DS-209] Tooltip #517

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions .changeset/nice-fireants-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
"@hopper-ui/components": patch
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
---
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved

vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
Added the Tooltip component.
1 change: 1 addition & 0 deletions packages/components/src/Tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./src/index.ts";
5 changes: 5 additions & 0 deletions packages/components/src/Tooltip/src/PassiveTrigger.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.hop-PassiveTrigger {
--hop-PassiveTrigger-inline-size: max-content;

inline-size: var(--hop-PassiveTrigger-inline-size);
}
80 changes: 80 additions & 0 deletions packages/components/src/Tooltip/src/PassiveTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useStyledSystem, type StyledSystemProps } from "@hopper-ui/styled-system";
import clsx from "clsx";
import { forwardRef, useRef, type ForwardedRef, type ReactNode } from "react";
import { useFocusable } from "react-aria";
import { useContextProps } from "react-aria-components";

import { cssModule, type BaseComponentDOMProps } from "../../utils/index.ts";

import { PassiveTriggerContext } from "./PassiveTriggerContext.ts";

import styles from "./PassiveTrigger.module.css";

export const GlobalPassiveTriggerCssSelector = "hop-PassiveTrigger";

export interface PassiveTriggerProps extends StyledSystemProps, BaseComponentDOMProps {
/**
* The children of the PassiveTrigger.
*/
children?: ReactNode;
}
/**
* A PassiveTrigger wraps a trigger element and Tooltip, handling visibility and positioning.
*
* [View Documentation](TODO)
*/
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
function PassiveTrigger(props: PassiveTriggerProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, PassiveTriggerContext);

const { stylingProps, ...ownProps } = useStyledSystem(props);
const backupRef = useRef<HTMLDivElement>(null);
const determinedRef = (ref ?? backupRef);
const { focusableProps } = useFocusable(ownProps, determinedRef);
const {
children,
className,
slot,
style: styleProp,
...otherProps
} = ownProps;

const classNames = clsx(
className,
GlobalPassiveTriggerCssSelector,
cssModule(
styles,
"hop-PassiveTrigger"
),
stylingProps.className
);

const style = {
...stylingProps.style,
...styleProp
};

return (
Copy link
Member

Choose a reason for hiding this comment

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

This passive trigger is not focusable and has no focusable style. This lack of accessibility + mobile usage should be reflected in the jsdoc and in the documentation.

<div
ref={determinedRef}
slot={slot ?? undefined}
className={classNames}
style={style}
{...focusableProps}
{...otherProps}
>
{children}
</div>
);
}

/**
* Wraps a tooltip trigger that is not normally focusable.
*
* [View Documentation](TODO)
*/
const _PassiveTrigger = forwardRef<HTMLDivElement, PassiveTriggerProps>(PassiveTrigger);
_PassiveTrigger.displayName = "PassiveTrigger";

export { _PassiveTrigger as PassiveTrigger };

8 changes: 8 additions & 0 deletions packages/components/src/Tooltip/src/PassiveTriggerContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createContext } from "react";
import type { ContextValue } from "react-aria-components";

import type { PassiveTriggerProps } from "./PassiveTrigger.tsx";

export const PassiveTriggerContext = createContext<ContextValue<PassiveTriggerProps, HTMLDivElement>>({});

PassiveTriggerContext.displayName = "PassiveTriggerContext";
68 changes: 68 additions & 0 deletions packages/components/src/Tooltip/src/Tooltip.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
.hop-Tooltip {
--hop-Tooltip-max-inline-size: 25rem;
--hop-Tooltip-slide-amount: 0.25rem; /* Tokens not available here */
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved

vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
/* Internal Variables */
--origin-x: 0;
--origin-y: 0;

/* Ensures there's always 1rem space around the tooltip, but it'll still have a max width of 25rem. */
max-inline-size: min(var(--hop-Tooltip-max-inline-size), calc(100% - (var(--container-padding) * 2)));
}

.hop-Tooltip--top {
--origin-x: 0;
--origin-y: var(--hop-Tooltip-slide-amount);
}

.hop-Tooltip--right {
--origin-x: calc(-1 * var(--hop-Tooltip-slide-amount));
--origin-y: 0;
}

.hop-Tooltip--bottom {
--origin-x: 0;
--origin-y: calc(-1 * var(--hop-Tooltip-slide-amount));
}

.hop-Tooltip--left {
--origin-x: var(--hop-Tooltip-slide-amount);
--origin-y: 0;
}

.hop-Tooltip[data-entering] {
animation: slide 0.2s ease-out;
}

.hop-Tooltip[data-exiting] {
animation: slide 0.2s ease-in reverse;
}

.hop-Tooltip__container {
--hop-Tooltip-background: var(--hop-neutral-surface-strong);
--hop-Tooltip-border-radius: var(--hop-shape-rounded-md);
--hop-Tooltip-box-shadow: var(--hop-elevation-raised);
--hop-Tooltip-color: var(--hop-neutral-text-strong);
--hop-Tooltip-padding: var(--hop-space-inset-squish-md);

padding: var(--hop-Tooltip-padding);

color: var(--hop-Tooltip-color);

background: var(--hop-Tooltip-background);
border-radius: var(--hop-Tooltip-border-radius);
box-shadow: var(--hop-Tooltip-box-shadow);
}

@keyframes slide {
from {
transform: translate(var(--origin-x), var(--origin-y));
fraincs marked this conversation as resolved.
Show resolved Hide resolved
opacity: 0;
}

to {
transform: translateY(0);
opacity: 1;
}
}

136 changes: 136 additions & 0 deletions packages/components/src/Tooltip/src/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
useColorSchemeContext,
useStyledSystem,
type StyledComponentProps,
type StyledSystemProps
} from "@hopper-ui/styled-system";
import clsx from "clsx";
import { forwardRef, useContext, type ForwardedRef } from "react";
import { composeRenderProps, Tooltip as RACTooltip, useContextProps, type TooltipProps as RACTooltipProps } from "react-aria-components";

import { HopperProvider } from "../../HopperProvider/index.ts";
import { TextContext } from "../../typography/index.ts";
import { composeClassnameRenderProps, cssModule, ensureTextWrapper, SlotProvider, type BaseComponentDOMProps } from "../../utils/index.ts";

import { TooltipContext } from "./TooltipContext.ts";
import { TooltipTriggerContext } from "./TooltipTriggerContext.ts";

import styles from "./Tooltip.module.css";

export const GlobalTooltipCssSelector = "hop-Tooltip";

type PropsToOmit = "triggerRef" | "UNSTABLE_portalContainer" | "placement" | "containerPadding" | "offset" | "crossOffset" |
victortrinh2 marked this conversation as resolved.
Show resolved Hide resolved
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
"shouldFlip" | "arrowBoundaryOffset" | "isOpen" | "defaultOpen" | "onOpenChange";

export type TooltipContainerProps = Omit<BaseComponentDOMProps, "slot"> & StyledSystemProps;

export interface TooltipProps extends StyledComponentProps<Omit<RACTooltipProps, PropsToOmit>> {
/**
* The props of the tooltip's inner container.
*/
containerProps?: TooltipContainerProps;
}

function Tooltip(props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, TooltipContext);
const { stylingProps, ...ownProps } = useStyledSystem(props);
const {
children: childrenProp,
className,
containerProps,
style: styleProp,
...otherProps
} = ownProps;

const {
containerPadding = 16,
crossOffset,
offset,
placement = "top",
shouldFlip
} = useContext(TooltipTriggerContext);

const { colorScheme } = useColorSchemeContext();

const { stylingProps: containerStylingProps, ...containerOwnProps } = useStyledSystem(containerProps ?? {});
const {
className: containerClassName,
style: containerStyleProp,
...containerOtherProps
} = containerOwnProps;

const classNames = composeClassnameRenderProps(
className,
GlobalTooltipCssSelector,
cssModule(
styles,
"hop-Tooltip",
placement
),
stylingProps.className
);

const containerClassNames = clsx(containerClassName, styles["hop-Tooltip__container"], containerStylingProps.className);

const style = composeRenderProps(styleProp, prev => {
return {
...stylingProps.style,
"--container-padding": `${containerPadding}px`,
...prev
};
});

const containerStyle = {
...containerStylingProps.style,
...containerStyleProp
};

const children = composeRenderProps(childrenProp, prev => {
return ensureTextWrapper(prev);
});

return (
<RACTooltip
ref={ref}
className={classNames}
style={style}
containerPadding={containerPadding}
crossOffset={crossOffset}
offset={offset}
placement={placement}
shouldFlip={shouldFlip}
{...otherProps}
>
{tooltipRenderProps => {
return (
<HopperProvider colorScheme={colorScheme} withCssVariables={false}>
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
<div
className={containerClassNames}
style={containerStyle}
{...containerOtherProps}
>
<SlotProvider values={[
[TextContext, {
size: "xs"
}]
]}
>
{children(tooltipRenderProps)}
</SlotProvider>
</div>
</HopperProvider>
);
}}
</RACTooltip>
);
}

/**
* Displays concise information on hover or focus.
*
* [View Documentation](TODO)
*/
const _Tooltip = forwardRef<HTMLDivElement, TooltipProps>(Tooltip);
_Tooltip.displayName = "Tooltip";

export { _Tooltip as Tooltip };
8 changes: 8 additions & 0 deletions packages/components/src/Tooltip/src/TooltipContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createContext } from "react";
import type { ContextValue } from "react-aria-components";

import type { TooltipProps } from "./Tooltip.tsx";

export const TooltipContext = createContext<ContextValue<TooltipProps, HTMLDivElement>>({});

TooltipContext.displayName = "TooltipContext";
55 changes: 55 additions & 0 deletions packages/components/src/Tooltip/src/TooltipTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { TooltipTrigger as RACTooltipTrigger, type TooltipProps as RACTooltipProps, type TooltipTriggerComponentProps as RACTooltipTriggerProps } from "react-aria-components";

import { TooltipTriggerContext } from "./TooltipTriggerContext.ts";

export type TooltipPlacement = "start" | "end" | "right" | "left" | "top" | "bottom";

type PropsToPick = "shouldFlip" | "containerPadding" | "offset" | "crossOffset" | "isOpen" | "defaultOpen" | "onOpenChange";

export interface TooltipTriggerProps extends RACTooltipTriggerProps, Pick<RACTooltipProps, PropsToPick> {
/**
* The placement of the element with respect to its anchor element.
*
* @default 'top'
*/
placement?: TooltipPlacement;
}

/**
* A TooltipTrigger wraps a trigger element and Tooltip, handling visibility and positioning.
*
* [View Documentation](TODO)
*/
export function TooltipTrigger(props: TooltipTriggerProps) {
alexasselin008 marked this conversation as resolved.
Show resolved Hide resolved
const {
children,
containerPadding = 16,
crossOffset,
delay = 600,
offset = 4,
placement = "top",
shouldFlip,
...otherProps
} = props;

return (
<RACTooltipTrigger
delay={delay}
{...otherProps}
>
<TooltipTriggerContext.Provider
value={{
containerPadding: containerPadding,
crossOffset: crossOffset,
offset: offset,
placement: placement,
shouldFlip: shouldFlip
}}
>
{children}
</TooltipTriggerContext.Provider>
</RACTooltipTrigger>
);
}

TooltipTrigger.displayName = "TooltipTrigger";
7 changes: 7 additions & 0 deletions packages/components/src/Tooltip/src/TooltipTriggerContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createContext } from "react";

import type { TooltipTriggerProps } from "./TooltipTrigger.tsx";

export const TooltipTriggerContext = createContext<Partial<TooltipTriggerProps>>({});

TooltipTriggerContext.displayName = "TooltipTriggerContext";
6 changes: 6 additions & 0 deletions packages/components/src/Tooltip/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from "./PassiveTrigger.tsx";
export * from "./PassiveTriggerContext.ts";
export * from "./Tooltip.tsx";
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
export * from "./TooltipContext.ts";
vicky-comeau marked this conversation as resolved.
Show resolved Hide resolved
export * from "./TooltipTrigger.tsx";

Loading