Skip to content

Commit

Permalink
Fix placement without JavaScript
Browse files Browse the repository at this point in the history
  • Loading branch information
connor-baer committed Mar 25, 2024
1 parent 5adf52a commit 2c3d96b
Show file tree
Hide file tree
Showing 2 changed files with 94 additions and 42 deletions.
84 changes: 71 additions & 13 deletions packages/circuit-ui/components/Tooltip/Tooltip.module.css
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
.base {
/* The arrow should be 8px tall. A square element is rotated to achieve a triangular shape. Using Pythagoras' theorem, we can calculate the ratio between the triangle height and the square sides: √(8^2 + 8^2) / 8 ≈ 1.414 */
--tooltip-arrow-size: calc(var(--cui-spacings-byte) * 1.414);
--tooltip-offset: var(--cui-spacings-kilo);

position: absolute;
bottom: calc(100% + 16px); /* 8px (arrow size) + 4px (offset) */
left: 50%;
z-index: var(--cui-z-index-tooltip);
pointer-events: none;
opacity: 0;
transition: opacity var(--cui-transitions-default);
}

.base[data-state="initial"][data-side="top"] {
bottom: calc(100% + var(--tooltip-offset));
left: 50%;
transform: translateX(-50%);
}

.base[data-state="initial"][data-side="left"] {
top: 50%;
right: 100%;
transform: translateY(-50%);
}

.base[data-state="initial"][data-side="bottom"] {
top: calc(100% + var(--tooltip-offset));
left: 50%;
transform: translateX(-50%);
}

.base[data-state="initial"][data-side="right"] {
top: 50%;
left: 100%;
transform: translateY(-50%);
}

.base[data-state="open"],
.base:hover,
.component:hover + .base,
Expand All @@ -16,32 +43,63 @@
transition-delay: 1s;
}

.base[data-state="open"] {
bottom: auto;
left: auto;
}

.base,
.base[data-state="closed"]:hover,
.component:hover + .base[data-state="closed"],
.component:focus + .base[data-state="closed"] {
pointer-events: none;
opacity: 0;
transition-delay: 0s;
}

/* We use padding instead of Floating UI's `offset` middleware to enable users
to hover over the tooltip without dismissing it. */
.base[data-side="top"] {
padding-bottom: var(--tooltip-offset);
}

.base[data-side="right"] {
padding-left: var(--tooltip-offset);
}

.base[data-side="bottom"] {
padding-top: var(--tooltip-offset);
}

.base[data-side="left"] {
padding-right: var(--tooltip-offset);
}

.arrow {
position: absolute;
width: var(--cui-spacings-kilo);
height: var(--cui-spacings-kilo);
width: var(--tooltip-arrow-size);
height: var(--tooltip-arrow-size);
background-color: var(--cui-bg-elevated);
border-right: var(--cui-border-width-kilo) solid var(--cui-border-subtle);
border-bottom: var(--cui-border-width-kilo) solid var(--cui-border-subtle);
border-bottom-right-radius: 2px;
}

[data-state="closed"] .arrow {
display: none;
.base[data-side="top"] .arrow {
top: calc(100% - var(--tooltip-offset) - (var(--tooltip-arrow-size) / 2));
left: calc(50% - (var(--tooltip-arrow-size) / 2));
transform: rotate(45deg);
}

.base[data-side="right"] .arrow {
right: calc(100% - var(--tooltip-offset) - (var(--tooltip-arrow-size) / 2));
bottom: calc(50% - (var(--tooltip-arrow-size) / 2));
transform: rotate(135deg);
}

.base[data-side="bottom"] .arrow {
bottom: calc(100% - var(--tooltip-offset) - (var(--tooltip-arrow-size) / 2));
left: calc(50% - (var(--tooltip-arrow-size) / 2));
transform: rotate(225deg);
}

.base[data-side="left"] .arrow {
bottom: calc(50% - (var(--tooltip-arrow-size) / 2));
left: calc(100% - var(--tooltip-offset) - (var(--tooltip-arrow-size) / 2));
transform: rotate(315deg);
}

.content {
Expand Down
52 changes: 23 additions & 29 deletions packages/circuit-ui/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,21 @@ import {
useFloating,
arrow,
flip,
offset,
shift,
type Placement,
type Side,
} from '@floating-ui/react-dom';
import { atom, onMount } from 'nanostores';
import { atom } from 'nanostores';
import { useStore } from '@nanostores/react';

import { clsx } from '../../styles/clsx.js';
import { applyMultipleRefs } from '../../util/refs.js';
import { useEscapeKey } from '../../hooks/useEscapeKey/index.js';
import { CircuitError } from '../../util/errors.js';
import {
AccessibilityError,
CircuitError,
isSufficientlyLabelled,
} from '../../util/errors.js';

import classes from './Tooltip.module.css';

Expand All @@ -64,7 +67,7 @@ export interface TooltipProps extends HTMLAttributes<HTMLDivElement> {
*/
label: string;
/**
* The focusable element that is labelled by the tooltip.
* The focusable element that acts as the reference for the tooltip.
*/
component: ComponentType<TooltipReferenceProps>;
/**
Expand All @@ -80,24 +83,7 @@ export interface TooltipProps extends HTMLAttributes<HTMLDivElement> {
placement?: Placement;
}

const ARROW_ROTATION_MAP: Record<Side, `${number}deg`> = {
top: '45deg',
right: '135deg',
bottom: '225deg',
left: '315deg',
};

export const $activeTooltipId = atom<string | null>('initial');

// The tooltip works without JavaScript using only CSS (the "initial" state).
// When JS is available, the component is progressively enhanced and toggles
// between the "closed" and "open" states.
onMount($activeTooltipId, () => {
$activeTooltipId.set(null);
return () => {
$activeTooltipId.set('initial');
};
});
const $activeTooltipId = atom<string | null>('initial');

enum State {
initial = 'initial',
Expand Down Expand Up @@ -152,8 +138,6 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
open: state === State.open,
placement: defaultPlacement,
middleware: [
// 8px (arrow size) + 4px (actual offset)
offset(12),
flip(),
shift(),
arrow({
Expand Down Expand Up @@ -193,6 +177,16 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
throw new CircuitError('Tooltip', 'The `type` prop is required.');
}

if (
process.env.NODE_ENV !== 'production' &&
!isSufficientlyLabelled(label)
) {
throw new AccessibilityError(
'Tooltip',
'The `label` prop is missing or invalid.',
);
}

const referenceProps = {
[type === 'label' ? 'aria-labelledby' : 'aria-describedby']: tooltipId,
};
Expand All @@ -218,19 +212,19 @@ export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
data-state={state}
data-side={side}
className={clsx(classes.base, className)}
style={{ ...style, ...floatingStyles }}
style={
state === State.initial ? style : { ...style, ...floatingStyles }
}
>
<div className={classes.content}>{label}</div>
<div
ref={arrowRef}
className={classes.arrow}
// @ts-expect-error The dynamic style rules are valid.
style={{
left: middlewareData.arrow?.x,
top: middlewareData.arrow?.y,
[side]: 'calc(100% - (var(--cui-spacings-kilo) / 2))',
transform: `rotate(${ARROW_ROTATION_MAP[side]})`,
left: middlewareData.arrow?.x,
}}
/>
</div>
Expand Down

0 comments on commit 2c3d96b

Please sign in to comment.