Skip to content

Commit 5482845

Browse files
committed
UI: Make focusOnUIElement poll to limit focus trap race conditions
1 parent 9827240 commit 5482845

1 file changed

Lines changed: 48 additions & 3 deletions

File tree

‎code/core/src/manager-api/modules/layout.ts‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,17 +327,62 @@ export const init: ModuleFn<SubAPI, SubState> = ({ store, provider, singleStory
327327
);
328328
},
329329

330+
/**
331+
* Attempts to focus (and select) an element identified by its ID. It is the responsibility of
332+
* the callee to ensure that the element is present in the DOM and that no focus trap is
333+
* available. This API polls and attempts to perform the focus for a set duration (max 500ms),
334+
* so that race conditions can be avoided with the current API design. Because this API is
335+
* historically synchronous, it cannot report errors or failure to focus. It fails silently.
336+
*
337+
* @param elementId The id of the element to focus.
338+
* @param select Whether to call select() on the element after focusing it.
339+
*/
330340
focusOnUIElement(elementId?: string, select?: boolean) {
341+
// See RFC https://github.com/storybookjs/storybook/discussions/32983 for
342+
// ways to make this API more robust to focus-trap race conditions.
343+
331344
if (!elementId) {
332345
return;
333346
}
334-
const element = document.getElementById(elementId);
335-
if (element) {
347+
348+
const startTime = Date.now();
349+
const maxDuration = 500;
350+
const pollInterval = 50;
351+
352+
const attemptFocus = () => {
353+
const element = document.getElementById(elementId);
354+
if (!element) {
355+
return false;
356+
}
357+
336358
element.focus();
359+
if (element !== document.activeElement) {
360+
return false;
361+
}
362+
337363
if (select) {
338-
(element as any).select();
364+
(element as any).select?.();
339365
}
366+
return true;
367+
};
368+
369+
if (attemptFocus()) {
370+
return;
340371
}
372+
373+
// Poll every 50ms for up to 500ms to account for race conditions.
374+
const intervalId = setInterval(() => {
375+
const elapsed = Date.now() - startTime;
376+
377+
if (elapsed >= maxDuration) {
378+
clearInterval(intervalId);
379+
return;
380+
}
381+
382+
if (attemptFocus()) {
383+
clearInterval(intervalId);
384+
}
385+
}, pollInterval);
341386
},
342387

343388
getInitialOptions() {

0 commit comments

Comments
 (0)