Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/core/BaseComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
// Event handling
addListener(element, event, handler) {
if (!element || !event || !handler) {
console.warn('addListener called with invalid parameters', { element, event, handler });

Check warning on line 98 in src/core/BaseComponent.js

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected console statement

Check warning on line 98 in src/core/BaseComponent.js

View workflow job for this annotation

GitHub Actions / Code Quality

Unexpected console statement
return;
}
const boundHandler = handler.bind(this);
Expand Down Expand Up @@ -234,14 +234,36 @@
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 (_) {
// Invalid selector, ignore
}
}

/** 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 '';
Expand Down
18 changes: 14 additions & 4 deletions src/core/EventBus.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@

// 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);
}
Expand Down Expand Up @@ -139,7 +145,7 @@
try {
handler(data, eventName);
} catch (error) {
console.error(`Error in event handler for ${eventName}:`, error);

Check warning on line 148 in src/core/EventBus.js

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected console statement

Check warning on line 148 in src/core/EventBus.js

View workflow job for this annotation

GitHub Actions / Code Quality

Unexpected console statement
}
}
}
Expand All @@ -147,7 +153,7 @@
// 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) {
Expand All @@ -157,7 +163,7 @@
try {
handler(data, eventName);
} catch (error) {
console.error(`Error in wildcard handler for ${eventName}:`, error);

Check warning on line 166 in src/core/EventBus.js

View workflow job for this annotation

GitHub Actions / ESLint

Unexpected console statement

Check warning on line 166 in src/core/EventBus.js

View workflow job for this annotation

GitHub Actions / Code Quality

Unexpected console statement
}
}
}
Expand All @@ -170,9 +176,13 @@
* 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, '.*') + '$');
}

/**
Expand Down
40 changes: 40 additions & 0 deletions tests/browser/focus-restoration.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Focus restoration browser regression</title>
<h1>Focus restoration browser regression</h1>
<pre id="results">Running…</pre>
<script type="module">
import { BaseComponent } from '../../src/core/BaseComponent.js';
class Fixture extends BaseComponent {
template() { return '<section><button id="target">Focus target</button></section>'; }
}
customElements.define('fc-focus-browser-fixture', Fixture);
const outcomes = [];
const cases = [
['visible fixed position', (el, target) => { target.style.position = 'fixed'; }, true],
['hidden target', (el, target) => { target.hidden = true; }, false],
['display none ancestor', (el, target) => { target.parentElement.style.display = 'none'; }, false],
['visibility hidden target', (el, target) => { target.style.visibility = 'hidden'; }, false],
['inert ancestor', (el, target) => { target.parentElement.inert = true; }, false],
['aria-hidden ancestor', (el, target) => { target.parentElement.setAttribute('aria-hidden', 'true'); }, false],
['hidden shadow host', el => { el.style.display = 'none'; }, false],
['disabled target', (el, target) => { target.disabled = true; }, false],
['removed target', (el, target) => { target.remove(); }, false]
];
for (const [name, prepare, expected] of cases) {
const element = document.createElement('fc-focus-browser-fixture');
document.body.appendChild(element);
const target = element.shadowRoot.querySelector('#target');
prepare(element, target);
let calls = 0;
const nativeFocus = target.focus.bind(target);
target.focus = () => { calls++; nativeFocus(); };
element._restoreFocus('#target');
const focused = element.shadowRoot.activeElement === target;
outcomes.push(`${calls === Number(expected) && focused === expected ? 'PASS' : 'FAIL'} ${name}`);
element.remove();
}
document.querySelector('#results').textContent = outcomes.join('\n');
</script>
</html>
62 changes: 62 additions & 0 deletions tests/unit/BaseComponentFocus.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { BaseComponent } from '../../src/core/BaseComponent.js';

class FocusFixture extends BaseComponent {
template() {
return '<section><button id="target">Target</button></section>';
}
}
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();
});
});
139 changes: 91 additions & 48 deletions tests/unit/EventBus.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading