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

🪟 🎨 Update Tooltip component to match design library #14816

Merged
merged 17 commits into from
Aug 18, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions airbyte-webapp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,7 @@ yarn-error.log*
.env.development
.env.production

storybook-static/

# Ignore generated API client, since it's automatically generated
/src/core/request/AirbyteClient.ts
47 changes: 47 additions & 0 deletions airbyte-webapp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions airbyte-webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"validate-links": "ts-node --skip-project ./scripts/validate-links.ts"
},
"dependencies": {
"@floating-ui/react-dom": "^1.0.0",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const ReleaseStageBadge: React.FC<ReleaseStageBadgeProps> = ({ stage, sma
);

return tooltip ? (
<ToolTip control={badge} cursor="help">
Copy link
Contributor Author

@edmundito edmundito Aug 4, 2022

Choose a reason for hiding this comment

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

While reviewing the design with @Upmitt, it was decided that it didn't make sense to show the help cursor here.

<ToolTip control={badge}>
<FormattedMessage id={`connector.releaseStage.${stage}.description`} />
</ToolTip>
) : (
Expand Down
34 changes: 34 additions & 0 deletions airbyte-webapp/src/components/ToolTip/ToolTip.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@use "../../scss/colors";
@use "../../scss/variables";
@use "../../scss/z-indices";

.container {
display: inline;
position: relative;
}

.tooltip {
font-size: 12px;
line-height: initial;

padding: variables.$spacing-md;
border-radius: 5px;
max-width: 300px;
z-index: z-indices.$tooltip;
box-shadow: 0px 2px 4px rgba(colors.$dark-blue, 0.12);
background: rgba(colors.$dark-blue, 0.9);
color: colors.$white;

a {
color: rgba(colors.$white, 0.5);
}

&.light {
background: rgba(colors.$white, 0.9);
color: colors.$dark-blue;

a {
color: colors.$blue;
}
}
}
125 changes: 77 additions & 48 deletions airbyte-webapp/src/components/ToolTip/ToolTip.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,80 @@
import React from "react";
import styled from "styled-components";

interface ToolTipProps {
control: React.ReactNode;
className?: string;
disabled?: boolean;
cursor?: "pointer" | "help" | "not-allowed";
}

const Control = styled.div<{ $cursor?: "pointer" | "help" | "not-allowed"; $showCursor?: boolean }>`
display: inline;
position: relative;
${({ $cursor, $showCursor = true }) => ($showCursor && $cursor ? `cursor: ${$cursor}` : "")};
`;

const ToolTipView = styled.div<{ $disabled?: boolean }>`
display: none;
font-size: 14px;
line-height: initial;
position: absolute;
padding: 9px 8px 8px;
box-shadow: 0 24px 38px rgba(53, 53, 66, 0.14), 0 9px 46px rgba(53, 53, 66, 0.12), 0 11px 15px rgba(53, 53, 66, 0.2);
border-radius: 4px;
background: rgba(26, 26, 33, 0.9);
color: ${({ theme }) => theme.whiteColor};
top: calc(100% + 10px);
left: -50px;
min-width: 100px;
width: max-content;
max-width: 380px;
z-index: 10;

div:hover > &&,
&&:hover {
display: ${({ $disabled }) => ($disabled ? "none" : "block")};
}
`;

const ToolTip: React.FC<ToolTipProps> = ({ children, control, className, disabled, cursor }) => {
import { flip, offset, shift, useFloating } from "@floating-ui/react-dom";
import classNames from "classnames";
import React, { useState, useEffect } from "react";

import { tooltipContext } from "./context";
import styles from "./ToolTip.module.scss";
import { ToolTipProps } from "./types";

const MOUSE_OUT_TIMEOUT_MS = 50;

export const ToolTip: React.FC<ToolTipProps> = (props) => {
const { children, control, className, disabled, cursor, theme = "dark", placement = "bottom" } = props;

const [isMouseOver, setIsMouseOver] = useState(false);
const [isVisible, setIsVisible] = useState(false);

const { x, y, reference, floating, strategy } = useFloating({
placement,
middleware: [
offset(5), // $spacing-sm
flip(),
shift(),
],
});

useEffect(() => {
if (isMouseOver) {
setIsVisible(true);
return;
}

const timeout = window.setTimeout(() => {
setIsVisible(false);
}, MOUSE_OUT_TIMEOUT_MS);

return () => {
window.clearTimeout(timeout);
};
}, [isMouseOver]);

const canShowTooltip = isVisible && !disabled;

const onMouseOver = () => {
setIsMouseOver(true);
};

const onMouseOut = () => {
setIsMouseOver(false);
};

return (
<Control $cursor={cursor} $showCursor={!disabled}>
{control}
<ToolTipView className={className} $disabled={disabled}>
{children}
</ToolTipView>
</Control>
<>
<div
ref={reference}
className={styles.container}
style={disabled ? undefined : { cursor }}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
>
{control}
</div>
{canShowTooltip && (
<div
role="tooltip"
ref={floating}
className={classNames(styles.tooltip, theme === "light" && styles.light, className)}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
}}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
>
<tooltipContext.Provider value={props}>{children}</tooltipContext.Provider>
</div>
)}
</>
);
};

export default ToolTip;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@use "../../scss/variables";

.container {
margin-top: variables.$spacing-md;
}
15 changes: 15 additions & 0 deletions airbyte-webapp/src/components/ToolTip/TooltipLearnMoreLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FormattedMessage } from "react-intl";

import styles from "./TooltipLearnMoreLink.module.scss";

interface TooltipLearnMoreLinkProps {
url: string;
}

export const TooltipLearnMoreLink: React.VFC<TooltipLearnMoreLinkProps> = ({ url }) => (
Copy link
Contributor

Choose a reason for hiding this comment

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

Just curious, why did you use React.VFC<> here, but React.FC<> in the ToolTip component?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

VFC (Void FunctionComponent) is a type of component that does not allow children to be passed through it. It's something I discovered recently while working on this.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I see, and we don't want this component to have any children because it is just a simple div with a link

<div className={styles.container}>
<a href={url} target="_blank" rel="noreferrer">
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably not worth the effort, nor would this be the place, but I'd love to have a component that would always tack on target="_blank" rel="noreferrer"
But remembering to use it would likely be more obnoxious than just adding this since the linter yells about it.
If we ever wanted click tracking for analytics we could create a TrackingLink component or something and put that in there. 🤷

<FormattedMessage id="ui.learnMore" />
</a>
</div>
);
13 changes: 13 additions & 0 deletions airbyte-webapp/src/components/ToolTip/TooltipTable.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@use "../../scss/colors";
@use "../../scss/variables";

.label {
color: rgba(colors.$white, 0.7);
padding-right: variables.$spacing-sm;
}

.light {
.label {
color: rgba(colors.$dark-blue, 0.7);
}
}
24 changes: 24 additions & 0 deletions airbyte-webapp/src/components/ToolTip/TooltipTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useTooltipContext } from "./context";
import styles from "./TooltipTable.module.scss";

