Skip to content

Commit 168f218

Browse files
committed
fix(forms): coordinate interest and popover interactions
Signed-off-by: John Yanarella <jyanarella@nvidia.com>
1 parent 2365253 commit 168f218

5 files changed

Lines changed: 284 additions & 20 deletions

File tree

‎projects/forms/src/internal/controllers/type-interest-invoker.controller.test.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import{html}from'lit';
5-
import{afterEach,describe,expect,it}from'vitest';
5+
import{afterEach,describe,expect,it,vi}from'vitest';
66
import{createFixture,removeFixture,untilEvent}from'@internals/testing';
77

88
import{TypeInterestInvokerController}from'./type-interest-invoker.controller.js';
@@ -12,6 +12,7 @@ type InterestTestEvent = Event & { source: HTMLElement };
1212

1313
classInterestInvokerControllerTestElementextendsHTMLElement{
1414
interestForElement: HTMLElement|null=null;
15+
popoverTargetElement: HTMLElement|null=null;
1516
#controllers =newSet<ReactiveController>();
1617

1718
constructor(){
@@ -41,6 +42,7 @@ describe('InterestInvokerController', () => {
4142

4243
afterEach(()=>{
4344
removeFixture(fixture);
45+
vi.restoreAllMocks();
4446
});
4547

4648
it('should dispatch interest and loseinterest events from pointer hover',async()=>{
@@ -62,7 +64,7 @@ describe('InterestInvokerController', () => {
6264
expect((awaitloseinterest).source).toBe(element);
6365
});
6466

65-
it('should dispatch interest and loseinterest events from focus and blur',async()=>{
67+
it('should dispatch interest and loseinterest events from focus-visible focus and blur',async()=>{
6668
fixture=awaitcreateFixture(html`
6769
<interest-invoker-controller-test-elementinterestfor="target"></interest-invoker-controller-test-element>
6870
<divid="target"></div>
@@ -73,12 +75,54 @@ describe('InterestInvokerController', () => {
7375
consttarget=fixture.querySelector<HTMLElement>('#target')!;
7476
constinterest=untilEvent<InterestTestEvent>(target,'interest');
7577
constloseinterest=untilEvent<InterestTestEvent>(target,'loseinterest');
78+
constfocusVisibleMatch=vi.spyOn(element,'matches').mockReturnValue(true);
7679

7780
element.dispatchEvent(newFocusEvent('focus'));
7881
element.dispatchEvent(newFocusEvent('blur'));
7982

8083
expect((awaitinterest).source).toBe(element);
8184
expect((awaitloseinterest).source).toBe(element);
85+
expect(focusVisibleMatch).toHaveBeenCalledWith(':focus-visible');
86+
});
87+
88+
it('should not dispatch interest from focus that is not focus-visible',async()=>{
89+
fixture=awaitcreateFixture(html`
90+
<interest-invoker-controller-test-elementinterestfor="target"></interest-invoker-controller-test-element>
91+
<divid="target"></div>
92+
`);
93+
constelement=fixture.querySelector<InterestInvokerControllerTestElement>(
94+
'interest-invoker-controller-test-element'
95+
)!;
96+
consttarget=fixture.querySelector<HTMLElement>('#target')!;
97+
constinterest=vi.fn();
98+
target.addEventListener('interest',interest);
99+
constfocusVisibleMatch=vi.spyOn(element,'matches').mockReturnValue(false);
100+
101+
element.dispatchEvent(newFocusEvent('focus'));
102+
103+
expect(interest).not.toHaveBeenCalled();
104+
expect(focusVisibleMatch).toHaveBeenCalledWith(':focus-visible');
105+
});
106+
107+
it('should not dispatch interest while its popover target is open',async()=>{
108+
fixture=awaitcreateFixture(html`
109+
<interest-invoker-controller-test-elementinterestfor="tooltip"></interest-invoker-controller-test-element>
110+
<divid="tooltip"></div>
111+
<divid="popover" popover>popover</div>
112+
`);
113+
constelement=fixture.querySelector<InterestInvokerControllerTestElement>(
114+
'interest-invoker-controller-test-element'
115+
)!;
116+
consttooltip=fixture.querySelector<HTMLElement>('#tooltip')!;
117+
constpopover=fixture.querySelector<HTMLElement>('[popover]')!;
118+
constinterest=vi.fn();
119+
tooltip.addEventListener('interest',interest);
120+
element.popoverTargetElement=popover;
121+
popover.showPopover();
122+
123+
element.dispatchEvent(newMouseEvent('mouseenter'));
124+
125+
expect(interest).not.toHaveBeenCalled();
82126
});
83127

84128
it('should support direct interestForElement references',async()=>{

‎projects/forms/src/internal/controllers/type-interest-invoker.controller.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type InterestEvent = Event & { source: HTMLElement };
88

99
typeInterestInvokerHost=ReactiveElement&{
1010
interestForElement: HTMLElement|null;
11+
popoverTargetElement: HTMLElement|null;
1112
};
1213

1314
exportclassTypeInterestInvokerController<TextendsInterestInvokerHost>implementsReactiveController{
@@ -29,12 +30,20 @@ export class TypeInterestInvokerController<T extends InterestInvokerHost> implem
2930
this.host.removeEventListener('blur',this.#onLoseInterest);
3031
}
3132

32-
#onInterest =()=>{
33+
#onInterest =(event: Event)=>{
34+
if(event.type==='focus'&&!this.host.matches(':focus-visible')){
35+
return;
36+
}
37+
38+
if(this.host.popoverTargetElement?.matches(':popover-open')){
39+
return;
40+
}
41+
3342
this.#updateInterestForElement();
3443
if(this.host.interestForElement){
35-
constevent=newEvent('interest',{cancelable: true})asInterestEvent;
36-
event.source=this.host;
37-
this.host.interestForElement.dispatchEvent(event);
44+
constinterest=newEvent('interest',{cancelable: true})asInterestEvent;
45+
interest.source=this.host;
46+
this.host.interestForElement.dispatchEvent(interest);
3847
}
3948
};
4049

‎projects/forms/src/internal/controllers/type-popover-trigger.controller.test.ts‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { ReactiveController } from './types.js';
1111

1212
classPopoverTriggerControllerTestElementextendsHTMLElement{
1313
disabled=false;
14+
interestForElement: HTMLElement|null=null;
1415
popoverTargetAction?: PopoverTargetAction;
1516
popoverTargetElement: HTMLElement|null=null;
1617
popovertarget?: string;
@@ -64,6 +65,102 @@ describe('PopoverTriggerController', () => {
6465
expect(togglePopover).toHaveBeenCalledWith({source: element});
6566
});
6667

68+
it('should not invoke a popover from a canceled click',async()=>{
69+
fixture=awaitcreateFixture(html`
70+
<popover-trigger-controller-test-element></popover-trigger-controller-test-element>
71+
<divid="popover" popover>popover</div>
72+
`);
73+
constelement=fixture.querySelector<PopoverTriggerControllerTestElement>(
74+
'popover-trigger-controller-test-element'
75+
)!;
76+
constpopover=fixture.querySelector<HTMLElement>('[popover]')!;
77+
consttogglePopover=vi.spyOn(popover,'togglePopover').mockImplementation(()=>false);
78+
fixture.addEventListener('click',event=>event.preventDefault(),{capture: true});
79+
element.popoverTargetElement=popover;
80+
81+
element.dispatchEvent(newMouseEvent('click',{bubbles: true,cancelable: true}));
82+
83+
expect(togglePopover).not.toHaveBeenCalled();
84+
});
85+
86+
it('should follow a changed popover target id',async()=>{
87+
fixture=awaitcreateFixture(html`
88+
<popover-trigger-controller-test-element></popover-trigger-controller-test-element>
89+
<divid="tooltip"></div>
90+
<divid="first" popover>first</div>
91+
<divid="second" popover>second</div>
92+
`);
93+
constelement=fixture.querySelector<PopoverTriggerControllerTestElement>(
94+
'popover-trigger-controller-test-element'
95+
)!;
96+
consttooltip=fixture.querySelector<HTMLElement>('#tooltip')!;
97+
constfirst=fixture.querySelector<HTMLElement>('#first')!;
98+
constsecond=fixture.querySelector<HTMLElement>('#second')!;
99+
constloseInterest=vi.fn();
100+
tooltip.addEventListener('loseinterest',loseInterest);
101+
102+
element.interestForElement=tooltip;
103+
element.popoverTargetAction='show';
104+
element.popovertarget='first';
105+
awaitemulateClick(element);
106+
first.hidePopover();
107+
108+
loseInterest.mockClear();
109+
element.popovertarget='second';
110+
awaitemulateClick(element);
111+
112+
expect(element.popoverTargetElement).toBe(second);
113+
expect(first.matches(':popover-open')).toBe(false);
114+
expect(second.matches(':popover-open')).toBe(true);
115+
expect(loseInterest).toHaveBeenCalledOnce();
116+
});
117+
118+
it('should preserve tooltip interest when opening is canceled',async()=>{
119+
fixture=awaitcreateFixture(html`
120+
<popover-trigger-controller-test-element></popover-trigger-controller-test-element>
121+
<divid="tooltip"></div>
122+
<divid="popover" popover>popover</div>
123+
`);
124+
constelement=fixture.querySelector<PopoverTriggerControllerTestElement>(
125+
'popover-trigger-controller-test-element'
126+
)!;
127+
consttooltip=fixture.querySelector<HTMLElement>('#tooltip')!;
128+
constpopover=fixture.querySelector<HTMLElement>('[popover]')!;
129+
constloseInterest=vi.fn();
130+
tooltip.addEventListener('loseinterest',loseInterest);
131+
popover.addEventListener('beforetoggle',event=>event.preventDefault());
132+
element.interestForElement=tooltip;
133+
element.popoverTargetElement=popover;
134+
135+
awaitemulateClick(element);
136+
137+
expect(popover.matches(':popover-open')).toBe(false);
138+
expect(loseInterest).not.toHaveBeenCalled();
139+
});
140+
141+
it('should preserve tooltip interest when toggling a popover closed',async()=>{
142+
fixture=awaitcreateFixture(html`
143+
<popover-trigger-controller-test-element></popover-trigger-controller-test-element>
144+
<divid="tooltip"></div>
145+
<divid="popover" popover>popover</div>
146+
`);
147+
constelement=fixture.querySelector<PopoverTriggerControllerTestElement>(
148+
'popover-trigger-controller-test-element'
149+
)!;
150+
consttooltip=fixture.querySelector<HTMLElement>('#tooltip')!;
151+
constpopover=fixture.querySelector<HTMLElement>('[popover]')!;
152+
constloseInterest=vi.fn();
153+
tooltip.addEventListener('loseinterest',loseInterest);
154+
element.interestForElement=tooltip;
155+
element.popoverTargetElement=popover;
156+
popover.showPopover();
157+
158+
awaitemulateClick(element);
159+
160+
expect(popover.matches(':popover-open')).toBe(false);
161+
expect(loseInterest).not.toHaveBeenCalled();
162+
});
163+
67164
it('should support direct popover target properties and show or hide actions',async()=>{
68165
fixture=awaitcreateFixture(html`
69166
<popover-trigger-controller-test-element></popover-trigger-controller-test-element>
@@ -107,7 +204,7 @@ describe('PopoverTriggerController', () => {
107204
expect(togglePopover).not.toHaveBeenCalled();
108205
});
109206

110-
it('should pass anchored popover sources',async()=>{
207+
it('should pass the anchor as the popover source when the target has an anchor',async()=>{
111208
fixture=awaitcreateFixture(html`
112209
<popover-trigger-controller-test-element></popover-trigger-controller-test-element>
113210
<divid="anchor"></div>
@@ -128,6 +225,39 @@ describe('PopoverTriggerController', () => {
128225
expect(showPopover).toHaveBeenCalledWith({source: anchor});
129226
});
130227

228+
it('should hand over tooltip interest after an anchored target popover opens from the trigger',async()=>{
229+
fixture=awaitcreateFixture(html`
230+
<popover-trigger-controller-test-elementinterestfor="tooltip" popovertarget="popover"></popover-trigger-controller-test-element>
231+
<divid="tooltip"></div>
232+
<divid="anchor"></div>
233+
<divid="popover" popover>popover</div>
234+
`);
235+
constelement=fixture.querySelector<PopoverTriggerControllerTestElement>(
236+
'popover-trigger-controller-test-element'
237+
)!;
238+
consttooltip=fixture.querySelector<HTMLElement>('#tooltip')!;
239+
constanchor=fixture.querySelector<HTMLElement>('#anchor')!;
240+
constpopover=fixture.querySelector<HTMLElement>('[popover]')!;
241+
letinterestSource: HTMLElement|undefined;
242+
letpopoverSource: HTMLElement|null|undefined;
243+
tooltip.addEventListener('loseinterest',event=>{
244+
interestSource=(eventasEvent&{source: HTMLElement}).source;
245+
});
246+
popover.addEventListener('beforetoggle',event=>{
247+
popoverSource=(eventasToggleEvent).source;
248+
});
249+
250+
Object.defineProperty(popover,'anchor',{configurable: true,value: 'anchor'});
251+
element.interestForElement=tooltip;
252+
element.popovertarget='popover';
253+
awaitemulateClick(element);
254+
255+
expect(element.popoverTargetElement).toBe(popover);
256+
expect(popover.matches(':popover-open')).toBe(true);
257+
expect(popoverSource).toBe(anchor);
258+
expect(interestSource).toBe(element);
259+
});
260+
131261
it('should remove click behavior on disconnect',async()=>{
132262
fixture=awaitcreateFixture(html`
133263
<popover-trigger-controller-test-element></popover-trigger-controller-test-element>

‎projects/forms/src/internal/controllers/type-popover-trigger.controller.ts‎

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import { getFlattenedDOMTree, getHostAnchor } from '../utils.js';
66
importtype{PopoverAnchorElement}from'../utils.js';
77
importtype{ReactiveController,ReactiveElement}from'./types.js';
88

9+
typeInterestEvent=Event&{source: HTMLElement};
10+
911
typePopoverTriggerHost=ReactiveElement&{
1012
disabled: boolean;
13+
interestForElement: HTMLElement|null;
1114
popoverTargetAction?: PopoverTargetAction;
1215
popoverTargetElement: HTMLElement|null;
1316
popovertarget?: string;
@@ -26,30 +29,80 @@ export class TypePopoverTriggerController<T extends PopoverTriggerHost> implemen
2629
this.host.removeEventListener('click',this.#onClick);
2730
}
2831

29-
#onClick =()=>{
30-
letsource=this.hostasHTMLElement;
31-
letpopoverTargetElement=this.host.popoverTargetElement;
32+
#resolvedPopoverTarget: HTMLElement|null|undefined;
33+
34+
#resolvePopoverTarget(){
35+
constpopoverTargetElement=this.host.popoverTargetElement;
36+
constcontrollerResolvedTarget=popoverTargetElement===this.#resolvedPopoverTarget;
3237

33-
if(!popoverTargetElement&&this.host.popovertarget){
34-
popoverTargetElement=
35-
getFlattenedDOMTree(this.host.getRootNode()).find(element=>element.id===this.host.popovertarget)??null;
36-
this.host.popoverTargetElement=popoverTargetElement;
38+
if(!this.host.popovertarget){
39+
this.#resolvedPopoverTarget =undefined;
40+
if(controllerResolvedTarget){
41+
this.host.popoverTargetElement=null;
42+
returnnull;
43+
}
44+
45+
returnpopoverTargetElement;
3746
}
3847

39-
if((popoverTargetElementasPopoverAnchorElement)?.anchor){
40-
source=getHostAnchor(popoverTargetElementasPopoverAnchorElement);
48+
if(popoverTargetElement&&!controllerResolvedTarget){
49+
returnpopoverTargetElement;
4150
}
4251

43-
if(!popoverTargetElement||this.host.disabled){
52+
constresolvedPopoverTarget=
53+
getFlattenedDOMTree(this.host.getRootNode()).find(element=>element.id===this.host.popovertarget)??null;
54+
if(resolvedPopoverTarget!==popoverTargetElement){
55+
this.host.popoverTargetElement=resolvedPopoverTarget;
56+
}
57+
this.#resolvedPopoverTarget =resolvedPopoverTarget;
58+
59+
returnresolvedPopoverTarget;
60+
}
61+
62+
#onClick =(event: MouseEvent)=>{
63+
if(event.defaultPrevented||this.host.disabled){
4464
return;
4565
}
4666

67+
constpopoverTargetElement=this.#resolvePopoverTarget();
68+
if(!popoverTargetElement){
69+
return;
70+
}
71+
72+
if(this.#invokePopover(popoverTargetElement)){
73+
this.#handoverInterest();
74+
}
75+
};
76+
77+
#getPopoverSource(popoverTargetElement: HTMLElement){
78+
if((popoverTargetElementasPopoverAnchorElement).anchor){
79+
returngetHostAnchor(popoverTargetElementasPopoverAnchorElement);
80+
}
81+
82+
returnthis.hostasHTMLElement;
83+
}
84+
85+
#invokePopover(popoverTargetElement: HTMLElement){
4786
if(this.host.popoverTargetAction==='hide'){
4887
popoverTargetElement.hidePopover();
49-
}elseif(this.host.popoverTargetAction==='show'){
88+
returnfalse;
89+
}
90+
91+
constsource=this.#getPopoverSource(popoverTargetElement);
92+
if(this.host.popoverTargetAction==='show'){
5093
popoverTargetElement.showPopover({ source });
5194
}else{
5295
popoverTargetElement.togglePopover({ source });
5396
}
54-
};
97+
98+
returnpopoverTargetElement.matches(':popover-open');
99+
}
100+
101+
#handoverInterest(){
102+
if(this.host.interestForElement){
103+
constloseInterest=newEvent('loseinterest',{cancelable: true})asInterestEvent;
104+
loseInterest.source=this.host;
105+
this.host.interestForElement.dispatchEvent(loseInterest);
106+
}
107+
}
55108
}

0 commit comments

Comments
 (0)