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

Copy Tooltip from nns-dapp #367

Merged
merged 5 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions src/docs/constants/docs.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ export const COMPONENT_ROUTES: ComponentRoute[] = [
description: "An opinionated theme toggle.",
},

{
path: "/components/tooltip",
title: "Tooltip",
description: "Tooltips provide extra information on hover or tap.",
},

{
path: "/components/toggle",
title: "Toggle",
Expand Down
170 changes: 170 additions & 0 deletions src/lib/components/Tooltip.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { debounce } from "@dfinity/utils";

export let id: string;
export let testId = "tooltip-component";
export let text = "";
export let noWrap = false;
export let top = false;
export let center = false;
export let containerSelector = "main";

let tooltipComponent: HTMLDivElement | undefined = undefined;
let target: HTMLDivElement | undefined = undefined;
let innerWidth: number | undefined = undefined;
let tooltipStyle: string | undefined = undefined;

const setPosition = debounce(() => {
// The debounce might effectively happen after the component has been destroyed, this is particularly the case in unit tests.
// That is why we are using a guard to avoid to perform any logic in case the Tooltip does not exist anymore.
if (destroyed) {
return;
}

// We need the main reference because at the moment the scrollbar is displayed in that element therefore it's the way to get to know the real width - i.e. window width - scrollbar width
const main: HTMLElement | null = document.querySelector(containerSelector);

if (
main === null ||
tooltipComponent === undefined ||
target === undefined
) {
// Do nothing, we need the elements to be rendered in order to get their size and position to fix the tooltip
return;
}

const SCROLLBAR_FALLBACK_WIDTH = 20;

const { clientWidth, offsetWidth } = main;
const { left: containerLeft } = main.getBoundingClientRect();
const scrollbarWidth =
offsetWidth - clientWidth > 0
? offsetWidth - clientWidth
: SCROLLBAR_FALLBACK_WIDTH;

const { left: targetLeft, width: targetWidth } =
target.getBoundingClientRect();
const targetCenter = targetLeft + targetWidth / 2;

const { width: tooltipWidth } = tooltipComponent.getBoundingClientRect();

// Space at the left of the center of the target until the containerSelector.
const spaceLeft = targetCenter - containerLeft;
// Space at the right of the center of the target until the containerSelector.
const spaceRight =
containerLeft + clientWidth - scrollbarWidth - targetCenter;

const overflowLeft = spaceLeft > 0 ? tooltipWidth / 2 - spaceLeft : 0;
const overflowRight = spaceRight > 0 ? tooltipWidth / 2 - spaceRight : 0;

const { left: mainLeft, right: mainRight } = main.getBoundingClientRect();

// If we cannot calculate the overflow left we then avoid overflow by setting no transform on the left side
const leftToMainCenter =
mainLeft + (mainRight - mainLeft) / 2 > targetCenter;

// If tooltip overflow both on left and right, we only set the left anchor.
// It would need the width to be maximized to window screen too but it seems to be an acceptable edge case.
tooltipStyle = center
? `--tooltip-transform-x: calc(-50%)`
: overflowLeft > 0
? `--tooltip-transform-x: calc(-50% + ${overflowLeft}px)`
: overflowRight > 0
? `--tooltip-transform-x: calc(-50% - ${overflowRight}px)`
: leftToMainCenter
? `--tooltip-transform-x: 0`
: undefined;
});

$: innerWidth, tooltipComponent, target, setPosition();

let destroyed = false;
onDestroy(() => (destroyed = true));
</script>

<svelte:window bind:innerWidth />

<div class="tooltip-wrapper" data-tid={testId}>
<div class="tooltip-target" aria-describedby={id} bind:this={target}>
<slot />
</div>
<div
class="tooltip"
role="tooltip"
{id}
class:noWrap
class:top
bind:this={tooltipComponent}
style={tooltipStyle}
>
{text}
</div>
</div>

<style lang="scss">
.tooltip-wrapper {
position: relative;
display: var(--tooltip-display, block);
width: var(--tooltip-width);
}

.tooltip {
z-index: calc(var(--overlay-z-index) + 1);

position: absolute;
display: inline-block;

left: 50%;
bottom: var(--padding-0_5x);
--tooltip-transform-x-default: calc(-50% - var(--padding-4x));
transform: translate(
var(--tooltip-transform-x, var(--tooltip-transform-x-default)),
100%
);

opacity: 0;
visibility: hidden;
transition:
opacity 150ms,
visibility 150ms;

padding: 4px 6px;
border-radius: 4px;

font-size: var(--font-size-small);

background: var(--card-background-contrast);
color: var(--card-background);

// limit width
white-space: pre-wrap;
max-width: 240px;
width: max-content;
overflow-wrap: break-word;

&.noWrap {
white-space: nowrap;
}

&.top {
bottom: unset;
top: calc(-1 * var(--padding));
transform: translate(
var(--tooltip-transform-x, var(--tooltip-transform-x-default)),
-100%
);
}

pointer-events: none;
}

.tooltip-target {
height: 100%;

&:hover + .tooltip {
opacity: 1;
visibility: initial;
}
}
</style>
110 changes: 110 additions & 0 deletions src/routes/(split)/components/tooltip/+page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<script lang="ts">
import Tooltip from "$lib/components/Tooltip.svelte";
</script>

# Tooltip

Used to provide extra information, often about why a button is disabled, on
hover or tap over the target element.

```javascript
<Tooltip
id="example-button"
text={"This button is disabled because of a long and complicated explanation
that doesn't fit in the margin of this webpage."}
>
<button class="secondary" disabled>Disabled</button>
</Tooltip>
```

## Properties

| Property | Description | Type | Default |
| ------------------- | --------------------------------------------------------------- | --------- | --------------------- |
| `id` | Used to link the target to the tooltip via `aria-describedby` | `string` | |
| `testId` | Add a `data-tid` attribute to the DOM, useful for test purpose. | `string` | `"tooltip-component"` |
| `text` | The text displayed in the tooltip. | `string` | `""` |
| `noWrap` | Whether to prevent the tooltip text from taking mulitple lines. | `boolean` | `false` |
| `top` | Whether to prevent the tooltip text from taking mulitple lines. | `boolean` | `false` |
| `center` | Whether to ignore overflow logic an just center align instead. | `boolean` | `false` |
| `containerSelector` | Used to query for the container used to determine overflow. | `string` | `"main"` |

## Slots

| Slot name | Description |
| ------------ | -------------------------- |
| Default slot | The target of the tooltip. |

## Showcase

The tooltips will appear when the buttons are hovered or tapped.

<div class="tooltip-target-container">
<div class="row">
<Tooltip
id="example-button"
containerSelector=".tooltip-target-container"
text={"This button is disabled because of a long and complicated explanation that doesn't fit in the margin of this webpage."}
>
<button class="secondary" disabled>Disabled</button>
</Tooltip>
<Tooltip
id="example-button"
containerSelector=".tooltip-target-container"
text={"This button is disabled because of a long and complicated explanation that doesn't fit in the margin of this webpage."}
>
<button class="secondary" disabled>Disabled</button>
</Tooltip>
<Tooltip
id="example-button"
containerSelector=".tooltip-target-container"
text={"This button is disabled because of a long and complicated explanation that doesn't fit in the margin of this webpage."}
>
<button class="secondary" disabled>Disabled</button>
</Tooltip>
</div>
<div class="row">
<Tooltip
id="example-button"
top={true}
containerSelector=".tooltip-target-container"
text={"This button is disabled because of a long and complicated explanation that doesn't fit in the margin of this webpage."}
>
<button class="secondary" disabled>Disabled</button>
</Tooltip>
<Tooltip
id="example-button"
top={true}
containerSelector=".tooltip-target-container"
text={"This button is disabled because of a long and complicated explanation that doesn't fit in the margin of this webpage."}
>
<button class="secondary" disabled>Disabled</button>
</Tooltip>
<Tooltip
id="example-button"
top={true}
containerSelector=".tooltip-target-container"
text={"This button is disabled because of a long and complicated explanation that doesn't fit in the margin of this webpage."}
>
<button class="secondary" disabled>Disabled</button>
</Tooltip>
</div>
</div>

<style lang="scss">
@use "../../../../lib/styles/mixins/media";

.tooltip-target-container {
background-color: var(--card-background);
padding: var(--padding);
display: flex;
flex-direction: column;
gap: 100px;
overflow: hidden;
}

.row {
display: flex;
justify-content: space-between;
}
</style>
21 changes: 21 additions & 0 deletions src/tests/lib/components/Tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render } from "@testing-library/svelte";
import TooltipTest from "./TooltipTest.svelte";

describe("Tooltip", () => {
it("should render target content", () => {
const { container } = render(TooltipTest);

const element: HTMLParagraphElement | null = container.querySelector("p");

expect(element).toBeInTheDocument();
expect(element?.innerHTML).toBe("content");
});

it("should render aria-describedby and relevant id", () => {
const { container } = render(TooltipTest);
expect(
container.querySelector("[aria-describedby='tid']"),
).toBeInTheDocument();
expect(container.querySelector("[id='tid']")).toBeInTheDocument();
});
});
7 changes: 7 additions & 0 deletions src/tests/lib/components/TooltipTest.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
import Tooltip from "$lib/components/Tooltip.svelte";
</script>

<Tooltip id="tid" text="text">
<p>content</p>
</Tooltip>
Loading