Skip to content

Commit 7ab9166

Browse files
authored
feat(core): elementFocus add writable focus control
1 parent 15086e6 commit 7ab9166

File tree

3 files changed

+176
-59
lines changed

3 files changed

+176
-59
lines changed

projects/core/elements/element-focus/index.test.ts

Lines changed: 115 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,70 @@ import { Component, ElementRef, inject, signal, viewChild } from '@angular/core'
22
import { TestBed } from '@angular/core/testing';
33
import { elementFocus } from './index';
44

5-
// @TODO: add tests with focusVisible
65
describe(elementFocus.name, () => {
76
describe('with reactive target (signal query)', () => {
87
describe('focus state tracking', () => {
98
@Component({ template: '<input #input />' })
109
class TestComponent {
1110
readonly input = viewChild<ElementRef>('input');
12-
readonly isFocused = elementFocus(this.input);
11+
readonly childFocused = elementFocus(this.input);
1312
}
1413

1514
function createComponent() {
1615
const fixture = TestBed.createComponent(TestComponent);
1716
fixture.detectChanges();
1817
return {
1918
component: fixture.componentInstance,
20-
target: fixture.nativeElement.querySelector('input'),
19+
focusableChildEl: fixture.nativeElement.querySelector('input'),
2120
};
2221
}
2322

2423
it('should be false initially', () => {
2524
const { component } = createComponent();
2625

27-
expect(component.isFocused()).toBe(false);
26+
expect(component.childFocused()).toBe(false);
2827
});
2928

3029
it('should handle multiple focus cycles', () => {
31-
const { component, target } = createComponent();
30+
const { component, focusableChildEl } = createComponent();
3231

33-
target.dispatchEvent(new FocusEvent('focus'));
34-
expect(component.isFocused()).toBe(true);
32+
focusableChildEl.dispatchEvent(new FocusEvent('focus'));
33+
expect(component.childFocused()).toBe(true);
3534

36-
target.dispatchEvent(new FocusEvent('blur'));
37-
expect(component.isFocused()).toBe(false);
35+
focusableChildEl.dispatchEvent(new FocusEvent('blur'));
36+
expect(component.childFocused()).toBe(false);
3837

39-
target.dispatchEvent(new FocusEvent('focus'));
40-
expect(component.isFocused()).toBe(true);
38+
focusableChildEl.dispatchEvent(new FocusEvent('focus'));
39+
expect(component.childFocused()).toBe(true);
4140
});
4241
});
4342

4443
describe('child element destroy', () => {
4544
it('should reset to false when element is destroyed', () => {
4645
@Component({
4746
template: `
48-
@if (show()) {
47+
@if (childShown()) {
4948
<input #input />
5049
}
5150
`,
5251
})
5352
class ConditionalComponent {
5453
readonly input = viewChild<ElementRef>('input');
55-
readonly isFocused = elementFocus(this.input);
56-
readonly show = signal(true);
54+
readonly childFocused = elementFocus(this.input);
55+
readonly childShown = signal(true);
5756
}
5857

5958
const fixture = TestBed.createComponent(ConditionalComponent);
6059
fixture.detectChanges();
6160
const element = fixture.nativeElement.querySelector('input');
6261
element.dispatchEvent(new FocusEvent('focus'));
6362

64-
expect(fixture.componentInstance.isFocused()).toBe(true);
63+
expect(fixture.componentInstance.childFocused()).toBe(true);
6564

66-
fixture.componentInstance.show.set(false);
65+
fixture.componentInstance.childShown.set(false);
6766
fixture.detectChanges();
6867

69-
expect(fixture.componentInstance.isFocused()).toBe(false);
68+
expect(fixture.componentInstance.childFocused()).toBe(false);
7069
});
7170
});
7271
});
@@ -84,7 +83,7 @@ describe(elementFocus.name, () => {
8483
fixture.detectChanges();
8584
return {
8685
component: fixture.componentInstance,
87-
target: fixture.nativeElement,
86+
componentHostEl: fixture.nativeElement,
8887
};
8988
}
9089

@@ -95,15 +94,15 @@ describe(elementFocus.name, () => {
9594
});
9695

9796
it('should handle multiple focus cycles', () => {
98-
const { component, target } = createComponent();
97+
const { component, componentHostEl } = createComponent();
9998

100-
target.dispatchEvent(new FocusEvent('focus'));
99+
componentHostEl.dispatchEvent(new FocusEvent('focus'));
101100
expect(component.isFocused()).toBe(true);
102101

103-
target.dispatchEvent(new FocusEvent('blur'));
102+
componentHostEl.dispatchEvent(new FocusEvent('blur'));
104103
expect(component.isFocused()).toBe(false);
105104

106-
target.dispatchEvent(new FocusEvent('focus'));
105+
componentHostEl.dispatchEvent(new FocusEvent('focus'));
107106
expect(component.isFocused()).toBe(true);
108107
});
109108
});
@@ -131,4 +130,98 @@ describe(elementFocus.name, () => {
131130
});
132131
});
133132
});
133+
134+
describe('focus control via set()', () => {
135+
@Component({ template: '<input #input />' })
136+
class TestComponent {
137+
readonly input = viewChild<ElementRef>('input');
138+
readonly childFocused = elementFocus(this.input);
139+
}
140+
141+
function createComponent() {
142+
const fixture = TestBed.createComponent(TestComponent);
143+
fixture.detectChanges();
144+
return {
145+
component: fixture.componentInstance,
146+
focusableChildEl: fixture.nativeElement.querySelector('input') as HTMLInputElement,
147+
};
148+
}
149+
150+
it('should set focus when set(true) is called', () => {
151+
const { component, focusableChildEl } = createComponent();
152+
153+
component.childFocused.set(true);
154+
155+
expect(focusableChildEl).toBe(document.activeElement);
156+
expect(component.childFocused()).toBe(true);
157+
});
158+
159+
it('should remove focus when set(false) is called', () => {
160+
const { component, focusableChildEl } = createComponent();
161+
162+
focusableChildEl.focus();
163+
expect(component.childFocused()).toBe(true);
164+
165+
component.childFocused.set(false);
166+
167+
expect(focusableChildEl).not.toBe(document.activeElement);
168+
expect(component.childFocused()).toBe(false);
169+
});
170+
171+
it('should not call focus() if already focused', () => {
172+
const { component, focusableChildEl } = createComponent();
173+
174+
focusableChildEl.focus();
175+
const spy = jest.spyOn(focusableChildEl, 'focus');
176+
177+
component.childFocused.set(true);
178+
179+
expect(spy).not.toHaveBeenCalled();
180+
});
181+
182+
it('should not call blur() if not focused', () => {
183+
const { component, focusableChildEl } = createComponent();
184+
const spy = jest.spyOn(focusableChildEl, 'blur');
185+
186+
component.childFocused.set(false);
187+
188+
expect(spy).not.toHaveBeenCalled();
189+
});
190+
191+
it('should support update() method', () => {
192+
const { component } = createComponent();
193+
194+
component.childFocused.set(true);
195+
expect(component.childFocused()).toBe(true);
196+
197+
component.childFocused.update(v => !v);
198+
expect(component.childFocused()).toBe(false);
199+
});
200+
});
201+
202+
describe('preventScroll option', () => {
203+
@Component({ template: '<input #input />' })
204+
class TestComponent {
205+
readonly input = viewChild<ElementRef>('input');
206+
readonly childFocused = elementFocus(this.input, { preventScroll: true });
207+
}
208+
209+
function createComponent() {
210+
const fixture = TestBed.createComponent(TestComponent);
211+
fixture.detectChanges();
212+
return {
213+
component: fixture.componentInstance,
214+
focusableChildEl: fixture.nativeElement.querySelector('input') as HTMLInputElement,
215+
};
216+
}
217+
218+
it('should call focus with preventScroll option', () => {
219+
const { component, focusableChildEl } = createComponent();
220+
const spy = jest.spyOn(focusableChildEl, 'focus');
221+
222+
component.childFocused.set(true);
223+
224+
expect(spy).toHaveBeenCalledWith({ preventScroll: true });
225+
});
226+
});
134227
});

