1616 :data-apos-graph-key =" props.graphKey || undefined"
1717 tabindex =" 0"
1818 @focus.capture =" captureFocus"
19- @keyup .tab =" onKeyup "
19+ @keydown .tab =" onKeydownTab "
2020 @keyup.esc =" onKeyup"
2121 >
2222 <transition :name =" transitionType" >
@@ -216,6 +216,22 @@ const nonDraggableElements = [
216216 ' .apos-input-array-inline-table'
217217];
218218
219+ // Selector for focusable elements inside the modal. Used both at trap setup
220+ // and on every Tab keydown to refresh the cycle list, so that elements that
221+ // became disabled/hidden (e.g. Save when validation fails) or newly visible
222+ // are reflected.
223+ const focusableSelector = [
224+ ' [tabindex]' ,
225+ ' [href]' ,
226+ ' input' ,
227+ ' select' ,
228+ ' textarea' ,
229+ ' button' ,
230+ ' [data-apos-focus-priority]'
231+ ]
232+ .map (s => ` ${ s} :not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden]):not(.apos-sr-only)` )
233+ .join (' , ' );
234+
219235const resizeSides = [
220236 {
221237 edge: ' top' ,
@@ -476,13 +492,30 @@ onUnmounted(() => {
476492 }
477493});
478494
479- function onKeyup (event ) {
495+ // Handle Tab on keydown — before the browser moves focus.
496+ // Handling Tab on keyup is too late: the browser has already moved focus,
497+ // so the cycling logic sees the wrong activeElement.
498+ //
499+ // We also recompute the focusable list here on every Tab, scoped to
500+ // modalEl, instead of relying on the snapshot taken at trapFocus() time.
501+ function onKeydownTab (event ) {
502+ if (! shouldTrapFocus .value ) {
503+ return ;
504+ }
480505 if (! store .isOnTop (modalEl .value )) {
481506 return ;
482507 }
508+ if (event .target ? .nodeName ? .toLowerCase () === ' textarea' ) {
509+ return ;
510+ }
511+ const elements = getFocusableElements (modalEl .value );
512+ // Keep the store snapshot consistent for other consumers.
513+ store .updateModalData (props .modalData .id , { elementsToFocus: elements });
514+ cycleElementsToFocus (event , elements);
515+ }
483516
484- if (event . key === ' Tab ' ) {
485- cycleElementsToFocus ( event , props . modalData . elementsToFocus );
517+ function onKeyup (event ) {
518+ if ( ! store . isOnTop ( modalEl . value )) {
486519 return ;
487520 }
488521
@@ -517,23 +550,20 @@ function captureFocus(e) {
517550 store .updateModalData (props .modalData .id , { focusedElement: e .target });
518551}
519552
553+ function getFocusableElements (rootEl ) {
554+ if (! rootEl) {
555+ return [];
556+ }
557+ return [ ... rootEl .querySelectorAll (focusableSelector) ]
558+ // a cheap "visible and in the DOM" condition,
559+ // false positive expected for position: fixed and
560+ // visually hidden elements (visible: hidden, opacity: 0, etc.)
561+ .filter (el => el .offsetParent !== null );
562+ }
563+
520564async function trapFocus () {
521565 if (modalEl? .value ) {
522- const elementSelectors = [
523- ' [tabindex]' ,
524- ' [href]' ,
525- ' input' ,
526- ' select' ,
527- ' textarea' ,
528- ' button' ,
529- ' [data-apos-focus-priority]'
530- ];
531-
532- const selector = elementSelectors
533- .map (addExcludingAttributes)
534- .join (' , ' );
535-
536- const elementsToFocus = [ ... modalEl .value .querySelectorAll (selector) ];
566+ const elementsToFocus = getFocusableElements (modalEl .value );
537567
538568 store .updateModalData (props .modalData .id , { elementsToFocus });
539569
@@ -556,10 +586,6 @@ async function trapFocus() {
556586 await nextTick ();
557587 focusElement (props .modalData .focusedElement , firstElementToFocus);
558588 }
559-
560- function addExcludingAttributes (element ) {
561- return ` ${ element} :not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden]):not(.apos-sr-only)` ;
562- }
563589}
564590
565591function close () {
0 commit comments