Skip to content

Commit 917cc7e

Browse files
authored
HParams: Add custom modal widget (#6414)
## Motivation for features / changes This is intended to be used with #6412 and the custom context menu we intend to build in the future. ## Screenshots of UI changes (or N/A) See #6412 for screenshots of a component being projected inside this. ## Alternate designs / implementations considered (or N/A) ### Why build it this way rather than use `MatDialog`? * `MatDialog` does not have all the built in logic to handle closing when clicking outside if the background is not enabled. * `MatDialog` seems to have issues with form components? (I consistently get errors related to property changes when I open it) * Content projection provides a nice declarative way of showcasing the what should appear in the modal. ### What are the downsides of doing it this way? * This is additional code we have to maintain * Clicking someplace else where event propagation is disabled will not result in the modal being closed.
1 parent b65bcb4 commit 917cc7e

File tree

5 files changed

+286
-0
lines changed

5 files changed

+286
-0
lines changed

tensorboard/webapp/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ tf_ng_web_test_suite(
293293
"//tensorboard/webapp/widgets:resize_detector_testing",
294294
"//tensorboard/webapp/widgets/card_fob:card_fob_test",
295295
"//tensorboard/webapp/widgets/content_wrapping_input:content_wrapping_input_tests",
296+
"//tensorboard/webapp/widgets/custom_modal:custom_modal_test",
296297
"//tensorboard/webapp/widgets/data_table:data_table_test",
297298
"//tensorboard/webapp/widgets/dropdown:dropdown_tests",
298299
"//tensorboard/webapp/widgets/experiment_alias:experiment_alias_test",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
load("//tensorboard/defs:defs.bzl", "tf_ng_module", "tf_ts_library")
2+
3+
package(default_visibility = ["//tensorboard:internal"])
4+
5+
tf_ng_module(
6+
name = "custom_modal",
7+
srcs = [
8+
"custom_modal_component.ts",
9+
"custom_modal_module.ts",
10+
],
11+
deps = [
12+
"@npm//@angular/common",
13+
"@npm//@angular/core",
14+
"@npm//rxjs",
15+
],
16+
)
17+
18+
tf_ts_library(
19+
name = "custom_modal_test",
20+
testonly = True,
21+
srcs = [
22+
"custom_modal_test.ts",
23+
],
24+
deps = [
25+
":custom_modal",
26+
"@npm//@angular/common",
27+
"@npm//@angular/core",
28+
"@npm//@angular/platform-browser",
29+
"@npm//@types/jasmine",
30+
],
31+
)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/* Copyright 2023 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {
16+
Component,
17+
EventEmitter,
18+
Output,
19+
ViewChild,
20+
ElementRef,
21+
HostListener,
22+
ContentChild,
23+
OnInit,
24+
} from '@angular/core';
25+
import {BehaviorSubject, tap} from 'rxjs';
26+
27+
export interface ModalContent {
28+
onRender?: () => void;
29+
}
30+
31+
@Component({
32+
selector: 'custom-modal',
33+
template: `
34+
<div class="content" #content (click)="$event.stopPropagation()">
35+
<ng-container *ngIf="visible$ | async">
36+
<ng-content></ng-content>
37+
</ng-container>
38+
</div>
39+
`,
40+
styles: [
41+
`
42+
:host {
43+
position: fixed;
44+
top: -64px; /* The height of the top bar */
45+
left: 0;
46+
z-index: 9001;
47+
}
48+
49+
.content {
50+
position: absolute;
51+
}
52+
`,
53+
],
54+
})
55+
export class CustomModalComponent implements OnInit {
56+
@Output() onOpen = new EventEmitter<void>();
57+
@Output() onClose = new EventEmitter<void>();
58+
59+
readonly visible$ = new BehaviorSubject(false);
60+
61+
@ViewChild('content', {static: false})
62+
private readonly content!: ElementRef;
63+
64+
private clickListener: () => void = this.close.bind(this);
65+
66+
ngOnInit() {
67+
this.visible$.subscribe((visible) => {
68+
// Wait until after the next browser frame.
69+
window.requestAnimationFrame(() => {
70+
if (visible) {
71+
this.onOpen.emit();
72+
} else {
73+
this.onClose.emit();
74+
}
75+
});
76+
});
77+
}
78+
79+
public openAtPosition(position: {x: number; y: number}) {
80+
this.content.nativeElement.style.left = position.x + 'px';
81+
this.content.nativeElement.style.top = position.y + 'px';
82+
this.visible$.next(true);
83+
document.addEventListener('click', this.clickListener);
84+
}
85+
86+
@HostListener('document:keydown.escape', ['$event'])
87+
public close() {
88+
document.removeEventListener('click', this.clickListener);
89+
this.visible$.next(false);
90+
}
91+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/* Copyright 2023 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
16+
import {CommonModule} from '@angular/common';
17+
import {NgModule} from '@angular/core';
18+
import {CustomModalComponent} from './custom_modal_component';
19+
20+
@NgModule({
21+
declarations: [CustomModalComponent],
22+
imports: [CommonModule],
23+
exports: [CustomModalComponent],
24+
})
25+
export class CustomModalModule {}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/* Copyright 2023 The TensorFlow Authors. All Rights Reserved.
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
==============================================================================*/
15+
import {ComponentFixture, TestBed} from '@angular/core/testing';
16+
import {CustomModalComponent} from './custom_modal_component';
17+
import {CommonModule} from '@angular/common';
18+
import {
19+
Component,
20+
ElementRef,
21+
EventEmitter,
22+
Output,
23+
ViewChild,
24+
} from '@angular/core';
25+
26+
function waitFrame() {
27+
return new Promise((resolve) => window.requestAnimationFrame(resolve));
28+
}
29+
30+
@Component({
31+
selector: 'testable-modal',
32+
template: `<custom-modal #modal (onOpen)="setOpen()" (onClose)="setClosed()">
33+
<div>My great content</div>
34+
</custom-modal> `,
35+
})
36+
class TestableComponent {
37+
@ViewChild('modal', {static: false})
38+
modalComponent!: CustomModalComponent;
39+
40+
@ViewChild('content', {static: false})
41+
content!: ElementRef;
42+
43+
isOpen = false;
44+
45+
@Output() onOpen = new EventEmitter();
46+
@Output() onClose = new EventEmitter();
47+
48+
setOpen() {
49+
this.isOpen = true;
50+
this.onOpen.emit();
51+
}
52+
53+
setClosed() {
54+
this.isOpen = false;
55+
this.onClose.emit();
56+
}
57+
58+
close() {
59+
this.modalComponent.close();
60+
}
61+
62+
getContentStyle() {
63+
return (this.modalComponent as any).content.nativeElement.style;
64+
}
65+
66+
public openAtPosition(position: {x: number; y: number}) {
67+
this.modalComponent.openAtPosition(position);
68+
}
69+
}
70+
71+
describe('custom modal', () => {
72+
beforeEach(async () => {
73+
await TestBed.configureTestingModule({
74+
declarations: [TestableComponent, CustomModalComponent],
75+
imports: [CommonModule],
76+
}).compileComponents();
77+
});
78+
79+
it('waits a frame before emitting onOpen or onClose', async () => {
80+
const fixture = TestBed.createComponent(TestableComponent);
81+
fixture.detectChanges();
82+
fixture.componentInstance.openAtPosition({x: 0, y: 0});
83+
expect(fixture.componentInstance.isOpen).toBeFalse();
84+
await waitFrame();
85+
expect(fixture.componentInstance.isOpen).toBeTrue();
86+
fixture.componentInstance.close();
87+
fixture.detectChanges();
88+
await waitFrame();
89+
expect(fixture.componentInstance.isOpen).toBeFalse();
90+
});
91+
92+
describe('openAtPosition', () => {
93+
it('applies top and left offsets', () => {
94+
const fixture = TestBed.createComponent(TestableComponent);
95+
fixture.detectChanges();
96+
fixture.componentInstance.openAtPosition({x: 20, y: 10});
97+
expect(fixture.componentInstance.getContentStyle().top).toEqual('10px');
98+
expect(fixture.componentInstance.getContentStyle().left).toEqual('20px');
99+
});
100+
101+
it('emits onOpen', async () => {
102+
const fixture = TestBed.createComponent(TestableComponent);
103+
const spy = spyOn(fixture.componentInstance.onOpen, 'emit');
104+
fixture.detectChanges();
105+
fixture.componentInstance.openAtPosition({x: 20, y: 10});
106+
expect(spy).not.toHaveBeenCalled();
107+
await waitFrame();
108+
expect(spy).toHaveBeenCalled();
109+
});
110+
});
111+
112+
describe('closing behavior', () => {
113+
let fixture: ComponentFixture<TestableComponent>;
114+
beforeEach(async () => {
115+
fixture = TestBed.createComponent(TestableComponent);
116+
fixture.detectChanges();
117+
fixture.componentInstance.openAtPosition({x: 0, y: 0});
118+
await waitFrame();
119+
});
120+
121+
it('closes when escape key is pressed', async () => {
122+
expect(fixture.componentInstance.isOpen).toBeTrue();
123+
const event = new KeyboardEvent('keydown', {key: 'escape'});
124+
document.dispatchEvent(event);
125+
await waitFrame();
126+
127+
expect(fixture.componentInstance.isOpen).toBeFalse();
128+
});
129+
130+
it('closes when user clicks outside modal', async () => {
131+
expect(fixture.componentInstance.isOpen).toBeTrue();
132+
document.body.click();
133+
await waitFrame();
134+
135+
expect(fixture.componentInstance.isOpen).toBeFalse();
136+
});
137+
});
138+
});

0 commit comments

Comments
 (0)