Skip to content

Commit 594b551

Browse files
Restore attachTo tabindex when tour is hidden (#3351)
1 parent 861823f commit 594b551

File tree

3 files changed

+180
-0
lines changed

3 files changed

+180
-0
lines changed

shepherd.js/src/components/shepherd-element.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
const attachTo = step._getResolvedAttachToOptions();
4545
if (attachTo?.element) {
4646
attachToElement = attachTo.element;
47+
step._storeOriginalTabIndex(attachToElement);
4748
attachToElement.tabIndex = 0;
4849
focusableAttachToElements = [
4950
attachToElement,

shepherd.js/src/step.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ export interface StepOptionsWhen {
305305
export class Step extends Evented {
306306
_resolvedAttachTo: StepOptionsAttachTo | null;
307307
_resolvedExtraHighlightElements?: HTMLElement[];
308+
_originalTabIndexes: Map<Element, string>;
308309
classPrefix?: string;
309310
declare cleanup: Function | null;
310311
el?: HTMLElement | null;
@@ -331,6 +332,13 @@ export class Step extends Evented {
331332
*/
332333
this._resolvedAttachTo = null;
333334

335+
/**
336+
* Map to store original tabIndex values of elements that are modified during the tour.
337+
* @type {Map<Element, string | null>}
338+
* @private
339+
*/
340+
this._originalTabIndexes = new Map();
341+
334342
autoBind(this);
335343

336344
this._setOptions(options);
@@ -369,6 +377,7 @@ export class Step extends Evented {
369377
}
370378

371379
this._updateStepTargetOnHide();
380+
this._originalTabIndexes.clear();
372381

373382
this.trigger('destroy');
374383
}
@@ -480,6 +489,37 @@ export class Step extends Evented {
480489
return this.target;
481490
}
482491

492+
/**
493+
* Stores the original tabIndex value of an element before modifying it.
494+
* Only stores the value if the element has a tabindex attribute.
495+
* @param {Element} element The element whose tabIndex will be stored
496+
* @private
497+
*/
498+
_storeOriginalTabIndex(element: Element): void {
499+
const originalValue = element.getAttribute('tabindex');
500+
if (originalValue !== null) {
501+
this._originalTabIndexes.set(element, originalValue);
502+
}
503+
}
504+
505+
/**
506+
* Restores the original tabIndex values for all elements that were modified during the tour.
507+
* If an element is in the map, restores its original value.
508+
* If an element is not in the map, removes the tabindex attribute (it didn't have one originally).
509+
* Note: Does not clear the map to allow for multiple show/hide cycles.
510+
* @private
511+
*/
512+
_restoreOriginalTabIndexes(): void {
513+
const target = this.target;
514+
if (target) {
515+
if (this._originalTabIndexes.has(target)) {
516+
target.setAttribute('tabindex', this._originalTabIndexes.get(target)!);
517+
} else {
518+
target.removeAttribute('tabindex');
519+
}
520+
}
521+
}
522+
483523
/**
484524
* Creates Shepherd element for step based on options
485525
*
@@ -722,5 +762,7 @@ export class Step extends Evented {
722762
`${this.classPrefix}shepherd-target`
723763
);
724764
});
765+
766+
this._restoreOriginalTabIndexes();
725767
}
726768
}

shepherd.js/test/unit/step.spec.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,4 +780,141 @@ describe('Tour | Step', () => {
780780
});
781781
});
782782
});
783+
784+
describe('tabIndex preservation', () => {
785+
let instance;
786+
let testElement;
787+
788+
beforeEach(() => {
789+
// Create a test element
790+
testElement = document.createElement('div');
791+
testElement.id = 'tabindex-test-element';
792+
document.body.appendChild(testElement);
793+
794+
instance = new Shepherd.Tour({
795+
steps: [
796+
{
797+
id: 'test-step',
798+
text: 'Test step',
799+
attachTo: { element: '#tabindex-test-element', on: 'top' }
800+
}
801+
]
802+
});
803+
});
804+
805+
afterEach(() => {
806+
instance.complete();
807+
testElement?.remove();
808+
});
809+
810+
it('stores and restores original tabIndex when element has no tabindex attribute', () => {
811+
// Initially, the element should have no tabindex attribute
812+
expect(testElement.hasAttribute('tabindex')).toBe(false);
813+
814+
// Start the tour
815+
instance.start();
816+
817+
// During the tour, tabIndex should be set to 0
818+
expect(testElement.tabIndex).toBe(0);
819+
expect(testElement.getAttribute('tabindex')).toBe('0');
820+
821+
// Hide the step
822+
instance.getCurrentStep().hide();
823+
824+
// After hiding, the tabindex attribute should be removed
825+
expect(testElement.hasAttribute('tabindex')).toBe(false);
826+
});
827+
828+
it('stores and restores original tabIndex when element has tabindex="-1"', () => {
829+
// Set tabindex to -1 initially
830+
testElement.setAttribute('tabindex', '-1');
831+
expect(testElement.getAttribute('tabindex')).toBe('-1');
832+
833+
// Start the tour
834+
instance.start();
835+
836+
// During the tour, tabIndex should be set to 0
837+
expect(testElement.tabIndex).toBe(0);
838+
expect(testElement.getAttribute('tabindex')).toBe('0');
839+
840+
// Hide the step
841+
instance.getCurrentStep().hide();
842+
843+
// After hiding, tabIndex should be restored to -1
844+
expect(testElement.getAttribute('tabindex')).toBe('-1');
845+
});
846+
847+
it('stores and restores original tabIndex when element has tabindex="5"', () => {
848+
// Set tabindex to 5 initially
849+
testElement.setAttribute('tabindex', '5');
850+
expect(testElement.getAttribute('tabindex')).toBe('5');
851+
852+
// Start the tour
853+
instance.start();
854+
855+
// During the tour, tabIndex should be set to 0
856+
expect(testElement.tabIndex).toBe(0);
857+
expect(testElement.getAttribute('tabindex')).toBe('0');
858+
859+
// Hide the step
860+
instance.getCurrentStep().hide();
861+
862+
// After hiding, tabIndex should be restored to 5
863+
expect(testElement.getAttribute('tabindex')).toBe('5');
864+
});
865+
866+
it('restores tabIndex when step is destroyed', () => {
867+
// Set tabindex to -1 initially
868+
testElement.setAttribute('tabindex', '-1');
869+
870+
// Start the tour
871+
instance.start();
872+
873+
// During the tour, tabIndex should be set to 0
874+
expect(testElement.getAttribute('tabindex')).toBe('0');
875+
876+
// Destroy the step
877+
instance.getCurrentStep().destroy();
878+
879+
// After destroying, tabIndex should be restored to -1
880+
expect(testElement.getAttribute('tabindex')).toBe('-1');
881+
});
882+
883+
it('handles multiple show/hide cycles correctly', () => {
884+
// Set tabindex to 2 initially
885+
testElement.setAttribute('tabindex', '2');
886+
887+
// Start the tour (first show)
888+
instance.start();
889+
expect(testElement.getAttribute('tabindex')).toBe('0');
890+
891+
// Hide the step
892+
instance.getCurrentStep().hide();
893+
expect(testElement.getAttribute('tabindex')).toBe('2');
894+
895+
// Show again
896+
instance.getCurrentStep().show();
897+
expect(testElement.getAttribute('tabindex')).toBe('0');
898+
899+
// Hide again
900+
instance.getCurrentStep().hide();
901+
expect(testElement.getAttribute('tabindex')).toBe('2');
902+
});
903+
904+
it('only stores the original value once, not intermediate values', () => {
905+
// Set tabindex to 3 initially
906+
testElement.setAttribute('tabindex', '3');
907+
908+
// Start the tour
909+
instance.start();
910+
expect(testElement.getAttribute('tabindex')).toBe('0');
911+
912+
// Manually change tabIndex (simulating some other code changing it)
913+
testElement.setAttribute('tabindex', '7');
914+
915+
// Hide the step - should restore to original value (3), not intermediate (7)
916+
instance.getCurrentStep().hide();
917+
expect(testElement.getAttribute('tabindex')).toBe('3');
918+
});
919+
});
783920
});

0 commit comments

Comments
 (0)