Skip to content
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
94 changes: 94 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# react-tip-magic

A React tooltip library with a guided-tour hook (`useTour`) and a keyboard-shortcut
discovery menu (`TipAdvisor`). One tooltip element is rendered at a time and moved
between targets; tours reuse that same element as their panel.

## Commands

```bash
npm run validate # typecheck + lint + format:check + test - run before every commit
npm test # vitest, jsdom
npm run dev # Storybook on :6006
npm run build # library (dist/index.mjs, .cjs, styles.css)
```

## Layout

| Path | Holds |
| -------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `src/components/` | React components. `Tooltip` renders the single tooltip; the rest are internals mounted by `TipMagicProvider`. |
| `src/hooks/` | Public hooks and the API objects behind `useTipMagic()`. |
| `src/hooks/useTour/utils/` | Tour helpers: DOM managers (classes) and pure functions. |
| `src/utils/` | Pure helpers shared across components. Tests in `__tests__/` beside them. |
| `src/types/` | All public types. `tour.ts` re-exports through `index.ts`. |
| `src/styles/` | One stylesheet per concern, all imported by `index.css`. |
| `src/*.mdx` | The docs published to GitHub Pages. Updating the README does not reach them. |

## Conventions

- **Class names and data attributes live in constants**, never inline strings:
`CSS_CLASSES` and `PRIMARY_ACTION_ATTRIBUTE` in `src/constants/`, `TOUR_CSS_CLASSES`
and `TOUR_DATA_ATTRIBUTES` in `src/hooks/useTour/constants.ts`. One name per value -
don't re-export a constant under a second name.
- **Pure logic goes in a `utils` module with its own test**, not inline in a component.
`tooltipStyles`, `groupCompatibility` and `autoFocusTarget` are the pattern.
- Options flow `TooltipShowOptions` → `ParsedTooltipData` → `Tooltip`, merged in
`useTooltipAPI` with the `...(options.x !== undefined && { x: options.x })` idiom.
- `DATA_ATTRIBUTES` in `src/constants/index.ts` is dead - it is documented as unused and
nothing reads it. Don't add live values to it.
- Comments are for things the code cannot say. Gotchas belong in this file.

## Releasing

No changesets. Bump `package.json` (`npm version <v> --no-git-tag-version`) and merge:
`publish.yml` asks the registry whether that version exists, so a failed publish retries
on the next push rather than stranding the bump.

## Gotchas

**`dangerouslySetInnerHTML` is re-applied on every render.** React rewrites the subtree
even when the html string is byte-identical. Anything focused inside it is dropped to
`<body>`, an `<img>` or autoplaying `<video>` is recreated and restarts, and a tour
panel's markup is re-parsed for nothing on every position or visibility update. This is
why `Tooltip` renders html through a memoised `HtmlContent`. Don't inline it back.

**`Node.contains` includes the node itself.** `panel.contains(document.activeElement)` is
true when the panel holds focus, so a containment check can never decide whether to focus
something _inside_ the panel. `resolveAutoFocusTarget` guards on identity for `'primary'`
and on containment only for `'panel'`, which is what keeps the default from stealing focus
back off a control the user tabbed to.

**`DOMTokenList.add` re-sets the `class` attribute even for a token already present.**
With a `MutationObserver` watching `class`, an unguarded re-apply notifies the observer
that called it - an unbroken microtask loop that starves the event loop, so even test
timeouts never fire. `BackdropManager` and `HighlightManager` only write when something
is actually missing.

**React rewrites `class` wholesale, so library state cannot live in a class.** A CSS-in-JS
theme switch produces a new generated hash and drops any class the library added. Tour
elevation therefore rides on `data-tip-magic-focus` / `data-tip-magic-elevated`; the
matching classes are kept only as styling hooks. A consumer's `highlightClass` has to stay
a class, so `TargetWatcher` re-applies it - and only registers that observer when such a
class exists.

**The tour panel is a non-modal dialog.** `role="dialog"` with no `aria-modal` and no
focus trap, deliberately: the app behind stays interactive and Escape exits. Don't add a
trap without revisiting that decision.

**The tour panel's primary action is `data-tip-magic-primary`.** Never mark the close
button - `autoFocus: 'primary'` would put Enter on "end the tour". With `showControls`
off, Close is the panel's only button, which is why the fallback is _marked primary →
panel_ and never "first focusable".

**Tour step `content` is injected as HTML.** `useTour` forces `html: true` whenever a step
has navigation features, and `showClose` defaults to true, so in practice every step goes
through `dangerouslySetInnerHTML`. Escape interpolations with the exported `escapeHtml`,
or use `TourStep.text`, which the library escapes.

**`onStepChange` fires before the panel exists on the first step.** It is called from
inside `start()`, so a consumer cannot use it to reach into the rendered panel.

