From 1e6160e66d8dd8a952adaa4c4a76e40aca75c095 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:52:33 +1000 Subject: [PATCH] fix: guard focus restoration and compile wildcard subscriptions --- src/core/BaseComponent.js | 24 ++++- src/core/EventBus.js | 18 +++- tests/browser/focus-restoration.html | 40 ++++++++ tests/unit/BaseComponentFocus.test.js | 62 ++++++++++++ tests/unit/EventBus.test.js | 139 +++++++++++++++++--------- 5 files changed, 230 insertions(+), 53 deletions(-) create mode 100644 tests/browser/focus-restoration.html create mode 100644 tests/unit/BaseComponentFocus.test.js diff --git a/src/core/BaseComponent.js b/src/core/BaseComponent.js index d27c800..9a6ac79 100644 --- a/src/core/BaseComponent.js +++ b/src/core/BaseComponent.js @@ -234,7 +234,7 @@ export class BaseComponent extends HTMLElement { if (!selector || !this._contentWrapper) return; try { const el = this._contentWrapper.querySelector(selector); - if (el && typeof el.focus === 'function') { + if (el && typeof el.focus === 'function' && this._canRestoreFocus(el)) { el.focus(); } } catch (_) { @@ -242,6 +242,28 @@ export class BaseComponent extends HTMLElement { } } + /** Avoid restoring focus into hidden, inert, disabled, or detached content. */ + _canRestoreFocus(element) { + if (!element.isConnected || element.matches(':disabled')) return false; + const view = element.ownerDocument.defaultView; + const style = view.getComputedStyle(element); + if (style.visibility === 'hidden' || style.visibility === 'collapse') return false; + + // Walk through shadow hosts too: a visible child can still be inside a + // display:none or inert host. offsetParent is unsuitable for fixed elements. + for (let node = element; node; node = node.parentElement || node.getRootNode().host) { + if ( + node.hidden || + node.hasAttribute('inert') || + node.getAttribute('aria-hidden') === 'true' || + view.getComputedStyle(node).display === 'none' + ) { + return false; + } + } + return true; + } + template() { // Override in child classes to provide component template return ''; diff --git a/src/core/EventBus.js b/src/core/EventBus.js index 48bd71c..b7b806b 100644 --- a/src/core/EventBus.js +++ b/src/core/EventBus.js @@ -23,7 +23,13 @@ class EventBus { // Handle wildcard subscriptions if (eventName.includes('*')) { - const subscription = { pattern: eventName, handler, once, priority }; + const subscription = { + pattern: eventName, + regex: this.compilePattern(eventName), + handler, + once, + priority + }; this.wildcardHandlers.add(subscription); return () => this.wildcardHandlers.delete(subscription); } @@ -147,7 +153,7 @@ class EventBus { // Handle wildcard subscriptions (copy Set to avoid mutation during iteration) const toRemove = []; for (const subscription of [...this.wildcardHandlers]) { - if (this.matchesPattern(eventName, subscription.pattern)) { + if (subscription.regex.test(eventName)) { const { handler, once } = subscription; if (once) { @@ -170,9 +176,13 @@ class EventBus { * Only `*` acts as a wildcard; all other characters match literally */ matchesPattern(eventName, pattern) { + return this.compilePattern(pattern).test(eventName); + } + + /** Compile a subscription pattern once; only * is a wildcard. */ + compilePattern(pattern) { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); - return regex.test(eventName); + return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$'); } /** diff --git a/tests/browser/focus-restoration.html b/tests/browser/focus-restoration.html new file mode 100644 index 0000000..6508a08 --- /dev/null +++ b/tests/browser/focus-restoration.html @@ -0,0 +1,40 @@ + + + +
Running…+ + diff --git a/tests/unit/BaseComponentFocus.test.js b/tests/unit/BaseComponentFocus.test.js new file mode 100644 index 0000000..e424e6f --- /dev/null +++ b/tests/unit/BaseComponentFocus.test.js @@ -0,0 +1,62 @@ +import { BaseComponent } from '../../src/core/BaseComponent.js'; + +class FocusFixture extends BaseComponent { + template() { + return ''; + } +} +customElements.define('fc-focus-fixture', FocusFixture); + +describe('BaseComponent focus restoration', () => { + let component; + let target; + let focus; + beforeEach(() => { + component = document.createElement('fc-focus-fixture'); + document.body.appendChild(component); + target = component.shadowRoot.querySelector('#target'); + focus = jest.spyOn(target, 'focus'); + }); + afterEach(() => { + component.remove(); + }); + + test('restores a visible fixed-position target', () => { + target.style.position = 'fixed'; + component._restoreFocus('#target'); + expect(focus).toHaveBeenCalledTimes(1); + expect(component.shadowRoot.activeElement).toBe(target); + }); + test.each(['display: none', 'visibility: hidden', 'visibility: collapse'])( + 'does not focus a target styled %s', + style => { + target.style.cssText = style; + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + } + ); + test.each(['hidden', 'inert', 'aria-hidden', 'display'])('honors ancestor %s', kind => { + const parent = target.parentElement; + if (kind === 'display') parent.style.display = 'none'; + else parent.setAttribute(kind, kind === 'aria-hidden' ? 'true' : ''); + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + }); + test('honors hidden shadow hosts', () => { + component.style.display = 'none'; + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + }); + test('does not focus disabled or removed targets', () => { + target.disabled = true; + component._restoreFocus('#target'); + target.disabled = false; + target.remove(); + component._restoreFocus('#target'); + expect(focus).not.toHaveBeenCalled(); + }); + test('ignores invalid selectors', () => { + expect(() => component._restoreFocus('[')).not.toThrow(); + expect(focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/EventBus.test.js b/tests/unit/EventBus.test.js index 1ef4310..0d89b2c 100644 --- a/tests/unit/EventBus.test.js +++ b/tests/unit/EventBus.test.js @@ -1,56 +1,99 @@ import { EventBus } from '../../src/core/EventBus.js'; describe('EventBus', () => { - let bus; + let bus; - beforeEach(() => { - bus = new EventBus(); + beforeEach(() => { + bus = new EventBus(); + }); + + describe('matchesPattern', () => { + test('matches exact event names', () => { + expect(bus.matchesPattern('event.add', 'event.add')).toBe(true); + }); + + test('treats dots as literals, not regex wildcards', () => { + expect(bus.matchesPattern('event_add', 'event.add')).toBe(false); + expect(bus.matchesPattern('eventXadd', 'event.add')).toBe(false); + }); + + test('supports * as a wildcard', () => { + expect(bus.matchesPattern('event.add', 'event.*')).toBe(true); + expect(bus.matchesPattern('event.remove', 'event.*')).toBe(true); + expect(bus.matchesPattern('view.change', 'event.*')).toBe(false); + }); + + test('supports * in the middle of a pattern', () => { + expect(bus.matchesPattern('event.user.add', 'event.*.add')).toBe(true); + expect(bus.matchesPattern('event.user.remove', 'event.*.add')).toBe(false); }); - describe('matchesPattern', () => { - test('matches exact event names', () => { - expect(bus.matchesPattern('event.add', 'event.add')).toBe(true); - }); - - test('treats dots as literals, not regex wildcards', () => { - expect(bus.matchesPattern('event_add', 'event.add')).toBe(false); - expect(bus.matchesPattern('eventXadd', 'event.add')).toBe(false); - }); - - test('supports * as a wildcard', () => { - expect(bus.matchesPattern('event.add', 'event.*')).toBe(true); - expect(bus.matchesPattern('event.remove', 'event.*')).toBe(true); - expect(bus.matchesPattern('view.change', 'event.*')).toBe(false); - }); - - test('supports * in the middle of a pattern', () => { - expect(bus.matchesPattern('event.user.add', 'event.*.add')).toBe(true); - expect(bus.matchesPattern('event.user.remove', 'event.*.add')).toBe(false); - }); - - test('escapes regex metacharacters in patterns', () => { - expect(bus.matchesPattern('a+b', 'a+b')).toBe(true); - expect(bus.matchesPattern('aab', 'a+b')).toBe(false); - expect(bus.matchesPattern('event(1)', 'event(1)')).toBe(true); - expect(bus.matchesPattern('event1', 'event(1)')).toBe(false); - expect(bus.matchesPattern('a|b', 'a|b')).toBe(true); - expect(bus.matchesPattern('a', 'a|b')).toBe(false); - expect(bus.matchesPattern('item[0]', 'item[0]')).toBe(true); - expect(bus.matchesPattern('item0', 'item[0]')).toBe(false); - expect(bus.matchesPattern('cost$', 'cost$')).toBe(true); - expect(bus.matchesPattern('x?y', 'x?y')).toBe(true); - expect(bus.matchesPattern('xy', 'x?y')).toBe(false); - }); - - test('wildcard subscriptions only fire for literal matches', () => { - const handler = jest.fn(); - bus.on('event.*', handler); - - bus.emit('event.add', { id: 1 }); - bus.emit('eventXadd', { id: 2 }); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith({ id: 1 }, 'event.add'); - }); + test('escapes regex metacharacters in patterns', () => { + expect(bus.matchesPattern('a+b', 'a+b')).toBe(true); + expect(bus.matchesPattern('aab', 'a+b')).toBe(false); + expect(bus.matchesPattern('event(1)', 'event(1)')).toBe(true); + expect(bus.matchesPattern('event1', 'event(1)')).toBe(false); + expect(bus.matchesPattern('a|b', 'a|b')).toBe(true); + expect(bus.matchesPattern('a', 'a|b')).toBe(false); + expect(bus.matchesPattern('item[0]', 'item[0]')).toBe(true); + expect(bus.matchesPattern('item0', 'item[0]')).toBe(false); + expect(bus.matchesPattern('cost$', 'cost$')).toBe(true); + expect(bus.matchesPattern('x?y', 'x?y')).toBe(true); + expect(bus.matchesPattern('xy', 'x?y')).toBe(false); }); + + test('wildcard subscriptions only fire for literal matches', () => { + const handler = jest.fn(); + bus.on('event.*', handler); + + bus.emit('event.add', { id: 1 }); + bus.emit('eventXadd', { id: 2 }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ id: 1 }, 'event.add'); + }); + }); +}); + +describe('compiled wildcard subscriptions', () => { + test('compiles once per subscription rather than per emitted event', () => { + const bus = new EventBus(); + const compile = jest.spyOn(bus, 'compilePattern'); + const handler = jest.fn(); + const unsubscribe = bus.on('event.*', handler); + expect(compile).toHaveBeenCalledTimes(1); + bus.emit('event.add'); + bus.emit('event.remove'); + bus.emit('unrelated'); + expect(compile).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(2); + unsubscribe(); + bus.emit('event.add'); + expect(handler).toHaveBeenCalledTimes(2); + expect(bus.getWildcardHandlerCount()).toBe(0); + }); + test.each(['off', 'offWildcard', 'offAll', 'clear'])( + '%s removes compiled subscriptions', + method => { + const bus = new EventBus(); + const handler = jest.fn(); + bus.on('event.*', handler); + if (method === 'off') bus.off('event.*', handler); + if (method === 'offWildcard') bus.offWildcard('event.*'); + if (method === 'offAll') bus.offAll(handler); + if (method === 'clear') bus.clear(); + bus.emit('event.add'); + expect(handler).not.toHaveBeenCalled(); + expect(bus.getWildcardHandlerCount()).toBe(0); + } + ); + test('once subscriptions retain their existing one-shot behavior', () => { + const bus = new EventBus(); + const handler = jest.fn(); + bus.once('event.*', handler); + bus.emit('event.add'); + bus.emit('event.add'); + expect(handler).toHaveBeenCalledTimes(1); + expect(bus.getWildcardHandlerCount()).toBe(0); + }); });