Skip to content
Open
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
99 changes: 99 additions & 0 deletions packages/core/src/events/FocusManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,105 @@ describe('FocusManager', () => {
expect(fm.isFocused('a')).toBe(false);
expect(fm.isFocused('b')).toBe(true);
});

describe('unregister', () => {
it('does not emit blur when unregistering a non-focused widget', () => {
const fm = new FocusManager();
const blurHandler = vi.fn();
fm.on('blur', blurHandler);

fm.register(makeWidget('a'));
fm.register(makeWidget('b'));
fm.register(makeWidget('c'));
// 'a' is focused

fm.unregister('b'); // 'b' is NOT focused

expect(blurHandler).not.toHaveBeenCalled();
expect(fm.currentId).toBe('a');
});

it('emits blur when unregistering the focused widget', () => {
const fm = new FocusManager();
const blurHandler = vi.fn();
fm.on('blur', blurHandler);

fm.register(makeWidget('a'));
fm.register(makeWidget('b'));
// 'a' is focused

fm.unregister('a');

expect(blurHandler).toHaveBeenCalledWith(
expect.objectContaining({ targetId: 'a', type: 'blur' })
);
});

it('moves focus to next widget when focused widget is unregistered', () => {
const fm = new FocusManager();
fm.register(makeWidget('a'));
fm.register(makeWidget('b'));
fm.register(makeWidget('c'));
// 'a' is focused

fm.unregister('a');

expect(fm.currentId).toBe('b');
});

it('sets currentId to null when last widget is unregistered', () => {
const fm = new FocusManager();
fm.register(makeWidget('a'));

fm.unregister('a');

expect(fm.currentId).toBeNull();
});

it('adjusts index correctly when non-focused widget before focused is removed', () => {
const fm = new FocusManager();
fm.register(makeWidget('a'));
fm.register(makeWidget('b'));
fm.register(makeWidget('c'));
fm.focusWidget('c');
// focused index = 2

fm.unregister('a');
// 'c' should still be focused, index adjusted from 2 to 1

expect(fm.currentId).toBe('c');
});

it('unregistering a non-focused widget after focused one does not affect focus', () => {
const fm = new FocusManager();
fm.register(makeWidget('a'));
fm.register(makeWidget('b'));
fm.register(makeWidget('c'));
// 'a' is focused

fm.unregister('c');

expect(fm.currentId).toBe('a');
});

it('does not emit any events when unregistering a non-focused widget', () => {
const fm = new FocusManager();
const focusHandler = vi.fn();
const blurHandler = vi.fn();
fm.on('focus', focusHandler);
fm.on('blur', blurHandler);

fm.register(makeWidget('a'));
fm.register(makeWidget('b'));
focusHandler.mockClear();
blurHandler.mockClear();

fm.unregister('b');

expect(focusHandler).not.toHaveBeenCalled();
expect(blurHandler).not.toHaveBeenCalled();
});
});
});

describe('FocusManager Spatial Navigation', () => {
Expand Down
17 changes: 5 additions & 12 deletions packages/core/src/events/FocusManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ export class FocusManager {
this._focusables.splice(idx, 1);

if (wasFocused) {
// The focused widget is being removed — emit blur for it, then
// move focus to the next available widget if one exists.
this._events.emit('blur', { targetId: id, type: 'blur', epoch: this._epoch++ });
// Try to focus the next widget
if (this._focusables.length > 0) {
this._currentIndex = Math.min(this._currentIndex, this._focusables.length - 1);
this._events.emit('focus', {
Expand All @@ -135,18 +136,10 @@ export class FocusManager {
this._currentIndex = -1;
}
} else if (idx < this._currentIndex) {
// Silent focus shift: the widget that preceded the removed item
// now occupies the focused position. Emit blur + focus to notify
// downstream so the visual focus state stays in sync.
// A non-focused widget before the focused one was removed
// just adjust the index. No blur/focus events because the
// focused widget hasn't changed.
this._currentIndex--;
this._events.emit('blur', { targetId: id, type: 'blur', epoch: this._epoch++ });
if (this._currentIndex >= 0 && this._currentIndex < this._focusables.length) {
this._events.emit('focus', {
targetId: this._focusables[this._currentIndex].id,
type: 'focus',
epoch: this._epoch++,
});
}
}
}

Expand Down
Loading