Repository: manusa/electronim
Pull request #203 (2022-11-14 06:30:28)

💄 app-menu uses Material

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=manusa_electronim&pullRequest=203)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=203&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=203&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=203&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=203&resolved=false&types=CODE_SMELL)

[![100.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/100-16px.png '100.0%')](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=203&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=203&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=203&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=203&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/src/app-menu/app-menu.browser.css b/src/app-menu/app-menu.browser.css
index a8a725d..36347af 100644
--- a/src/app-menu/app-menu.browser.css
+++ b/src/app-menu/app-menu.browser.css
@@ -47,24 +47,8 @@ html.electronim,
   top: var(--tab-container-height);
   background: var(--scrim-color);
 }
-.electronim .app-menu .dropdown,
-.electronim .app-menu .dropdown-menu {
+.electronim .app-menu .menu {
   position: absolute;
   left: auto;
   right: 0;
 }
-
-.electronim .app-menu .dropdown-menu {
-  padding: var(--default-spacing) var(--default-spacing)  0 0;
-}
-
-.electronim .app-menu .dropdown-item {
-  cursor: pointer;
-  user-select: none;
-  color: var(--color-gray-darker);
-  text-decoration: none;
-}
-
-.electronim .app-menu .dropdown-item.is-active {
-  color: var(--color-white);
-}
diff --git a/src/app-menu/app-menu.browser.mjs b/src/app-menu/app-menu.browser.mjs
index dbf822f..3ba86fc 100644
--- a/src/app-menu/app-menu.browser.mjs
+++ b/src/app-menu/app-menu.browser.mjs
@@ -13,18 +13,12 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
-import {html, render, DropDown, Icon} from '../components/index.mjs';
+import {html, render, Menu} from '../components/index.mjs';
 
 const {aboutOpenDialog, close, helpOpenDialog, settingsOpenDialog} = window.electron;
 
 const getAppMenu = () => document.querySelector('.app-menu');
 
-const MenuItem = ({icon, label, onClick, testId}) => html`
-  <${DropDown.Item} data-testid=${testId} onClick=${onClick}>
-    <${Icon} icon=${icon}>${label}</${Icon}>
-  </${DropDown.Item}>
-`;
-
 const AppMenu = () => {
   const noBubbling = func => e => {
     e.preventDefault();
@@ -34,16 +28,14 @@ const AppMenu = () => {
   return (html`
     <div class='wrapper' onClick=${close}>
       <div class='scrim'>
-        <${DropDown} active=${true}>
-        <${DropDown.Menu}>
-          <${MenuItem} icon='fas fa-question-circle' label='Help' testId='help-menu-entry'
+        <${Menu}>
+          <${Menu.Item} icon='\ue887' label='Help' data-testid='help-menu-entry'
             onClick=${noBubbling(helpOpenDialog)} />
-          <${MenuItem} icon='fas fa-cog' label='Settings' testId='settings-menu-entry'
+          <${Menu.Item} icon='\ue8b8' label='Settings' data-testid='settings-menu-entry'
             onClick=${noBubbling(settingsOpenDialog)} />
-          <${MenuItem} icon='fas fa-info-circle' label='About' testId='about-menu-entry'
+          <${Menu.Item} icon='\ue88e' label='About' data-testid='about-menu-entry'
             onClick=${noBubbling(aboutOpenDialog)} />
-        </${DropDown.Menu}>
-        </${DropDown}>
+        </${Menu}>
       </div>
     </div>
   `);
diff --git a/src/app-menu/index.html b/src/app-menu/index.html
index 1519412..655a995 100644
--- a/src/app-menu/index.html
+++ b/src/app-menu/index.html
@@ -25,8 +25,5 @@ limitations under the License.
   <body>
     <div class="app-menu"></div>
     <script src="./app-menu.browser.mjs" type="module"></script>
-    <script src="../../bundles/lib/bulma/css/bulma.css.js"></script>
-    <script src="../../bundles/lib/bulma/bulma.dark.scss.js"></script>
-    <script src="../../bundles/lib/@fortawesome/fontawesome-free/css/all.css.js"></script>
   </body>
 </html>
diff --git a/src/components/icon-button.mjs b/src/components/icon-button.mjs
index 4a0cfe9..c6775f9 100644
--- a/src/components/icon-button.mjs
+++ b/src/components/icon-button.mjs
@@ -15,6 +15,14 @@
  */
 import {html} from './index.mjs';
 
+/**
+ An Icon Button based on Material design (3) guidelines.
+
+ @param icon represented using a Material Icon codepoint.
+ @param iconClick callback function to be executed when the icon is clicked.
+ @param disabled whether the button is disabled or not.
+ @param className additional classes to be added to the button.
+ */
 export const IconButton = ({
   icon,
   onClick = () => {},
diff --git a/src/components/index.mjs b/src/components/index.mjs
index 951fa76..9cc5db9 100644
--- a/src/components/index.mjs
+++ b/src/components/index.mjs
@@ -21,5 +21,6 @@ export {Checkbox, Field, HorizontalField, Select, sizes} from './form/index.mjs'
 export {DropDown} from './drop-down.mjs';
 export {Icon} from './icon.mjs';
 export {IconButton} from './icon-button.mjs';
+export {Menu} from './menu.mjs';
 export {Panel} from './panel.mjs';
 export {TopAppBar} from './top-app-bar.mjs';
diff --git a/src/components/menu.mjs b/src/components/menu.mjs
new file mode 100644
index 0000000..dc59827
--- /dev/null
+++ b/src/components/menu.mjs
@@ -0,0 +1,35 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   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 {html} from './index.mjs';
+
+export const Menu = ({children}) => html`
+  <div class='material3 menu elevation-2 surface body-large'>
+    ${children}
+  </div>
+`;
+
+Menu.Item = ({
+  icon,
+  label,
+  trailingIcon,
+  ...properties
+}) => html`
+  <a class='menu-item' ...${properties}>
+    <div class='menu-item__leading-icon'>${icon}</div>
+    <div class='menu-item__text'>${label}</div>
+    <div class='menu-item__trailing-icon'>${trailingIcon}</div>
+  </a>
+`;
diff --git a/src/styles/base/material3/colors.css b/src/styles/base/material3/colors.css
index cc3a3e2..1c32b77 100644
--- a/src/styles/base/material3/colors.css
+++ b/src/styles/base/material3/colors.css
@@ -26,7 +26,8 @@
   --md-sys-color-on-tertiary: var(--md-ref-palette-tertiary100);
   --md-sys-color-tertiary-container: var(--md-ref-palette-tertiary90);
   --md-sys-color-tertiary: var(--md-ref-palette-tertiary40);
-  --md-sys-color-shadow: var(--md-ref-palette-neutral0-rgb);
+  --md-sys-color-shadow: var(--md-ref-palette-neutral0);
+  --md-sys-color-shadow-rgb: var(--md-ref-palette-neutral0-rgb);
   --md-sys-color-error: var(--md-ref-palette-error40);
   --md-sys-color-outline: var(--md-ref-palette-neutral-variant50);
   --md-sys-color-outline-variant: var(--md-ref-palette-neutral-variant80);
@@ -60,7 +61,8 @@
     --md-sys-color-on-tertiary: var(--md-ref-palette-tertiary20);
     --md-sys-color-tertiary-container: var(--md-ref-palette-tertiary30);
     --md-sys-color-tertiary: var(--md-ref-palette-tertiary80);
-    --md-sys-color-shadow: var(--md-ref-palette-neutral0-rgb);
+    --md-sys-color-shadow: var(--md-ref-palette-neutral0);
+    --md-sys-color-shadow-rgb: var(--md-ref-palette-neutral0-rgb);
     --md-sys-color-error: var(--md-ref-palette-error80);
     --md-sys-color-outline: var(--md-ref-palette-neutral-variant60);
     --md-sys-color-outline-variant: var(--md-ref-palette-neutral-variant30);
diff --git a/src/styles/base/material3/elevation.css b/src/styles/base/material3/elevation.css
index 4f64bf2..7c54a08 100644
--- a/src/styles/base/material3/elevation.css
+++ b/src/styles/base/material3/elevation.css
@@ -17,51 +17,48 @@
 /* https://github.com/material-foundation/material-tokens/blob/main/css/elevation.css */
 
 :root {
-  /* Surface tint color */
-  --md-sys-elevation-surface-tint-color: var(--md-sys-color-primary);
-  /* +5 */
-  --md-sys-elevation-level5-value: 12px;
-  --md-sys-elevation-level5-unit: 1px;
-  --md-sys-elevation-level5: 12px;
-  /* +4 */
-  --md-sys-elevation-level4-value: 8px;
-  --md-sys-elevation-level4-unit: 1px;
-  --md-sys-elevation-level4: 8px;
-  /* +3 */
-  --md-sys-elevation-level3-value: 6px;
-  --md-sys-elevation-level3-unit: 1px;
-  --md-sys-elevation-level3: 6px;
-  /* +2 */
-  --md-sys-elevation-level2-value: 3px;
-  --md-sys-elevation-level2-unit: 1px;
-  --md-sys-elevation-level2: 3px;
-  /* +1 */
-  --md-sys-elevation-level1-value: 1px;
-  --md-sys-elevation-level1-unit: 1px;
-  --md-sys-elevation-level1: 1px;
-  /* 0 */
-  --md-sys-elevation-level0-value: 0px;
-  --md-sys-elevation-level0-unit: 1px;
-  --md-sys-elevation-level0: 0px;
+  /* offset-x | offset-y | blur-radius | spread-radius | color */
+  --md-sys-elevation-0:
+      0px 0px 0px 0px rgb(var(--md-sys-color-shadow-rgb) / 0.2),
+      0px 0px 0px 0px rgb(var(--md-sys-color-shadow-rgb) / 0.14),
+      0px 0px 0px 0px rgb(var(--md-sys-color-shadow-rgb) / 0.12);
+  --md-sys-elevation-1:
+      0px 3px 1px -2px rgb(var(--md-sys-color-shadow-rgb) / 0.2),
+      0px 2px 2px 0px rgb(var(--md-sys-color-shadow-rgb) / 0.14),
+      0px 1px 5px 0px rgb(var(--md-sys-color-shadow-rgb) / 0.12);
+  --md-sys-elevation-2:
+      0px 2px 4px -1px rgb(var(--md-sys-color-shadow-rgb) / 0.2),
+      0px 4px 5px 0px rgb(var(--md-sys-color-shadow-rgb) / 0.14),
+      0px 1px 10px 0px rgb(var(--md-sys-color-shadow-rgb) / 0.12);
+  --md-sys-elevation-3:
+      0px 5px 5px -3px rgb(var(--md-sys-color-shadow-rgb) / 0.2),
+      0px 8px 10px 1px rgb(var(--md-sys-color-shadow-rgb) / 0.14),
+      0px 3px 14px 2px rgb(var(--md-sys-color-shadow-rgb) / 0.12);
+  --md-sys-elevation-4:
+      0px 5px 5px -3px rgb(var(--md-sys-color-shadow-rgb) / 0.2),
+      0px 8px 10px 1px rgb(var(--md-sys-color-shadow-rgb) / 0.14),
+      0px 3px 14px 2px rgb(var(--md-sys-color-shadow-rgb) / 0.12);
+  --md-sys-elevation-5:
+      0px 8px 10px -6px rgb(var(--md-sys-color-shadow-rgb) / 0.2),
+      0px 16px 24px 2px rgb(var(--md-sys-color-shadow-rgb) / 0.14),
+      0px 6px 30px 5px rgb(var(--md-sys-color-shadow-rgb) / 0.12);
 }
+
 .elevation-0 {
-  box-shadow: var(--md-sys-elevation-level0);
+  box-shadow: var(--md-sys-elevation-0);
 }
 .elevation-1 {
-  box-shadow:
-    0 2px 1px -1px rgba(var(--md-sys-color-shadow), 0.2),
-    0 1px 1px 0 rgba(var(--md-sys-color-shadow), 0.14),
-    0 1px 3px 0 rgba(var(--md-sys-color-shadow), 0.12);
+  box-shadow: var(--md-sys-elevation-1);
 }
 .elevation-2 {
-  box-shadow: var(--md-sys-elevation-level2);
+  box-shadow: var(--md-sys-elevation-2);
 }
 .elevation-3 {
-  box-shadow: var(--md-sys-elevation-level3);
+  box-shadow: var(--md-sys-elevation-3);
 }
 .elevation-4 {
-  box-shadow: var(--md-sys-elevation-level4);
+  box-shadow: var(--md-sys-elevation-4);
 }
 .elevation-5 {
-  box-shadow: var(--md-sys-elevation-level5);
+  box-shadow: var(--md-sys-elevation-5);
 }
diff --git a/src/styles/base/material3/palette.css b/src/styles/base/material3/palette.css
index 3284244..e108bf7 100644
--- a/src/styles/base/material3/palette.css
+++ b/src/styles/base/material3/palette.css
@@ -84,7 +84,7 @@
   --md-ref-palette-neutral-variant99: #fffbfeff;
   --md-ref-palette-neutral-variant100: #ffffffff;
   --md-ref-palette-neutral0: #000000ff;
-  --md-ref-palette-neutral0-rgb: 0, 0, 0;
+  --md-ref-palette-neutral0-rgb: 0 0 0;
   --md-ref-palette-neutral10: #1c1b1fff;
   --md-ref-palette-neutral20: #313033ff;
   --md-ref-palette-neutral30: #484649ff;
diff --git a/src/styles/base/material3/state-layer.css b/src/styles/base/material3/state-layer.css
index 46d1e74..56e053e 100644
--- a/src/styles/base/material3/state-layer.css
+++ b/src/styles/base/material3/state-layer.css
@@ -16,4 +16,6 @@
 
 :root {
   --md-sys-state-hover-state-layer-opacity: 8%;
+  --md-sys-state-focus-state-layer-opacity: 12%;
+  --md-sys-state-pressed-state-layer-opacity: 12%;
 }
diff --git a/src/styles/components/index.css b/src/styles/components/index.css
index 5f38967..c2c95df 100644
--- a/src/styles/components/index.css
+++ b/src/styles/components/index.css
@@ -16,4 +16,5 @@
 @import './card.css';
 @import './checkbox.css';
 @import './icon-button.css';
+@import './menu.css';
 @import './top-bar.css';
diff --git a/src/styles/components/menu.css b/src/styles/components/menu.css
new file mode 100644
index 0000000..7f461f7
--- /dev/null
+++ b/src/styles/components/menu.css
@@ -0,0 +1,77 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   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.
+ */
+
+:root {
+  --material3-menu-item-size: 24px;
+}
+
+.electronim .material3.menu {
+  position: relative;
+  z-index: 0;
+  min-width: 112px;
+  max-width: 280px;
+  border-radius: 4px;
+  padding: 4px 0;
+  background-color: var(--md-sys-color-surface);
+}
+
+.electronim .material3.menu a.menu-item {
+  position: relative;
+  padding: 0 12px;
+  z-index: 2;
+  outline: none;
+  cursor: pointer;
+  text-decoration: none;
+  line-height: 48px;
+  display: flex;
+  gap: 12px;
+  color: var(--md-sys-color-on-surface);
+}
+
+.electronim .material3.menu a.menu-item::before {
+  content: '';
+  background-color: var(--md-sys-color-on-surface);
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 1;
+  opacity: 0;
+  transition: opacity 200ms linear;
+}
+
+.electronim .material3.menu a.menu-item:hover::before {
+  opacity: var(--md-sys-state-hover-state-layer-opacity);
+}
+
+.electronim .material3.menu a.menu-item:focus::before {
+  opacity: var(--md-sys-state-focus-state-layer-opacity);
+}
+
+.electronim .material3.menu a.menu-item:active::before {
+  opacity: var(--md-sys-state-pressed-state-layer-opacity);
+}
+
+.electronim .material3.menu .menu-item .menu-item__leading-icon,
+.electronim .material3.menu .menu-item .menu-item__trailing-icon {
+  color: var(--md-sys-color-on-surface-variant);
+  font-family: 'Material Symbols Outlined', sans-serif;
+  width: var(--material3-menu-item-size);
+  height: var(--material3-menu-item-size);
+  text-align: center;
+  font-size: var(--material3-icon-button-size);
+}


Test patch:
diff --git a/src/app-menu/__tests__/app-menu.browser.test.mjs b/src/app-menu/__tests__/app-menu.browser.test.mjs
index f043cc2..4ee01cf 100644
--- a/src/app-menu/__tests__/app-menu.browser.test.mjs
+++ b/src/app-menu/__tests__/app-menu.browser.test.mjs
@@ -15,7 +15,7 @@
  */
 import {jest} from '@jest/globals';
 import {loadDOM} from '../../__tests__/index.mjs';
-import {getByTestId, fireEvent, waitFor} from '@testing-library/dom';
+import {getByTestId, fireEvent} from '@testing-library/dom';
 
 describe('App Menu in Browser test suite', () => {
   beforeEach(async () => {
@@ -35,9 +35,9 @@ describe('App Menu in Browser test suite', () => {
     expect(window.electron.close).toHaveBeenCalledTimes(1);
   });
   describe.each([
-    {testId: 'about', icon: 'fas fa-info-circle', label: 'About', expectedFunction: 'aboutOpenDialog'},
-    {testId: 'help', icon: 'fas fa-question-circle', label: 'Help', expectedFunction: 'helpOpenDialog'},
-    {testId: 'settings', icon: 'fas fa-cog', label: 'Settings', expectedFunction: 'settingsOpenDialog'}
+    {testId: 'about', icon: '\ue88e', label: 'About', expectedFunction: 'aboutOpenDialog'},
+    {testId: 'help', icon: '\ue887', label: 'Help', expectedFunction: 'helpOpenDialog'},
+    {testId: 'settings', icon: '\ue8b8', label: 'Settings', expectedFunction: 'settingsOpenDialog'}
   ])('$label entry', ({testId, icon, label, expectedFunction}) => {
     let entry;
     beforeEach(() => {
@@ -45,11 +45,11 @@ describe('App Menu in Browser test suite', () => {
     });
     test('should be visible', () => {
       expect(entry.getAttribute('class'))
-        .toMatch(/^dropdown-item*/);
-      expect(entry.textContent).toBe(label);
+        .toMatch(/^menu-item*/);
+      expect(entry.querySelector('.menu-item__text').textContent).toBe(label);
     });
     test(`should have icon ${icon}`, () => {
-      expect(entry.querySelector('i').getAttribute('class')).toBe(icon);
+      expect(entry.querySelector('.menu-item__leading-icon').textContent).toBe(icon);
     });
     test('click, should invoke function', () => {
       // When
@@ -57,13 +57,6 @@ describe('App Menu in Browser test suite', () => {
       // Then
       expect(window.electron[expectedFunction]).toHaveBeenCalledTimes(1);
     });
-    test('hover, should activate entry', async () => {
-      // When
-      fireEvent.mouseOver(entry);
-      // Then
-      await waitFor(() => expect(entry.getAttribute('class'))
-        .toContain('is-active'));
-    });
   });
 });