-
Notifications
You must be signed in to change notification settings - Fork 9.7k
fix: assets/knowledge-base accessibility #13973
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
Changes from 2 commits
065ce2a
77ff778
6262d7e
5bc604c
a2cfe58
5d09a62
39af62d
cbeb7d6
1c1d324
0f7060d
6107988
c1cd90f
904dfb4
cf5c19b
c56c3bf
339f259
179cbc9
8b3289e
63f1236
07d58aa
67cb4a3
99d27b2
fb3ad83
09b7f03
b9c003e
ec82363
9577a60
2268597
8fa857a
dfa6a56
4a0706d
6c7f9ce
957a034
dfcdf2a
47fc93c
768f387
01ead49
f9a22ab
edb8308
c92564e
2774e07
a9847e7
9bbaf63
242d040
c3d1e8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,59 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CONTROLS_CACHE_ATTR = "data-radix-aria-controls-cache"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Radix sets aria-controls on closed popovers/selects/dialogs while the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * referenced node is aria-hidden or unmounted. IBM's checker treats that as | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * aria_id_unique violation. Strip the attribute whenever the trigger is closed. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * The stripped value is cached on the node first: since the id Radix passes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * in is stable across renders, React's reconciler sees an "unchanged" prop | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * on the next render and won't re-apply it once we've removed it from the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * DOM directly, so a trigger that closes and later reopens would otherwise | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * be left with no aria-controls at all. Restoring from the cache when the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * trigger re-opens keeps that reference intact. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function RadixAriaControlsFix() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fixClosedTriggers = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| document | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .querySelectorAll('[data-state="closed"][aria-controls]') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .forEach((node) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const controls = node.getAttribute("aria-controls"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (controls) node.setAttribute(CONTROLS_CACHE_ATTR, controls); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| node.removeAttribute("aria-controls"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| document | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .querySelectorAll( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `[data-state="open"][${CONTROLS_CACHE_ATTR}]:not([aria-controls])`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .forEach((node) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cached = node.getAttribute(CONTROLS_CACHE_ATTR); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cached) node.setAttribute("aria-controls", cached); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| document.querySelectorAll("[data-radix-focus-guard]").forEach((node) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| node.setAttribute("tabindex", "-1"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| node.setAttribute("aria-hidden", "true"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fixClosedTriggers(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const observer = new MutationObserver(fixClosedTriggers); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| observer.observe(document.body, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| subtree: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| childList: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attributes: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attributeFilter: ["data-state", "aria-controls"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return () => observer.disconnect(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Throttle the observer callback to avoid full-document rescans on every DOM mutation.
Coalesce bursts with ⚡ Suggested throttling+ let rafId: number | null = null;
+ const scheduleFixup = () => {
+ if (rafId !== null) return;
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ fixClosedTriggers();
+ });
+ };
+
fixClosedTriggers();
- const observer = new MutationObserver(fixClosedTriggers);
+ const observer = new MutationObserver(scheduleFixup);
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-state", "aria-controls"],
});
- return () => observer.disconnect();
+ return () => {
+ observer.disconnect();
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ };📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default RadixAriaControlsFix; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: langflow-ai/langflow
Length of output: 28653
🌐 Web query:
Radix UI focus guard data-radix-focus-guard tabIndex 0 onFocus redirect focus-scope source💡 Result:
In Radix UI, the
data-radix-focus-guardattribute is used to identify invisible, focusable elements that ensure keyboard focus management works reliably across the entire document [1][2]. These guards are essential components injected by the@radix-ui/react-focus-guardspackage to help components likeDialogandPopovertrap and maintain focus [1][3]. Key technical details regarding these guards include: Mechanism: Radix UI injects two invisible elements into the document body—one at the very beginning (afterbegin) and one at the very end (beforeend) [1][2]. These elements are explicitly marked with thedata-radix-focus-guardattribute [1][2]. Purpose: By assigning themtabIndex={0}, Radix UI makes these elements focusable [1]. This ensures that when a user reaches the beginning or end of a focus-trapped area, the browser's focus events (likefocusinandfocusout) are consistently fired [1]. This allows the library's focus-trapping logic to detect when the user is trying to move focus outside the trapped region and redirect it accordingly, preventing the focus from "leaking" into the browser UI or other non-trapped areas [1][3]. Properties: To keep them invisible and non-interactive to the user, they are styled withopacity: 0,position: fixed, andpointer-events: none, while theiroutlineis set tonone[1][2]. Reference Counting: The implementation uses a reference counter to manage these guards [1]. Multiple components can "request" focus guards, but only one pair exists in the document at any time; they are only removed when the last component requiring them unmounts [1][2]. Relationship with FocusScope: WhileFocusGuardsprovides the infrastructure for capturing focus at the document edges,FocusScopeis the component that performs the actual logic of trapping, looping, and restoring focus [3]. WhenFocusScopeis set totrapped={true}, it listens for focus events and, should the focus land on a guard or attempt to exit the scope, it uses its internal logic to redirect focus back to a valid element within the designated scope [3][4].Citations:
🌐 Web query:
@radix-ui/react-focus-scope focus guards tabIndex 0 focusin trapped source implementation💡 Result:
In Radix UI, focus management relies on two primary utilities working in tandem:
@radix-ui/react-focus-guardsand@radix-ui/react-focus-scope[1][2]. Focus Guards (Implementation) Focus guards are invisible, focusable elements strategically placed in the DOM to ensure focus events are captured correctly at the document boundaries [1]. - Structure: They are elements with an empty data-radix-focus-guard attribute [1]. - Styling: They are set to tabIndex={0} (making them focusable), with CSS properties like position: fixed, opacity: 0, and pointer-events: none to remain invisible and non-obstructive [1]. - Functionality: They are injected at the very start and end of document.body [1]. When a user tabs into these guards, they provide a reliable target for the browser to trigger focusin and focusout events, which the FocusScope can then monitor [1]. This prevents "focus leakage" where focus might otherwise accidentally move into the browser's chrome or become lost when navigating at the edges of a trapped focus area [1]. Focus Scope and Trapping The FocusScope component manages the trapping of focus within a specific DOM container [2]. - Trapping Mechanism: When trapped={true}, FocusScope listens for focusin and focusout events [2]. If the focus moves to an element outside the defined scope, the component programmatically redirects it back inside [2]. - Tab Navigation: It implements a "looping" behavior: when a user tabs from the last focusable element in the scope, focus is programmatically moved back to the first element (and vice versa for Shift+Tab) [2][3]. - Implementation Logic: FocusScope uses a function—often identified in community discussions as a variation of getTabbableCandidates—to scan the DOM tree within its container [3]. It evaluates element properties, including tabIndex, to determine the order of focusable items [4][3]. - tabIndex Usage: While focus guards use tabIndex={0} to participate in the natural tab order [1], the container of the FocusScope itself often uses tabIndex={-1} [3]. This makes the container programmatically focusable (allowing it to receive focus via JavaScript) without adding it to the natural keyboard tab order [3]. These systems ensure that even in complex UI components like Modals or Popovers, keyboard focus remains strictly contained, predictable, and compliant with accessibility standards [1][2][5].Citations:
Don't force Radix focus guards to
tabindex="-1"data-radix-focus-guardelements need to stay tabbable for Radix to catch edge Tab/Shift+Tab navigation and redirect focus back into open dialogs/popovers. Remove this mutation and exclude these guards from the scanner instead.🤖 Prompt for AI Agents