diff --git a/packages/vue/src/composables/useFocusGuard/index.test.ts b/packages/vue/src/composables/useFocusGuard/index.test.ts new file mode 100644 index 0000000..f646716 --- /dev/null +++ b/packages/vue/src/composables/useFocusGuard/index.test.ts @@ -0,0 +1,69 @@ +import { describe, it, beforeEach, afterEach, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, nextTick } from 'vue'; +import { useFocusGuard } from '.'; + +const setupFocusGuard = (namespace?: string) => { + return mount( + defineComponent({ + setup() { + useFocusGuard(namespace); + }, + template: '
', + }) + ); +}; + +const getFocusGuards = (namespace: string) => + document.querySelectorAll(`[data-${namespace}]`); + +describe('useFocusGuard', () => { + let component: ReturnType; + const namespace = 'test-guard'; + + beforeEach(() => { + document.body.innerHTML = ''; + }); + + afterEach(() => { + component.unmount(); + }); + + it('create focus guards when mounted', async () => { + component = setupFocusGuard(namespace); + + const guards = getFocusGuards(namespace); + expect(guards.length).toBe(2); + + guards.forEach((guard) => { + expect(guard.getAttribute('tabindex')).toBe('0'); + expect(guard.getAttribute('style')).toContain('opacity: 0'); + }); + }); + + it('remove focus guards when unmounted', () => { + component = setupFocusGuard(namespace); + + component.unmount(); + + expect(getFocusGuards(namespace).length).toBe(0); + }); + + it('correctly manage multiple instances with the same namespace', () => { + const wrapper1 = setupFocusGuard(namespace); + const wrapper2 = setupFocusGuard(namespace); + + // Guards should not be duplicated + expect(getFocusGuards(namespace).length).toBe(2); + + wrapper1.unmount(); + + // Second instance still keeps the guards + expect(getFocusGuards(namespace).length).toBe(2); + + wrapper2.unmount(); + + // No guards left after all instances are unmounted + expect(getFocusGuards(namespace).length).toBe(0); + }); +}); diff --git a/packages/vue/src/composables/useFocusGuard/index.ts b/packages/vue/src/composables/useFocusGuard/index.ts new file mode 100644 index 0000000..a547522 --- /dev/null +++ b/packages/vue/src/composables/useFocusGuard/index.ts @@ -0,0 +1,40 @@ +import { focusGuard } from '@robonen/platform/browsers'; +import { onMounted, onUnmounted } from 'vue'; + +// Global counter to drop the focus guards when the last instance is unmounted +let counter = 0; + +/** + * @name useFocusGuard + * @category Utilities + * @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior + * + * @param {string} [namespace] - A namespace to group the focus guards + * @returns {void} + * + * @example + * useFocusGuard(); + * + * @example + * useFocusGuard('my-namespace'); + * + * @since 0.0.2 + */ +export function useFocusGuard(namespace?: string) { + const manager = focusGuard(namespace); + + const createGuard = () => { + manager.createGuard(); + counter++; + }; + + const removeGuard = () => { + if (counter <= 1) + manager.removeGuard(); + + counter = Math.max(0, counter - 1); + }; + + onMounted(createGuard); + onUnmounted(removeGuard); +} diff --git a/packages/vue/src/types/window.ts b/packages/vue/src/types/window.ts index afa5061..bfd076b 100644 --- a/packages/vue/src/types/window.ts +++ b/packages/vue/src/types/window.ts @@ -1,3 +1,3 @@ -import { isClient } from '@robonen/platform'; +import { isClient } from '@robonen/platform/multi'; export const defaultWindow = /* #__PURE__ */ isClient ? window : undefined \ No newline at end of file