**jsdom cannot answer two things**, so don't claim them from a passing test: it runs no
transitions (`transitionBehavior: 'move'` timing) and has no `:focus-visible` heuristic
(whether a programmatic focus draws a ring). Both need a browser.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,20 @@ if (tour.start()) {

Recovery depends on the direction of travel: `next()` skips or ends the tour, while `prev()` and `goTo()` leave it where it is — the step on screen is still fine. `goTo()` never skips, so it lands on the step you asked for or returns `false`.

### Where focus lands

A tour panel is a dialog, so focus moves into it when a step opens. `autoFocus` picks which element inside it:

```tsx
const tour = useTour({
steps,
// 'panel' (default) | 'primary' — Next/Finish, so Enter advances | false — leave focus alone
navigation: { showControls: true, autoFocus: 'primary' },
});
```

`'primary'` falls back to the panel when a step renders no Next/Finish button — never to the close button. Overridable per step via `step.navigation.autoFocus`.

Tours also support progress indicators, keyboard support, and backdrop highlighting (`focus: true`, or `focus: { dismissOnClick: true }` to let a click on the backdrop end the tour)—all configurable to fit your needs.

---
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@galangel/react-tip-magic",
"version": "1.2.2",
"version": "1.3.0",
"description": "A sophisticated, elegant, and performant tooltip library for React with an intelligent floating helper system.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
75 changes: 74 additions & 1 deletion src/Flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -380,11 +380,79 @@ navigation: {
nextLabel: 'Next', // Custom label for Next button
backLabel: 'Back', // Custom label for Back button
finishLabel: 'Finish', // Label for last step's button
autoFocus: 'panel', // Where focus lands: 'panel' | 'primary' | false
}
```

When `showControls` is enabled, navigation buttons are rendered inside the tooltip automatically.

### Where focus lands

A tour panel is a `role="dialog"`, so focus moves into it when a step opens — otherwise a
screen reader is never told it appeared, and Escape would not be reachable.
`autoFocus` controls which element inside it receives that focus.

<table>
<thead>
<tr>
<th>Value</th>
<th>Focus lands on</th>
<th>Pressing Enter</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>'panel'</code> (default)
</td>
<td>The tour panel itself</td>
<td>Nothing, until the user Tabs to a control</td>
</tr>
<tr>
<td>
<code>'primary'</code>
</td>
<td>Next, or Finish on the last step</td>
<td>Advances the tour</td>
</tr>
<tr>
<td>
<code>false</code>
</td>
<td>Nothing — focus is left where it was</td>
<td>Whatever the page already did</td>
</tr>
</tbody>
</table>

```tsx
// A read-and-advance tour: Enter goes to the next step
const tour = useTour({
steps,
navigation: { showControls: true, autoFocus: 'primary' },
});
```

`'primary'` falls back to the panel when a step renders no primary action — with
`showControls` off, for example. It never falls back to the close button, since Enter on
that would end the tour.

`false` is an escape hatch for consumers moving focus themselves. It gives up the dialog
announcement and leaves focus behind the panel, so prefer `'panel'` unless you have a
reason.

Like the rest of `navigation`, it can be overridden per step:

```tsx
steps: [
{ target: 'intro', content: 'Read this first', navigation: { autoFocus: 'panel' } },
{ target: 'action', content: 'Now press Enter', navigation: { autoFocus: 'primary' } },
];
```

Steps that render as a plain tooltip rather than a dialog — no navigation controls, close
button, media or progress — never move focus, whatever `autoFocus` is set to.

---

## Progress Options
Expand Down Expand Up @@ -644,7 +712,12 @@ Enable focus mode on key steps to draw attention without overwhelming users.
- Tooltips are keyboard accessible
- Respects `prefers-reduced-motion` for animations
- Close button has proper ARIA labels
- Focus is managed appropriately during the tour
- The tour panel is a `role="dialog"` labelled by the step title, and focus moves into it
when a step opens so it is announced. Pick which element receives that focus with
[`navigation.autoFocus`](#where-focus-lands)
- The panel is deliberately non-modal: the app behind it stays interactive and there is no
focus trap, so `Escape` always exits
- Focus returns to whatever held it before the tour when the tour ends

---

Expand Down
32 changes: 25 additions & 7 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,28 @@ import {
useFloating,
type Placement,
} from '@floating-ui/react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ANIMATION, CSS_CLASSES } from '../../constants';
import { useTipMagicContext } from '../../context/TipMagicContext';
import type { TooltipTransitionBehavior } from '../../types';
import { resolveAutoFocusTarget } from '../../utils/autoFocusTarget';
import { areGroupsCompatible, shouldAnimatePosition } from '../../utils/groupCompatibility';
import {
buildTooltipClassNames,
getArrowStaticSide,
getArrowStyles,
} from '../../utils/tooltipStyles';

/**
* Content rendered from an HTML string.
*
* Memoised on `html` so the subtree survives re-renders - see CLAUDE.md,
* "dangerouslySetInnerHTML is re-applied on every render".
*/
const HtmlContent = memo(function HtmlContent({ html }: { html: string }) {
return <span className={CSS_CLASSES.TOOLTIP_TEXT} dangerouslySetInnerHTML={{ __html: html }} />;
});

/**
* Main Tooltip component - renders a single tooltip instance
* that moves between targets for smooth transitions
Expand Down Expand Up @@ -118,16 +129,23 @@ export function Tooltip() {
};
}, [isDialogOpen]);

// Move focus into the panel on open and on every content change (a tour step change),
// so the new controls are reachable
const autoFocus = tooltip.parsedData?.autoFocus ?? 'panel';
useEffect(() => {
if (!isDialogOpen || !isPositioned) return;

const panel = refs.floating.current;
if (panel && !panel.contains(document.activeElement)) {
if (!panel) return;

const target = resolveAutoFocusTarget(panel, autoFocus, document.activeElement);
if (!target) return;

target.focus({ preventScroll: true });

const focusLanded = panel.contains(document.activeElement);
if (!focusLanded) {
panel.focus({ preventScroll: true });
}
}, [isDialogOpen, isPositioned, tooltip.content, refs]);
}, [isDialogOpen, isPositioned, tooltip.content, refs, autoFocus]);

// Handle transition end - only clear transitioning state when transform finishes
// (or opacity if not animating position)
Expand Down Expand Up @@ -209,9 +227,9 @@ export function Tooltip() {
>
<div className={CSS_CLASSES.TOOLTIP_CONTENT}>
{isHtmlContent ? (
<span className="tip-magic-text" dangerouslySetInnerHTML={{ __html: mainContent }} />
<HtmlContent html={mainContent} />
) : (
<span className="tip-magic-text">{mainContent}</span>
<span className={CSS_CLASSES.TOOLTIP_TEXT}>{mainContent}</span>
)}
{shortcut && config.enableShortcutStyle && (
<kbd className={CSS_CLASSES.TOOLTIP_SHORTCUT}>{shortcut}</kbd>
Expand Down
55 changes: 55 additions & 0 deletions src/components/Tooltip/__tests__/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,58 @@ describe('Tooltip', () => {
});
});
});

describe('HTML content is not rebuilt on every re-render', () => {
const HTML =
'<div class="tip-magic-tour-content">' +
'<button type="button" data-tip-magic-primary>Next</button>' +
'<img id="media" src="clip.gif" alt="" />' +
'</div>';

function htmlState(content: string): TipMagicState {
const base = createMockState().tooltip.parsedData as ParsedTooltipData;
return createMockState({ content, parsedData: { ...base, content, html: true } });
}

function renderHtml(content: string) {
const { rerender } = renderTooltip(htmlState(content));
return (next: string) =>
rerender(
<TipMagicContext.Provider value={{ state: htmlState(next), dispatch: () => {} }}>
<Tooltip />
</TipMagicContext.Provider>
);
}

it('keeps the same nodes when a re-render does not change the content', () => {
const rerenderWith = renderHtml(HTML);
const button = document.querySelector('[data-tip-magic-primary]');
const media = document.getElementById('media');
expect(button).not.toBeNull();

rerenderWith(HTML);

expect(document.querySelector('[data-tip-magic-primary]')).toBe(button);
expect(document.getElementById('media')).toBe(media);
});

it('keeps focus that is inside the content', () => {
const rerenderWith = renderHtml(HTML);
const button = document.querySelector('[data-tip-magic-primary]') as HTMLElement;
button.focus();

rerenderWith(HTML);

expect(document.activeElement).toBe(button);
});

it('still rebuilds when the content actually changes', () => {
const rerenderWith = renderHtml(HTML);
expect(document.getElementById('media')).not.toBeNull();

rerenderWith('<div class="tip-magic-tour-content"><p id="plain">Step two</p></div>');

expect(document.getElementById('plain')?.textContent).toBe('Step two');
expect(document.getElementById('media')).toBeNull();
});
});
6 changes: 6 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const DATA_ATTRIBUTES = {
export const CSS_CLASSES = {
TOOLTIP: 'tip-magic-tooltip',
TOOLTIP_CONTENT: 'tip-magic-content',
TOOLTIP_TEXT: 'tip-magic-text',
TOOLTIP_ARROW: 'tip-magic-arrow',
TOOLTIP_SHORTCUT: 'tip-magic-shortcut',
TOOLTIP_VISIBLE: 'tip-magic-visible',
Expand All @@ -67,6 +68,11 @@ export const CSS_CLASSES = {
HIGHLIGHT: 'tip-magic-highlight',
} as const;

/**
* Marks the primary action inside a tooltip's content, for `autoFocus: 'primary'`
*/
export const PRIMARY_ACTION_ATTRIBUTE = 'data-tip-magic-primary';

/**
* Default selector for tooltip targets
*/
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useTooltipAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function useTooltipAPI(
...(options.shortcut !== undefined && { shortcut: options.shortcut }),
...(options.role !== undefined && { role: options.role }),
...(options.ariaLabelledBy !== undefined && { ariaLabelledBy: options.ariaLabelledBy }),
...(options.autoFocus !== undefined && { autoFocus: options.autoFocus }),
};

if (state.tooltip.visible) {
Expand Down
Loading
Loading