interface TooltipTableProps {
rows: React.ReactNode[][];
}

export const TooltipTable: React.VFC<TooltipTableProps> = ({ rows }) => {
const { theme } = useTooltipContext();
Copy link
Contributor

Choose a reason for hiding this comment

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

For my own learning, could you briefly explain why you chose to use a context here? Is this because the theme prop could not be passed directly to TooltipTable, because that sub-component is not always present?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did this in order to understand the context of the tooltip without having to pass the props twice. Otherwise in the component that implements you you'd have to pass the same theme:

<Tooltip theme="light">
  <TooltipTable theme="light" />
</Tooltip>

// Nothing stopping you from doing:
<Tooltip theme="light">
  <TooltipTable /> // If you don't pass the theme and have no context, the theme of the table will be "dark"
</Tooltip>

Copy link
Contributor

@lmossman lmossman Aug 10, 2022

Choose a reason for hiding this comment

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

Ah okay, so this is basically forcing the TooltipTable to match the same theme as its parent Tooltip through the use of context, removing that burden from the developer who adds the TooltipTable component somewhere. That is cool, thanks for explaining!


return rows.length > 0 ? (
<table className={theme === "light" ? styles.light : undefined}>
<tbody>
{rows?.map((cols) => (
<tr>
{cols.map((col, index) => (
<td className={index === 0 ? styles.label : undefined}>{col}</td>
))}
</tr>
))}
</tbody>
</table>
) : null;
};
15 changes: 15 additions & 0 deletions airbyte-webapp/src/components/ToolTip/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";

import { TooltipContext } from "./types";

export const tooltipContext = createContext<TooltipContext | null>(null);
Copy link
Contributor

Choose a reason for hiding this comment

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

We use both camelCase and PascalCase for context's. We should decide one way or the other just for consistency.
My argument for PascalCase is since it's used as part of JSX and looks like a component, it should match that kind of usage.


export const useTooltipContext = () => {
const ctx = useContext(tooltipContext);

if (!ctx) {
throw new Error("useTooltipContext should be used within tooltipContext.Provider");
}

return ctx;
};
57 changes: 57 additions & 0 deletions airbyte-webapp/src/components/ToolTip/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";

import { ToolTip } from "./ToolTip";
import { TooltipLearnMoreLink } from "./TooltipLearnMoreLink";
import { TooltipTable } from "./TooltipTable";

export default {
title: "Ui/ToolTip",
component: ToolTip,
argTypes: {
control: { type: { name: "string", required: true } },
children: { type: { name: "string", required: true } },
},
} as ComponentMeta<typeof ToolTip>;

const Template: ComponentStory<typeof ToolTip> = (args) => (
<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center" }}>
<ToolTip {...args} />
</div>
);

export const Primary = Template.bind({});
Primary.args = {
control: "Hover to see Tooltip",
children: (
<>
Looking for a job?{" "}
<a href="https://www.airbyte.com/careers" target="_blank" rel="noreferrer">
Apply at Airbyte!
</a>
</>
),
};

export const WithLearnMoreUrl = Template.bind({});
WithLearnMoreUrl.args = {
control: "Hover to see Tooltip with Body",
children: (
<>
Airbyte is hiring! <TooltipLearnMoreLink url="https://www.airbyte.com/careers" />
</>
),
};

export const WithTable = Template.bind({});
WithTable.args = {
control: "Hover to see Tooltip with Table",
children: (
<TooltipTable
rows={[
["String", "Value"],
["Number", 32768],
["With a longer label", "And here is a longer value"],
]}
/>
),
};
Loading