+
+
+
+
diff --git a/modules/ui/src/app/components/list-item/list-item.component.scss b/modules/ui/src/app/components/list-item/list-item.component.scss
new file mode 100644
index 000000000..457d60e41
--- /dev/null
+++ b/modules/ui/src/app/components/list-item/list-item.component.scss
@@ -0,0 +1,59 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * 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
+ *
+ * https://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.
+ */
+@use 'variables';
+@use 'colors';
+@use '@angular/material' as mat;
+
+// Customize the entire app. Change :root to your selector if you want to scope the styles.
+::ng-deep :root {
+ @include mat.menu-overrides(
+ (
+ item-label-text-color: colors.$on-surface,
+ item-icon-color: colors.$on-surface-variant,
+ container-color: colors.$surface,
+ item-label-text-weight: 400,
+ item-label-text-size: 16px,
+ item-label-text-font: variables.$font-text,
+ item-hover-state-layer-color: colors.$secondary-container,
+ item-with-icon-leading-spacing: 24px,
+ item-with-icon-trailing-spacing: 24px,
+ )
+ );
+}
+
+::ng-deep {
+ .list-item-menu {
+ width: 220px;
+ }
+}
+
+.list-item {
+ border-radius: variables.$corner-large;
+ background-color: colors.$surface-container;
+ height: 92px;
+ padding: 0 24px 0 32px;
+ display: grid;
+ grid-template-columns: auto 40px;
+ align-items: center;
+}
+
+.example-menu {
+ left: 12px;
+}
+
+.list-item-menu-item {
+ padding: 16px 24px;
+}
diff --git a/modules/ui/src/app/components/list-item/list-item.component.spec.ts b/modules/ui/src/app/components/list-item/list-item.component.spec.ts
new file mode 100644
index 000000000..5bc51fc6a
--- /dev/null
+++ b/modules/ui/src/app/components/list-item/list-item.component.spec.ts
@@ -0,0 +1,101 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * 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
+ *
+ * https://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 { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatMenuModule } from '@angular/material/menu';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { ListItemComponent } from './list-item.component';
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import {
+ MatMenuHarness,
+ MatMenuItemHarness,
+} from '@angular/material/menu/testing';
+
+describe('ListItemComponent', () => {
+ let component: ListItemComponent;
+ let fixture: ComponentFixture;
+ let compiled: HTMLElement;
+ let loader: HarnessLoader;
+ const testActions = [
+ { action: 'Edit', icon: 'edit_icon' },
+ { action: 'Delete', icon: 'delete_icon' },
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ ListItemComponent,
+ MatButtonModule,
+ MatIconModule,
+ MatMenuModule,
+ NoopAnimationsModule,
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ListItemComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('actions', testActions);
+ compiled = fixture.nativeElement as HTMLElement;
+ loader = TestbedHarnessEnvironment.loader(fixture);
+ fixture.detectChanges();
+ });
+
+ describe('menu', () => {
+ let menu;
+ let items: MatMenuItemHarness[];
+
+ beforeEach(async () => {
+ menu = await loader.getHarness(MatMenuHarness);
+ await menu.open();
+ items = await menu.getItems();
+ });
+
+ it('should render actions in the menu', async () => {
+ expect(items.length).toBe(2);
+
+ const text0 = await items[0].getText();
+ const text1 = await items[1].getText();
+
+ expect(text0).toContain('Edit');
+ expect(text1).toContain('Delete');
+ });
+
+ it('should emit the correct action when a menu item is clicked', async () => {
+ const menuItemClickedSpy = spyOn(component.menuItemClicked, 'emit');
+
+ await items[0].click();
+
+ expect(menuItemClickedSpy).toHaveBeenCalledWith('Edit');
+ });
+
+ it('should display the correct icons for actions', async () => {
+ const text0 = await items[0].getText();
+ const text1 = await items[1].getText();
+
+ expect(text0).toContain('edit');
+ expect(text1).toContain('delete');
+ });
+ });
+
+ it('should render menu button', () => {
+ const button = compiled.querySelector('button[mat-icon-button]');
+
+ expect(button).toBeTruthy();
+ expect(button?.textContent).toContain('more_vert');
+ });
+});
diff --git a/modules/ui/src/app/components/list-item/list-item.component.ts b/modules/ui/src/app/components/list-item/list-item.component.ts
new file mode 100644
index 000000000..ea9d8afbf
--- /dev/null
+++ b/modules/ui/src/app/components/list-item/list-item.component.ts
@@ -0,0 +1,39 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * 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
+ *
+ * https://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, input, output } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { CommonModule } from '@angular/common';
+import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
+import { EntityAction } from '../../model/entity-action';
+
+@Component({
+ selector: 'app-list-item',
+ imports: [
+ MatButtonModule,
+ MatIconModule,
+ MatMenuTrigger,
+ MatMenu,
+ MatMenuItem,
+ CommonModule,
+ ],
+ templateUrl: './list-item.component.html',
+ styleUrl: './list-item.component.scss',
+})
+export class ListItemComponent {
+ actions = input([]);
+ menuItemClicked = output();
+}
diff --git a/modules/ui/src/app/components/list-layout/list-layout.component.html b/modules/ui/src/app/components/list-layout/list-layout.component.html
index dedf67cd1..652988e27 100644
--- a/modules/ui/src/app/components/list-layout/list-layout.component.html
+++ b/modules/ui/src/app/components/list-layout/list-layout.component.html
@@ -13,7 +13,53 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
- 0; else empty">
+ 0; else empty">
+
+
+
+