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
2 changes: 1 addition & 1 deletion docs/docs/guidelines/_category_.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"label": "📏 Guidelines",
"label": "Guidelines",
"position": 4,
"link": {
"type": "generated-index",
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/project-scripts/_category_.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"label": "Project Scripts",
"label": "Project Scripts",
"position": 5,
"link": {
"type": "generated-index",
Expand Down
133 changes: 133 additions & 0 deletions docs/docs/reference/react/components/Tooltip/index.md
Comment thread
Ryan-Millard marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
title: Tooltip.jsx
---

**What this file covers (quick):**
- How to use the `Tooltip` component
- Props & defaults
- Accessibility
- Short implementation caveats

## Dependencies
- [`react-tooltip`](https://www.npmjs.com/package/react-tooltip).

## Basic usage
```jsx
import Tooltip from '@components/Tooltip'

export default function Example() {
return (
<Tooltip content="Helpful hint">
<button>Hover or focus me</button>
</Tooltip>
)
}
```

The `Tooltip` will attach the attributes `data-tooltip-id` and `data-tooltip-content` to the element you pass as `children` when possible.
If you pass a non-element child (plain text or multiple nodes), the component wraps them in a focusable `<span tabIndex={0}>`
so keyboard users can discover the tooltip. The actual tooltip element is rendered by `react-tooltip` and appended to `document.body` as a portal.

## Props
| Prop | Type | Required | Default | Notes |
| -------------------- | -------: | -------: | --------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `content` | `string` | Yes | | Text shown inside the tooltip. Keep it short - tooltips are for hints. |
| `children` | `node` | Yes | | Element that triggers the tooltip. Prefer a single React element (`<button>`, `<a>`, `<Link>`, etc.). If a non-element is passed (string / fragment) the component wraps it in a focusable `<span>`. |
| `id` | `string` | No | generated | Optional ID to control multiple tooltips. |
| `dynamicPositioning` | `bool` | No | `true` | When `true`, fallback placements `['bottom','top','left']` will be tried if the preferred placement (`place="right"`) doesn't fit. When `false` no fallbacks are provided. |
:::note
`children` remains typed as `node` so you can pass text, small fragments or an element,
but the component behaves best when given **a single element** so attributes can be attached directly.
:::

## Accessibility & Link behaviour
- `react-tooltip` renders a node with `role="tooltip"`; screen-readers can discover the tooltip content through that node.
- **Keyboard support:** the component enables focus-triggered tooltips using `openOnFocus`.
For the tooltip to open on keyboard navigation, the element that receives focus must be the same element the tooltip is attached to:
- If you pass a focusable element such as `<button>`, `<a>`, or a `<Link>` component,
`Tooltip` will attach the necessary attributes to that element and `openOnFocus` will work as expected.
- If you pass plain text or a non-focusable element, `Tooltip` will wrap it in a `<span tabIndex={0}>` so it becomes keyboard focusable.
- **Important:** Do **not** wrap a focusable child inside an extra `tabIndex={0}` element - this creates two tab stops (double focus).
Prefer giving the tooltip attributes directly to the interactive element.
For example, wrap the `<a>` with `Tooltip` rather than putting `Tooltip` inside the `<a>` with a nested focusable wrapper.

### Good: attach tooltip to the interactive element
```jsx
<Tooltip content="Open project on GitHub (opens in new tab)">
<a href="https://github.com/..." target="_blank" rel="noopener noreferrer">
GitHub
</a>
</Tooltip>
```

### Bad: wrapping the interactive element inside a focusable wrapper (creates duplicate focus targets)
```jsx
/* avoid this */
<a href="..." target="_blank" rel="noopener noreferrer">
<Tooltip content="...">
<span>GitHub</span> {/* the span might be focusable and compete with the link */}
</Tooltip>
</a>
```

## Implementation notes (what the component does)
- The component tries to attach `data-tooltip-id` and `data-tooltip-content` directly to the single React child you pass by cloning it.
This preserves semantics for `<a>`, `<button>` and `<Link>` components and avoids double tab stops.
If `children` is not a valid single element, the component renders a `<span tabIndex={0}>` wrapper and attaches the attributes there.
- `appendTo={document.body}` and `positionStrategy="fixed"` - the tooltip is rendered as a portal to the document body so it sits above layout and isn’t clipped by scroll/overflow.
- `useId()` is used to generate a stable id at runtime; you can pass your own `id` prop if you need deterministic IDs.
- `dynamicPositioning` default `true` provides fallback placements when the preferred placement doesn't fit. Set to `false` to force a single placement.
- `openOnFocus` is enabled so keyboard users can open the tooltip when the trigger element receives focus. Make sure the trigger element is focusable (native element or wrapper with `tabIndex={0}`).
- Styling & animations: `react-tooltip` adds classes and may apply show/hide transitions. If you change global CSS or reset transitions you may affect tooltip visibility timing and tests.

## Examples
```jsx title="Button (works out of the box)"
<Tooltip content="Do the thing">
<button type="button">Action</button>
</Tooltip>
```
```jsx title="Internal navigation (react-router Link)"
<Tooltip content="Go to profile">
<Link to="/profile">Profile</Link>
</Tooltip>
```
```jsx title="External link (attach tooltip to the <a> itself — target & rel recommended)"
<Tooltip content="Open on GitHub (opens in a new tab)">
<a href="https://github.com/..." target="_blank" rel="noopener noreferrer">GitHub</a>
</Tooltip>
```
```jsx title="Plain text / complex non-focusable nodes (gets wrapped in a focusable span)"
<Tooltip content="Short hint">
Some inline text or an icon-only element
</Tooltip>
```

## Testing tips
- `react-tooltip` mounts the tooltip node into `document.body`.
In the `jsdom` environment `screen` queries will still find it.
- Portal timing & animations can make tests flaky.
Wrap assertions in `await waitFor()` or use `findBy*` queries which retry until the element appears. Example patterns:

```js title="Hover"
await user.hover(screen.getByText('Hover me'))
expect(await screen.findByText('Hello tooltip')).toBeVisible()
```
```js title="Focus"
await user.tab()
expect(await screen.findByText('Hello tooltip')).toBeVisible()
```
```js title="Hide with waitFor to accommodate transition"
await waitFor(() => expect(screen.queryByText('Hello tooltip')).not.toBeInTheDocument())
```
- In tests prefer passing an actual element as `children` (button, Link, or anchor)
so the library attributes are attached directly and keyboard focus works reliably.
If you need to assert behavior for plain text triggers, test the wrapped `<span>` behavior explicitly.
- If you see intermittent failures due to CSS transitions, disable transitions in your test setup
(for example, add a small global CSS rule to turn off transitions during tests) - this makes timing deterministic.

## Summary
- The `Tooltip` prefers to attach attributes directly to a single React child (preserves semantics for `<a>`, `<button>`, `<Link>`).
- If you pass non-element children, the component wraps them in a focusable `<span>` so keyboard users can reveal the tooltip.
- For external links (anchors), wrap the anchor with `Tooltip` (so tooltip attributes are attached to the anchor) -
do **not** make an extra focusable wrapper inside the anchor.
- `openOnFocus` + focusable trigger = keyboard-accessible tooltip.
44 changes: 44 additions & 0 deletions docs/docs/reference/react/components/Tooltip/tests.md
Comment thread
Ryan-Millard marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: Tests
---

This page documents the Vitest test suite for the `Tooltip` component and provides guidance for writing reliable tests,
including for keyboard accessibility and portal behavior.

## Individual Test Explanations
1. **does not show tooltip content by default**
- Ensures the tooltip is not rendered until user interaction.
`react-tooltip` appends the tooltip node to `document.body`, accessible via `screen` queries in jsdom.
2. **shows tooltip on hover**
- `userEvent.hover` triggers pointer events. The tooltip library mounts the tooltip node in response.
3. **hides tooltip when mouse leaves**
- Uses `waitFor()` to accommodate any hide delays or CSS transitions.
4. **shows tooltip on keyboard focus**
- Tests accessibility: the tooltip appears when the trigger element receives keyboard focus.
5. **hides tooltip when focus leaves**
- Confirms the tooltip hides when the trigger loses focus. `waitFor()` ensures the test accounts for any hide transition delays.

## Common Flakiness and Fixes
1. **Portal timing / animations**
- `react-tooltip` may delay mounting/unmounting for animations.
Wrap visibility assertions in `await waitFor()` or use `findBy*` queries to retry until the tooltip appears/disappears:
```js
expect(await screen.findByText('Hello tooltip')).toBeVisible()
await waitFor(() => expect(screen.queryByText('Hello tooltip')).not.toBeInTheDocument())
```
- You can also disable CSS transitions in test setup for deterministic results.
2. **Missing jsdom environment**
- `Tooltip` renders portals to `document.body`. Ensure Vitest is running with `environment: 'jsdom'`.
3. **Event ordering**
- Always use `userEvent.setup()` and `await` interactions. Avoid manually firing low-level events unless necessary.
4. **Double focus / tab issues**
- When testing tooltips on interactive elements, pass a single focusable element (e.g., `<button>` or `<Link>`)
as `children`. If the component wraps non-element children in a `<span tabIndex={0}>`, account for that extra tab stop in tests.
5. **React strict mode / act warnings**
- Vitest + Testing Library usually handles `act()` automatically. Ensure `globals: true` in your test setup and keep dependencies updated.

## Testing Recommendations
- Always test tooltips attached to **real interactive elements** (buttons, links, `<Link>` from react-router-dom) to avoid duplicate tab stops.
- When testing external links or non-focusable nodes, account for the optional wrapper `<span tabIndex={0}>`.
- Use `findBy*` for asserting tooltip visibility; it retries until the tooltip appears.
- Wrap hide assertions in `waitFor()` to accommodate CSS transitions or delayed unmounts.
10 changes: 10 additions & 0 deletions docs/docs/reference/react/components/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"label": "Components",
"position": 1,
"link": {
"type": "generated-index",
"title": "React Components",
"description": "Reference for React components in Img2Num",
"slug": "/reference/react/components"
},
}
Comment thread
Ryan-Millard marked this conversation as resolved.
48 changes: 47 additions & 1 deletion package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-helmet": "^6.1.0",
"react-router-dom": "^7.10.1"
"react-router-dom": "^7.10.1",
"react-tooltip": "^5.30.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
Expand Down
54 changes: 36 additions & 18 deletions src/components/NavBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,65 @@ import { SquareArrowOutUpRight } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import styles from './NavBar.module.css';
import GlassCard from '@components/GlassCard';
import Tooltip from '@components/Tooltip';

export default function NavBar() {
const [isOpen, setIsOpen] = useState(false);
const location = useLocation();

const links = [
{ path: '/', label: 'Home' },
{ path: '/credits', label: 'Credits' },
{ path: '/about', label: 'About' },
{ path: 'https://github.com/Ryan-Millard/Img2Num', label: 'GitHub', external: true },
{ path: '/', label: 'Home', tooltip: 'Go to the home page' },
{ path: '/credits', label: 'Credits', tooltip: 'View project credits' },
{ path: '/about', label: 'About', tooltip: 'Learn more about Img2Num' },
{ path: 'https://github.com/Ryan-Millard/Img2Num', label: 'GitHub', tooltip: 'Open the project on GitHub', external: true },
];


const renderLinks = links.map((link) => {
const isActive = !link.external && location.pathname === link.path; // active if route matches
const isActive = !link.external && location.pathname === link.path;
return (
<li key={link.label}>
{link.external ? (
<a href={link.path} target="_blank" rel="noopener noreferrer" className={styles.externalLink}>
{link.label}
<SquareArrowOutUpRight size={'1.25em'} className={styles.externalLinkIcon} />
</a>
<Tooltip content={`${link.tooltip} (opens in a new tab)`}>
<a
href={link.path}
target="_blank"
rel="noopener noreferrer"
className={styles.externalLink}
>
{link.label}
<SquareArrowOutUpRight size="1.25em" className={styles.externalLinkIcon} />
</a>
</Tooltip>
) : (
<Link to={link.path} className={isActive ? styles.activeLink : ''}>
{link.label}
</Link>
<Tooltip content={link.tooltip}>
<Link to={link.path} className={isActive ? styles.activeLink : ''}>
{link.label}
</Link>
</Tooltip>
)}
</li>

);
});


return (
<GlassCard as="nav" className={styles.navbar}>
<div className={styles.logo}>
<Link to="/">Img2Num</Link>
<Tooltip content="Go to home page">
<Link to="/">Img2Num</Link>
</Tooltip>
</div>

<button className={styles.hamburger} onClick={() => setIsOpen(!isOpen)} aria-label="Toggle menu">
<span className={isOpen ? styles.barActive : styles.bar}></span>
<span className={isOpen ? styles.barActive : styles.bar}></span>
<span className={isOpen ? styles.barActive : styles.bar}></span>
</button>
<Tooltip content="Toggle navigation menu">
<button className={styles.hamburger} onClick={() => setIsOpen(!isOpen)} aria-label="Toggle menu">
<span className={isOpen ? styles.barActive : styles.bar}></span>
<span className={isOpen ? styles.barActive : styles.bar}></span>
<span className={isOpen ? styles.barActive : styles.bar}></span>
</button>
</Tooltip>


{isOpen ? (
<GlassCard as="ul" className={`${styles.navLinks} ${isOpen ? styles.active : ''} ${isOpen ? 'stacked' : ''}`}>
Expand Down
Loading