projects/core/elements/element-focus/index.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { type CreateSignalOptions, signal, type Signal } from '@angular/core';
2-
import { constSignal, setupContext } from '@signality/core/internal';
1+
import { type CreateSignalOptions, signal, type WritableSignal } from '@angular/core';
2+
import { proxySignal, setupContext, toElement } from '@signality/core/internal';
33
import type { MaybeElementSignal, WithInjector } from '@signality/core/types';
44
import { listener } from '@signality/core/browser/listener';
55
import { onDisconnect } from '@signality/core/elements/on-disconnect';
@@ -14,24 +14,29 @@ export interface ElementFocusOptions extends CreateSignalOptions<boolean>, WithI
1414
* @default false
1515
*/
1616
readonly focusVisible?: boolean;
17+
18+
/**
19+
* Prevent scrolling to the element when it is focused.
20+
* @default false
21+
*/
22+
readonly preventScroll?: boolean;
1723
}
1824

1925
/**
2026
* Reactive tracking of focus state on an element.
21-
* Detects when an element gains or loses focus.
27+
* Detects when an element gains or loses focus, and allows programmatically setting focus.
2228
*
2329
* @param target - The element to track focus state on
24-
* @param options - Optional configuration including focusVisible mode and injector
25-
* @returns A signal that is `true` when the element has focus
30+
* @param options - Optional configuration including focusVisible, preventScroll and injector
31+
* @returns A writable signal that is `true` when the element has focus
2632
*
2733
* @example
2834
* ```typescript
2935
* @Component({
3036
* template: `
3137
* <input #input [class.focused]="isFocused()" />
32-
* @if (isFocused()) {
33-
* <p>Input is focused</p>
34-
* }
38+
* <button (click)="isFocused.set(true)">Focus Input</button>
39+
* <button (click)="isFocused.set(false)">Blur Input</button>
3540
* `
3641
* })
3742
* export class FocusDemo {
@@ -43,15 +48,17 @@ export interface ElementFocusOptions extends CreateSignalOptions<boolean>, WithI
4348
export function elementFocus(
4449
target: MaybeElementSignal<HTMLElement>,
4550
options?: ElementFocusOptions
46-
): Signal<boolean> {
51+
): WritableSignal<boolean> {
4752
const { runInContext } = setupContext(options?.injector, elementFocus);
4853

4954
return runInContext(({ isServer }) => {
5055
if (isServer) {
51-
return constSignal(false);
56+
return signal(false, options);
5257
}
5358

5459
const focusVisible = options?.focusVisible ?? false;
60+
const preventScroll = options?.preventScroll ?? false;
61+
5562
const focused = signal<boolean>(false, options);
5663

5764
listener(target, 'focus', e => {
@@ -62,8 +69,21 @@ export function elementFocus(
6269
focused.set(false);
6370
});
6471

65-
onDisconnect(target, () => focused.set(false));
72+
onDisconnect(target, () => {
73+
focused.set(false);
74+
});
6675

67-
return focused.asReadonly();
76+
return proxySignal(focused, {
77+
set: (value: boolean) => {
78+
const el = toElement(target);
79+
const hasFocus = el?.matches(':focus') ?? false;
80+
81+
if (value && !hasFocus) {
82+
el?.focus({ preventScroll });
83+
} else if (!value && hasFocus) {
84+
el?.blur();
85+
}
86+
},
87+
});
6888
});
6989
}

0 commit comments

Comments
 (0)