From 7a7500c5b56213832031a06fc1cfcd475996e906 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 21:02:18 +0000
Subject: [PATCH 1/2] Initial plan
From 3288b81c6fe1154bc62e5cc8f5df8914c57ea620 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Mar 2026 21:12:56 +0000
Subject: [PATCH 2/2] Add reusable tb-list widget with Material list,
selection, and disabled support
Co-authored-by: cdavalos7 <31576209+cdavalos7@users.noreply.github.com>
---
tensorboard/webapp/angular/BUILD | 9 ++
tensorboard/webapp/widgets/list/BUILD | 42 ++++++
.../webapp/widgets/list/list_component.scss | 33 +++++
.../webapp/widgets/list/list_component.ts | 56 +++++++
.../widgets/list/list_component_test.ts | 139 ++++++++++++++++++
.../webapp/widgets/list/list_module.ts | 28 ++++
6 files changed, 307 insertions(+)
create mode 100644 tensorboard/webapp/widgets/list/BUILD
create mode 100644 tensorboard/webapp/widgets/list/list_component.scss
create mode 100644 tensorboard/webapp/widgets/list/list_component.ts
create mode 100644 tensorboard/webapp/widgets/list/list_component_test.ts
create mode 100644 tensorboard/webapp/widgets/list/list_module.ts
diff --git a/tensorboard/webapp/angular/BUILD b/tensorboard/webapp/angular/BUILD
index af6894dce3b..ce0f0fef577 100644
--- a/tensorboard/webapp/angular/BUILD
+++ b/tensorboard/webapp/angular/BUILD
@@ -204,6 +204,15 @@ tf_ts_library(
],
)
+# This is a dummy rule used as a @angular/material/list dependency.
+tf_ts_library(
+ name = "expect_angular_material_list",
+ srcs = [],
+ deps = [
+ "@npm//@angular/material",
+ ],
+)
+
# This is a dummy rule used as a @angular/material/menu dependency.
tf_ts_library(
name = "expect_angular_material_menu",
diff --git a/tensorboard/webapp/widgets/list/BUILD b/tensorboard/webapp/widgets/list/BUILD
new file mode 100644
index 00000000000..f1a24217397
--- /dev/null
+++ b/tensorboard/webapp/widgets/list/BUILD
@@ -0,0 +1,42 @@
+load("//tensorboard/defs:defs.bzl", "tf_ng_module", "tf_sass_binary", "tf_ts_library")
+
+package(default_visibility = ["//tensorboard:internal"])
+
+licenses(["notice"])
+
+tf_sass_binary(
+ name = "list_styles",
+ src = "list_component.scss",
+)
+
+tf_ng_module(
+ name = "list",
+ srcs = [
+ "list_component.ts",
+ "list_module.ts",
+ ],
+ assets = [
+ ":list_styles",
+ ],
+ deps = [
+ "//tensorboard/webapp/angular:expect_angular_material_list",
+ "@npm//@angular/common",
+ "@npm//@angular/core",
+ ],
+)
+
+tf_ts_library(
+ name = "list_tests",
+ testonly = True,
+ srcs = ["list_component_test.ts"],
+ deps = [
+ ":list",
+ "//tensorboard/webapp/angular:expect_angular_core_testing",
+ "//tensorboard/webapp/angular:expect_angular_material_list",
+ "//tensorboard/webapp/angular:expect_angular_platform_browser_animations",
+ "@npm//@angular/common",
+ "@npm//@angular/core",
+ "@npm//@angular/platform-browser",
+ "@npm//@types/jasmine",
+ ],
+)
diff --git a/tensorboard/webapp/widgets/list/list_component.scss b/tensorboard/webapp/widgets/list/list_component.scss
new file mode 100644
index 00000000000..5c0aa6e5e23
--- /dev/null
+++ b/tensorboard/webapp/widgets/list/list_component.scss
@@ -0,0 +1,33 @@
+/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+==============================================================================*/
+
+.list-item {
+ cursor: pointer;
+
+ &.selected {
+ background-color: rgba(0, 0, 0, 0.08);
+ font-weight: 500;
+ }
+
+ &.disabled {
+ cursor: default;
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ &:hover:not(.disabled) {
+ background-color: rgba(0, 0, 0, 0.04);
+ }
+}
diff --git a/tensorboard/webapp/widgets/list/list_component.ts b/tensorboard/webapp/widgets/list/list_component.ts
new file mode 100644
index 00000000000..dedc35baf6c
--- /dev/null
+++ b/tensorboard/webapp/widgets/list/list_component.ts
@@ -0,0 +1,56 @@
+/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+==============================================================================*/
+import {Component, EventEmitter, Input, Output} from '@angular/core';
+
+export interface ListItem {
+ value: any;
+ label: string;
+ disabled?: boolean;
+}
+
+/**
+ * A generic list component that displays items and supports single selection.
+ */
+@Component({
+ standalone: false,
+ selector: 'tb-list',
+ template: `
+
+
+ {{ item.label }}
+
+
+ `,
+ styleUrls: [`list_component.css`],
+})
+export class ListComponent {
+ @Input() items: ListItem[] = [];
+ @Input() selectedValue: any = null;
+
+ @Output() selectedValueChange = new EventEmitter();
+
+ onItemClick(item: ListItem): void {
+ if (!item.disabled) {
+ this.selectedValueChange.emit(item.value);
+ }
+ }
+}
diff --git a/tensorboard/webapp/widgets/list/list_component_test.ts b/tensorboard/webapp/widgets/list/list_component_test.ts
new file mode 100644
index 00000000000..b325a681d9a
--- /dev/null
+++ b/tensorboard/webapp/widgets/list/list_component_test.ts
@@ -0,0 +1,139 @@
+/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+==============================================================================*/
+import {CommonModule} from '@angular/common';
+import {Component, Input} from '@angular/core';
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {MatListModule} from '@angular/material/list';
+import {By} from '@angular/platform-browser';
+import {NoopAnimationsModule} from '@angular/platform-browser/animations';
+import {ListComponent, ListItem} from './list_component';
+
+@Component({
+ standalone: false,
+ selector: 'testing-component',
+ template: `
+
+ `,
+})
+class TestableComponent {
+ @Input() items: ListItem[] = [];
+ @Input() selectedValue: any = null;
+ @Input() onSelectedValueChange!: (value: any) => void;
+}
+
+describe('tb-list', () => {
+ let selectionChangeSpy: jasmine.Spy;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [CommonModule, MatListModule, NoopAnimationsModule],
+ declarations: [ListComponent, TestableComponent],
+ }).compileComponents();
+ });
+
+ function createFixture(input: {
+ items?: ListItem[];
+ selectedValue?: any;
+ }): ComponentFixture {
+ const fixture = TestBed.createComponent(TestableComponent);
+ fixture.componentInstance.items = input.items ?? [];
+ fixture.componentInstance.selectedValue = input.selectedValue ?? null;
+ selectionChangeSpy = jasmine.createSpy();
+ fixture.componentInstance.onSelectedValueChange = selectionChangeSpy;
+ return fixture;
+ }
+
+ it('renders a list of items', () => {
+ const fixture = createFixture({
+ items: [
+ {value: 'a', label: 'Item A'},
+ {value: 'b', label: 'Item B'},
+ ],
+ });
+ fixture.detectChanges();
+
+ const listItems = fixture.debugElement.queryAll(By.css('.list-item'));
+ expect(listItems.length).toBe(2);
+ expect(listItems[0].nativeElement.textContent).toContain('Item A');
+ expect(listItems[1].nativeElement.textContent).toContain('Item B');
+ });
+
+ it('marks the selected item with the selected class', () => {
+ const fixture = createFixture({
+ items: [
+ {value: 'a', label: 'Item A'},
+ {value: 'b', label: 'Item B'},
+ ],
+ selectedValue: 'b',
+ });
+ fixture.detectChanges();
+
+ const listItems = fixture.debugElement.queryAll(By.css('.list-item'));
+ expect(listItems[0].nativeElement.classList).not.toContain('selected');
+ expect(listItems[1].nativeElement.classList).toContain('selected');
+ });
+
+ it('emits selectedValueChange when an item is clicked', () => {
+ const fixture = createFixture({
+ items: [{value: 'a', label: 'Item A'}],
+ });
+ fixture.detectChanges();
+
+ const listItem = fixture.debugElement.query(By.css('.list-item'));
+ listItem.nativeElement.click();
+ fixture.detectChanges();
+
+ expect(selectionChangeSpy).toHaveBeenCalledOnceWith('a');
+ });
+
+ it('does not emit selectedValueChange when a disabled item is clicked', () => {
+ const fixture = createFixture({
+ items: [{value: 'a', label: 'Item A', disabled: true}],
+ });
+ fixture.detectChanges();
+
+ const listItem = fixture.debugElement.query(By.css('.list-item'));
+ listItem.nativeElement.click();
+ fixture.detectChanges();
+
+ expect(selectionChangeSpy).not.toHaveBeenCalled();
+ });
+
+ it('adds the disabled class to disabled items', () => {
+ const fixture = createFixture({
+ items: [
+ {value: 'a', label: 'Item A', disabled: true},
+ {value: 'b', label: 'Item B'},
+ ],
+ });
+ fixture.detectChanges();
+
+ const listItems = fixture.debugElement.queryAll(By.css('.list-item'));
+ expect(listItems[0].nativeElement.classList).toContain('disabled');
+ expect(listItems[1].nativeElement.classList).not.toContain('disabled');
+ });
+
+ it('renders an empty list when no items are provided', () => {
+ const fixture = createFixture({items: []});
+ fixture.detectChanges();
+
+ const listItems = fixture.debugElement.queryAll(By.css('.list-item'));
+ expect(listItems.length).toBe(0);
+ });
+});
diff --git a/tensorboard/webapp/widgets/list/list_module.ts b/tensorboard/webapp/widgets/list/list_module.ts
new file mode 100644
index 00000000000..1d915ba1022
--- /dev/null
+++ b/tensorboard/webapp/widgets/list/list_module.ts
@@ -0,0 +1,28 @@
+/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+==============================================================================*/
+import {CommonModule} from '@angular/common';
+import {NgModule} from '@angular/core';
+import {MatListModule} from '@angular/material/list';
+import {ListComponent} from './list_component';
+
+/**
+ * Provides a ListComponent for rendering a selectable list of items.
+ */
+@NgModule({
+ declarations: [ListComponent],
+ exports: [ListComponent],
+ imports: [CommonModule, MatListModule],
+})
+export class ListModule {}