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

APP-2206: break apart tooltip component for custom usage #384

Merged
merged 12 commits into from
Sep 29, 2023
9 changes: 8 additions & 1 deletion packages/core/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ export { default as Tabs } from './tabs.svelte';
export { default as Modal } from './modal.svelte';
export { uniqueId } from './unique-id';

export { Tooltip, type TooltipLocation, type TooltipState } from './tooltip';
export {
Tooltip,
TooltipContainer,
TooltipTarget,
TooltipText,
type TooltipLocation,
type TooltipVisibility,
} from './tooltip';

export { default as ContextMenu } from './context-menu/context-menu.svelte';
export {
Expand Down
20 changes: 1 addition & 19 deletions packages/core/src/lib/input/slider-input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ const dispatch = createEventDispatcher<{
change: number | undefined;
}>();

let numberDragTooltip: Tooltip;
let numberDragCord: HTMLDivElement;
let numberDragHead: HTMLDivElement;
let isDragging = false;
Expand Down Expand Up @@ -114,13 +113,6 @@ $: handlePointerMove = (event: PointerEvent) => {
value = next;
dispatch('input', value);
}

/**
* TODO: Determine why this is being interpreted as an `any` type by the
* linter when it is of `() => Promise<void>`.
Comment on lines -119 to -120
Copy link
Member Author

@mcous mcous Sep 25, 2023

Choose a reason for hiding this comment

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

This appears to be a limitation of the Svelte ESLint plugin; it's a little buggy when it comes to correctly manipulating the parse tree when TS is involved (e.g. sveltejs/eslint-plugin-svelte#583).

I've been moving type exports to separate pure TS files to work around the issue. It's a little irritating to move the interfaces out of the component that uses them; but it's worth it to avoid losing type safety

*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
numberDragTooltip.recalculateStyle();
};

const handlePointerUp = () => {
Expand All @@ -139,13 +131,6 @@ const handlePointerDown = async (event: PointerEvent) => {
await tick();

numberDragHead.style.transform = 'translate(0px, 0px)';

/**
* TODO: Determine why this is being interpreted as an `any` type by the
* linter when it is of `() => Promise<void>`.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
numberDragTooltip.recalculateStyle();
};

const handleInput = () => {
Expand Down Expand Up @@ -207,10 +192,7 @@ const handleChange = () => {
class="pointer-events-none -ml-[2px] -mt-[5px]"
>
<div class="h-2 w-2">
<Tooltip
bind:this={numberDragTooltip}
state="visible"
>
<Tooltip state="visible">
<div class="h-2 w-2 rounded-full bg-gray-800" />
<span slot="description">{value}</span>
</Tooltip>
Expand Down
18 changes: 14 additions & 4 deletions packages/core/src/lib/tooltip/__tests__/tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
import Tooltip from './tooltip.spec.svelte';

describe('Tooltip', () => {
it('Renders the target element without the tooltip', () => {
it('renders the target element without the tooltip', () => {
render(Tooltip);

const target = screen.getByTestId('target');
Expand All @@ -15,7 +15,17 @@ describe('Tooltip', () => {
expect(tooltip).toHaveClass('invisible');
});

it('Renders the tooltip when state is visible', async () => {
it('passes the tooltip ID to the target slot', () => {
render(Tooltip);

const target = screen.getByTestId('target');
const tooltip = screen.getByRole('tooltip');

expect(tooltip).toHaveAttribute('id', expect.any(String));
expect(target).toHaveAttribute('aria-describedby', tooltip.id);
});

it('renders the tooltip when state is visible', async () => {
const user = userEvent.setup();

render(Tooltip, { state: 'visible' });
Expand All @@ -32,7 +42,7 @@ describe('Tooltip', () => {
expect(tooltip).not.toHaveClass('invisible');
});

it('Renders the tooltip on mouse enter and hides it on mouse leave', async () => {
it('shows/hides the tooltip on mouse enter/exit', async () => {
const user = userEvent.setup();

render(Tooltip);
Expand All @@ -47,7 +57,7 @@ describe('Tooltip', () => {
expect(tooltip).toHaveClass('invisible');
});

it('Renders the tooltip on keyboard focus', async () => {
it('shows/hides the tooltip on keyboard focus/blur', async () => {
render(Tooltip);

const target = screen.getByTestId('target');
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/lib/tooltip/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export { default as Tooltip } from './tooltip.svelte';
export type { TooltipLocation, TooltipState } from './tooltip-styles';
export { default as TooltipContainer } from './tooltip-container.svelte';
export { default as TooltipTarget } from './tooltip-target.svelte';
export { default as TooltipText } from './tooltip-text.svelte';
export type { TooltipLocation, TooltipVisibility } from './tooltip-styles';
40 changes: 40 additions & 0 deletions packages/core/src/lib/tooltip/tooltip-container.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!--
@component

A wrapper component to contain a tooltip and the element it describes.

Used alongside <TooltipTarget> and <TooltipText> to create more
customized tooltips when the regular <Tooltip> can't be used.

For example, you may want to attach the tooltip of an input control
to an icon, even though the tooltip describes the input, not the icon.

```svelte
<TooltipContainer let:tooltipID>
<Label>
Name
<TooltipTarget>
<Icon
tabindex="0"
cx="cursor-pointer"
name="information-outline"
/>
</TooltipTarget>
<TextInput
slot="input"
aria-describedby={tooltipID}
/>
</Label>
<TooltipText>Cool name you got there!</TooltipText>
</TooltipContainer>
```
-->
<svelte:options immutable />

<script lang="ts">
import { provideTooltipContext } from './tooltip-styles';

const { id } = provideTooltipContext();
</script>

<slot tooltipID={id} />
177 changes: 135 additions & 42 deletions packages/core/src/lib/tooltip/tooltip-styles.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,39 @@
import { computePosition, flip, shift, offset, arrow } from '@floating-ui/dom';
import { writable, type Readable } from 'svelte/store';
import {
autoUpdate,
computePosition,
flip,
shift,
offset,
arrow as arrowMiddleware,
} from '@floating-ui/dom';
import { setContext, getContext } from 'svelte';
import { derived, writable, type Readable } from 'svelte/store';
import noop from 'lodash/noop';

import { uniqueId } from '$lib/unique-id';

export type TooltipLocation = 'top' | 'bottom' | 'right' | 'left';

export type TooltipState = 'invisible' | 'visible';
export type TooltipVisibility = 'invisible' | 'visible';

export interface TooltipContext {
id: string;
styles: Readable<TooltipStyles>;
isVisible: Readable<boolean>;
setHovered: (isHovered: boolean) => void;
setTarget: (target: HTMLElement | undefined) => void;
setTooltip: (options: {
location: TooltipLocation;
visibility: TooltipVisibility;
tooltip: HTMLElement | undefined;
arrow: HTMLElement | undefined;
}) => void;
}

export interface Styles extends Readable<TooltipStyles> {
recalculate: (
target: HTMLElement | undefined,
tooltipElement: HTMLElement | undefined,
arrowElement: HTMLElement | undefined,
location: TooltipLocation
) => unknown;
export interface TooltipElements {
target?: HTMLElement;
tooltip?: HTMLElement;
arrow?: HTMLElement;
}

export interface TooltipStyles {
Expand All @@ -27,53 +49,124 @@ export interface TooltipStyles {
};
}

export const tooltipStyles = (): Styles => {
const { subscribe, set } = writable<TooltipStyles>({
tooltip: {},
arrow: {},
});

const recalculate = async (
targetElement: HTMLElement | undefined,
tooltipElement: HTMLElement | undefined,
arrowElement: HTMLElement | undefined,
location: TooltipLocation
) => {
if (targetElement && tooltipElement && arrowElement) {
const nextStyles = await calculateStyle(
targetElement,
tooltipElement,
arrowElement,
location
);
set(nextStyles);
}
export interface State {
location?: TooltipLocation;
visibility?: TooltipVisibility;
target?: HTMLElement | undefined;
tooltip?: HTMLElement | undefined;
arrow?: HTMLElement | undefined;
isHovered?: boolean;
}

const CONTEXT_KEY = Symbol('tooltip');
const INITIAL_STYLE: Readonly<TooltipStyles> = { tooltip: {}, arrow: {} };

/**
* Create and provide a context for the components of a tooltip.
*
* @returns tooltip ID, styles, and reactive actions
*/
export const provideTooltipContext = (): TooltipContext => {
const context = createContext();

setContext(CONTEXT_KEY, context);

return context;
};

/**
* Use a provided tooltip context inside a tooltip component.
*
* @returns tooltip ID, styles, and reactive actions
*/
export const useTooltip = (): TooltipContext => {
const context = getContext<TooltipContext | undefined>(CONTEXT_KEY);

if (!context) {
throw new Error('Usage: tooltip context required');
}

return context;
};

/** Create a context for a single tooltip */
const createContext = (): TooltipContext => {
const id = uniqueId('tooltip');
const state = writable<State>({});
const isVisible = derived(
state,
($state) => $state.visibility === 'visible' || Boolean($state.isHovered)
);
const styles = derived<Readable<State>, TooltipStyles>(
state,
updateStyles,
INITIAL_STYLE
);

return {
id,
isVisible,
styles,
setHovered: (isHovered) =>
state.update((previous) => ({ ...previous, isHovered })),
setTarget: (target) =>
state.update((previous) => ({ ...previous, target })),
setTooltip: (options) =>
state.update((previous) => ({ ...previous, ...options })),
};
};

/**
* Asynchronously update a tooltip's style as its state changes.
*
* For use as the update function of a derived store.
* Will update the styles when state changes, and also hooks into `autoUpdate`
* to update styles when the target or tooltip move on the page.
*
* @param state the current tooltip state
* @param set a callback to set the tooltips styles when needed
* @returns a cleanup function that will run whenever `state` is updated,
* or the derived store has no more subscribers
*/
const updateStyles = (
state: State,
set: (nextStyles: TooltipStyles) => void
): (() => void) => {
const { target, tooltip } = state;
let cleanup = noop;

return { subscribe, recalculate };
if (target && tooltip) {
cleanup = autoUpdate(target, tooltip, () => {
void calculateStyle(state).then((styles) => set(styles));
});
}

return cleanup;
};

const calculateStyle = async (
container: HTMLElement,
tooltipElement: HTMLElement,
arrowElement: HTMLElement,
location: TooltipLocation
): Promise<TooltipStyles> => {
/** Given a tooltip's state, calculate its position with floating-ui. */
const calculateStyle = async (state: State): Promise<TooltipStyles> => {
const { target, tooltip, arrow, location } = state;

if (!target || !tooltip || !arrow || !location) {
return INITIAL_STYLE;
}

const { x, y, placement, middlewareData } = await computePosition(
container,
tooltipElement,
target,
tooltip,
{
placement: location,
middleware: [
offset(7),
flip({ fallbackAxisSideDirection: 'start' }),
shift({ padding: 5 }),
arrow({ element: arrowElement }),
arrowMiddleware({ element: arrow }),
],
}
);

const { x: arrowX, y: arrowY } = middlewareData.arrow!;
const { x: arrowX, y: arrowY } = middlewareData.arrow ?? {};
const side = placement.split('-')[0] as TooltipLocation;
const staticSide = (
{ top: 'bottom', right: 'left', bottom: 'top', left: 'right' } as const
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/lib/tooltip/tooltip-target.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
@component

The target of a tooltip.

Used alongside <TooltipContainer> and <TooltipTarget> to create
customized tooltips when the regular <Tooltip> can't be used.

See <TooltipContainer> for details.
-->
<svelte:options immutable />

<script lang="ts">
import { useTooltip } from './tooltip-styles';

const { setTarget, setHovered } = useTooltip();
const hover = () => setHovered(true);
const unhover = () => setHovered(false);
let target: HTMLElement | undefined;

$: setTarget(target);
</script>

<span
role="presentation"
bind:this={target}
on:mouseenter={hover}
on:mouseleave={unhover}
on:focusin={hover}
on:focusout={unhover}
>
<slot />
</span>
Loading