Skip to content

Conversation

chardin1
Copy link

@chardin1 chardin1 commented Sep 17, 2025

Automatically applies aria-describedby to the active anchor when the tooltip is visible, but only if an id has been provided to <Tooltip />

Summary by CodeRabbit

  • New Features

    • Multiple, separately identified tooltips with selector-based anchoring and configurable open/close triggers (e.g., click).
    • Imperative control for specific tooltips, enabling precise show/hide behavior.
  • Accessibility

    • Improved screen reader behavior by ensuring tooltip references (aria-describedby) update correctly when tooltips open, close, or move between anchors.

Copy link

coderabbitai bot commented Sep 17, 2025

Walkthrough

Adds multiple identified Tooltips and anchorSelect-based anchoring in App; extends Tooltip API (id, anchorSelect, event controls) and introduces previousActiveAnchor tracking with an effect that manages aria-describedby on anchors when tooltips show/hide.

Changes

Cohort / File(s) Summary
App Tooltip usage updates
src/App.tsx
Replaces a single bottom tooltip with multiple identified tooltips (id="button1", id="button2", id="float-tooltip", id="onclick-tooltip", id="tooltip-content"); adds anchorSelect, variant, event-based openEvents/closeEvents/globalCloseEvents, and an imperative ref usage.
Tooltip core logic & a11y
src/components/Tooltip/Tooltip.tsx
Adds previousActiveAnchor prop and an effect to manage aria-describedby on anchors when tooltip show/hide changes, removing the id from previous anchor and updating current anchor (deduped).
Tooltip types
src/components/Tooltip/TooltipTypes.d.ts
Adds `previousActiveAnchor: HTMLElement
TooltipController state wiring
src/components/TooltipController/TooltipController.tsx
Introduces previousActiveAnchorRef, updates it when activeAnchor changes (using isSameNode), and passes previousActiveAnchor through to Tooltip props.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User
  participant App as App.tsx
  participant TC as TooltipController
  participant TT as Tooltip
  participant A as ActiveAnchor (HTMLElement)
  participant PA as PreviousAnchor (HTMLElement)

  U->>App: Trigger open/close (click/hover)
  App->>TC: setActiveAnchor(newAnchor)
  alt anchor changed
    TC->>TC: previousActiveAnchorRef = activeAnchor
    TC->>TC: activeAnchor = newAnchor
  end
  TC->>TT: render({ activeAnchor, previousActiveAnchor, id, anchorSelect, openEvents, closeEvents })

  rect rgba(200,230,255,0.25)
  note right of TT: on show -> manage aria-describedby
  TT->>PA: remove tooltip id from aria-describedby (if present)
  TT->>A: add tooltip id to aria-describedby (deduped)
  end

  U->>TT: close event (per closeEvents/globalCloseEvents)
  TT->>A: remove tooltip id from aria-describedby
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

Feature, Awaiting merge

Suggested reviewers

  • gabrieljablonski
  • danielbarion

Poem

I nudge a node, then hop to next—so spry!
Aria whispers follow where I fly.
Two tips, two anchors, click—now show!
A gentle close when breezes blow.
Previous, active—hand in paw—A tidy DOM is bunny law. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "Add aria-describedby automatically" succinctly and accurately summarizes the primary change described in the PR: automatically applying aria-describedby to the active anchor when a Tooltip (with an id) is shown. It is concise, clear, and focused; while the changeset includes additional Tooltip API additions, the title highlights the main accessibility improvement reviewers need to notice.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 37cab51 and c208987.

📒 Files selected for processing (1)
  • src/components/Tooltip/Tooltip.tsx (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/Tooltip/Tooltip.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Tooltip/Tooltip.tsx (1)

66-74: Default previousActiveAnchor to null to keep prop optional.

Pairs with making it optional in types; preserves backward compatibility for direct <Tooltip /> use.

-  previousActiveAnchor,
+  previousActiveAnchor = null,
🧹 Nitpick comments (4)
src/components/TooltipController/TooltipController.tsx (1)

379-385: Use functional state update to avoid stale activeAnchor in closure.

Ensures previousActiveAnchorRef always sees the true previous anchor.

-      setActiveAnchor: (anchor: HTMLElement | null) => {
-        if (!anchor?.isSameNode(activeAnchor)) {
-          previousActiveAnchorRef.current = activeAnchor
-        }
-        setActiveAnchor(anchor)
-      },
+      setActiveAnchor: (anchor: HTMLElement | null) => {
+        setActiveAnchor((prev) => {
+          if (!anchor?.isSameNode(prev)) {
+            previousActiveAnchorRef.current = prev
+          }
+          return anchor
+        })
+      },
src/components/Tooltip/Tooltip.tsx (1)

209-236: Aria cleanup: include id in deps and add unmount cleanup.

  • Missing id in deps can leave stale tokens on id changes.
  • Add cleanup on unmount/hide edge-cases.
-  useEffect(() => {
+  useEffect(() => {
     if (!id) return
@@
-    if (show) {
+    if (show) {
       removeAriaDescribedBy(previousActiveAnchor)
       const currentDescribedBy = getAriaDescribedBy(activeAnchor)
       const describedBy = [...new Set([...currentDescribedBy, id])].filter(Boolean).join(' ')
       activeAnchor?.setAttribute('aria-describedby', describedBy)
     } else {
       removeAriaDescribedBy(activeAnchor)
     }
-  }, [activeAnchor, show])
+    return () => {
+      // best-effort cleanup on unmount or dependency change
+      removeAriaDescribedBy(activeAnchor)
+    }
+  }, [activeAnchor, show, id, previousActiveAnchor])

Optional hardening: split on /\s+/ to normalize whitespace.

src/App.tsx (2)

58-64: Avoid conflicting manual aria-describedby on the anchor.

Since the Tooltip now manages it, remove the hardcoded aria-describedby="tooltip" on Line 44 to prevent stale/duplicate tokens.

Outside this hunk, apply:

-        aria-describedby="tooltip"

3-5: Import types from the module, not the .d file.

Avoid coupling to the declaration filename; let TS resolve .d.ts.

Outside this hunk, apply:

-import { IPosition, TooltipRefProps } from 'components/Tooltip/TooltipTypes.d'
+import { IPosition, TooltipRefProps } from 'components/Tooltip/TooltipTypes'
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9faf2c6 and 8878931.

⛔ Files ignored due to path filters (2)
  • src/test/__snapshots__/tooltip-attributes.spec.js.snap is excluded by !**/*.snap
  • src/test/__snapshots__/tooltip-props.spec.js.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • src/App.tsx (4 hunks)
  • src/components/Tooltip/Tooltip.tsx (2 hunks)
  • src/components/Tooltip/TooltipTypes.d.ts (1 hunks)
  • src/components/TooltipController/TooltipController.tsx (2 hunks)
🔇 Additional comments (5)
src/components/TooltipController/TooltipController.tsx (1)

84-84: OK: local ref for previous active anchor.

Ref-based tracking makes sense and avoids extra renders.

src/App.tsx (4)

51-56: Good: assign explicit id to enable aria-describedby automation.

Looks correct with controller wiring.


111-117: LGTM: anchorSelect demo with id and event maps.

Matches the new API and will manage describedby automatically.


139-145: LGTM: id on float tooltip ensures unique describedby token.

All good.


164-168: LGTM: click-positioned tooltip with stable id.

Consistent with the new behavior.

afterShow?: () => void
afterHide?: () => void
disableTooltip?: (anchorRef: HTMLElement | null) => boolean
previousActiveAnchor: HTMLElement | null
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Don’t make controller-only prop public-required; mark optional and internal.

Requiring previousActiveAnchor on ITooltip breaks direct <Tooltip /> usage and leaks internals. Make it optional and annotate as internal.

Apply:

-  previousActiveAnchor: HTMLElement | null
+  /** @internal Controlled by TooltipController. Do not pass manually. */
+  previousActiveAnchor?: HTMLElement | null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
previousActiveAnchor: HTMLElement | null
/** @internal Controlled by TooltipController. Do not pass manually. */
previousActiveAnchor?: HTMLElement | null
🤖 Prompt for AI Agents
In src/components/Tooltip/TooltipTypes.d.ts around line 156, the ITooltip
property previousActiveAnchor is currently required and exposes controller
internals; change its type to be optional (previousActiveAnchor?: HTMLElement |
null) and annotate it as internal (e.g., add an inline comment or JSDoc like /**
@internal */) so it is no longer a public-required prop while preserving type
compatibility for internal controller usage.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/components/Tooltip/Tooltip.tsx (1)

66-74: Is previousActiveAnchor intended to be public? If not, hide it.

Surfacing this controller-only prop on the public ITooltip props leaks internal wiring. If external, document it; otherwise, mark it @internal and exclude from the published .d.ts, or pass via context only.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8878931 and 37cab51.

📒 Files selected for processing (2)
  • src/components/Tooltip/Tooltip.tsx (2 hunks)
  • src/components/TooltipController/TooltipController.tsx (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/TooltipController/TooltipController.tsx

Comment on lines 209 to 237
/**
* Add aria-describedby to activeAnchor when tooltip is active
*/
useEffect(() => {
if (!id) return

function getAriaDescribedBy(element: HTMLElement | null) {
return element?.getAttribute('aria-describedby')?.split(' ') || []
}

function removeAriaDescribedBy(element: HTMLElement | null) {
const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id)
if (newDescribedBy.length) {
element?.setAttribute('aria-describedby', newDescribedBy.join(' '))
} else {
element?.removeAttribute('aria-describedby')
}
}

if (show) {
removeAriaDescribedBy(previousActiveAnchor)
const currentDescribedBy = getAriaDescribedBy(activeAnchor)
const describedBy = [...new Set([...currentDescribedBy, id])].filter(Boolean).join(' ')
activeAnchor?.setAttribute('aria-describedby', describedBy)
} else {
removeAriaDescribedBy(activeAnchor)
}
}, [activeAnchor, show, id, previousActiveAnchor])

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Add cleanup and harden aria-describedby token handling.

  • Missing cleanup can leave stale aria-describedby on anchors (especially if id changes or Tooltip unmounts while visible).
  • .split(' ') mishandles tabs/newlines and multiple spaces; normalize with /\s+/ and filter empties.
  • Skip unnecessary DOM writes and avoid removing from the same node as current.

Apply:

   /**
    * Add aria-describedby to activeAnchor when tooltip is active
    */
   useEffect(() => {
     if (!id) return

-    function getAriaDescribedBy(element: HTMLElement | null) {
-      return element?.getAttribute('aria-describedby')?.split(' ') || []
-    }
+    function getAriaDescribedBy(element: HTMLElement | null) {
+      const raw = element?.getAttribute('aria-describedby') ?? ''
+      return raw.trim().split(/\s+/).filter(Boolean)
+    }

-    function removeAriaDescribedBy(element: HTMLElement | null) {
-      const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id)
-      if (newDescribedBy.length) {
-        element?.setAttribute('aria-describedby', newDescribedBy.join(' '))
-      } else {
-        element?.removeAttribute('aria-describedby')
-      }
-    }
+    function removeAriaDescribedBy(element: HTMLElement | null) {
+      if (!element) return
+      const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id)
+      if (newDescribedBy.length) {
+        const next = newDescribedBy.join(' ')
+        if (element.getAttribute('aria-describedby') !== next) {
+          element.setAttribute('aria-describedby', next)
+        }
+      } else {
+        element.removeAttribute('aria-describedby')
+      }
+    }

     if (show) {
-      removeAriaDescribedBy(previousActiveAnchor)
+      if (previousActiveAnchor && activeAnchor && !previousActiveAnchor.isSameNode(activeAnchor)) {
+        removeAriaDescribedBy(previousActiveAnchor)
+      }
       const currentDescribedBy = getAriaDescribedBy(activeAnchor)
-      const describedBy = [...new Set([...currentDescribedBy, id])].filter(Boolean).join(' ')
-      activeAnchor?.setAttribute('aria-describedby', describedBy)
+      const describedBy = [...new Set([...currentDescribedBy, id])].filter(Boolean).join(' ')
+      if (activeAnchor && activeAnchor.getAttribute('aria-describedby') !== describedBy) {
+        activeAnchor.setAttribute('aria-describedby', describedBy)
+      }
     } else {
       removeAriaDescribedBy(activeAnchor)
     }
-  }, [activeAnchor, show, id, previousActiveAnchor])
+    return () => {
+      // Cleanup on id/anchor changes and unmount to avoid stale ARIA refs.
+      removeAriaDescribedBy(activeAnchor)
+      removeAriaDescribedBy(previousActiveAnchor)
+    }
+  }, [activeAnchor, show, id, previousActiveAnchor])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Add aria-describedby to activeAnchor when tooltip is active
*/
useEffect(() => {
if (!id) return
function getAriaDescribedBy(element: HTMLElement | null) {
return element?.getAttribute('aria-describedby')?.split(' ') || []
}
function removeAriaDescribedBy(element: HTMLElement | null) {
const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id)
if (newDescribedBy.length) {
element?.setAttribute('aria-describedby', newDescribedBy.join(' '))
} else {
element?.removeAttribute('aria-describedby')
}
}
if (show) {
removeAriaDescribedBy(previousActiveAnchor)
const currentDescribedBy = getAriaDescribedBy(activeAnchor)
const describedBy = [...new Set([...currentDescribedBy, id])].filter(Boolean).join(' ')
activeAnchor?.setAttribute('aria-describedby', describedBy)
} else {
removeAriaDescribedBy(activeAnchor)
}
}, [activeAnchor, show, id, previousActiveAnchor])
/**
* Add aria-describedby to activeAnchor when tooltip is active
*/
useEffect(() => {
if (!id) return
function getAriaDescribedBy(element: HTMLElement | null) {
const raw = element?.getAttribute('aria-describedby') ?? ''
return raw.trim().split(/\s+/).filter(Boolean)
}
function removeAriaDescribedBy(element: HTMLElement | null) {
if (!element) return
const newDescribedBy = getAriaDescribedBy(element).filter((s) => s !== id)
if (newDescribedBy.length) {
const next = newDescribedBy.join(' ')
if (element.getAttribute('aria-describedby') !== next) {
element.setAttribute('aria-describedby', next)
}
} else {
element.removeAttribute('aria-describedby')
}
}
if (show) {
if (previousActiveAnchor && activeAnchor && !previousActiveAnchor.isSameNode(activeAnchor)) {
removeAriaDescribedBy(previousActiveAnchor)
}
const currentDescribedBy = getAriaDescribedBy(activeAnchor)
const describedBy = [...new Set([...currentDescribedBy, id])].filter(Boolean).join(' ')
if (activeAnchor && activeAnchor.getAttribute('aria-describedby') !== describedBy) {
activeAnchor.setAttribute('aria-describedby', describedBy)
}
} else {
removeAriaDescribedBy(activeAnchor)
}
return () => {
// Cleanup on id/anchor changes and unmount to avoid stale ARIA refs.
removeAriaDescribedBy(activeAnchor)
removeAriaDescribedBy(previousActiveAnchor)
}
}, [activeAnchor, show, id, previousActiveAnchor])
🤖 Prompt for AI Agents
In src/components/Tooltip/Tooltip.tsx around lines 209-237, the useEffect that
manages aria-describedby needs robust token handling and cleanup: change
splitting to use .split(/\s+/).filter(Boolean) to normalize whitespace and
remove empty tokens; when adding/removing ensure you skip DOM writes if the
computed attribute would be unchanged; avoid removing the id from the same node
that you’re about to set on (i.e., only remove from previousActiveAnchor when
previousActiveAnchor !== activeAnchor); and add a cleanup function returned from
the effect that removes the current id from activeAnchor and
previousActiveAnchor when the component unmounts or id changes so no stale
aria-describedby remains. Ensure all string joins use ' ' and all DOM
set/removeAttribute calls are guarded by checks that the attribute actually
needs to change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant