Repository: manusa/electronim
Pull request #227 (2022-11-26 07:34:16)

✨ settings uses Material 3 checkbox

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=227)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=227&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=227&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=227&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=227&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=227&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=227&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=227&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=227&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=227&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=227&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=227&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=227&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=227&metric=new_coverage&view=list) [100.0% Coverage](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=227&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=227&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=227&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/src/components/checkbox.mjs b/src/components/checkbox.mjs
new file mode 100644
index 0000000..bccf004
--- /dev/null
+++ b/src/components/checkbox.mjs
@@ -0,0 +1,52 @@
+/*
+   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, Icon} from './index.mjs';
+
+/**
+ * A Checkbox based on Material design (3) guidelines.
+ *
+ * @param checked whether the checkbox is checked or not.
+ * @param onClick callback function to be executed when the checkbox is clicked.
+ * @param label optional label to be displayed along with the checkbox.
+ * @param value the checkbox value.
+ * @param properties any other properties to be added to the checkbox.
+ */
+export const Checkbox = ({
+  checked,
+  onClick,
+  label,
+  value,
+  ...properties
+}) => {
+  let checkboxClass = 'material3 checkbox';
+  if (checked) {
+    checkboxClass += ' checked';
+  }
+  const onCheck = e => {
+    e.preventDefault();
+    e.stopPropagation();
+    onClick(e);
+  };
+  return html`
+    <label class=${checkboxClass} onClick=${onCheck} ...${properties}>
+      <div class='checkbox__box'>
+        <${Icon} className='checkbox__icon'>${checked ? Icon.check : ''}</${Icon}>
+      </div>
+      ${label && html`<span class='checkbox__label'>${label}</span>`}
+      <input type='checkbox' class='checkbox__input' value=${value} checked=${checked} />
+    </label>
+  `;
+};
diff --git a/src/components/form/checkbox.mjs b/src/components/form/checkbox.mjs
deleted file mode 100644
index c95620f..0000000
--- a/src/components/form/checkbox.mjs
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
-   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 Checkbox = ({label, title = '', icon, checked, value, onClick, ...properties}) => (html`
-  <div class='control'>
-    <label class='checkbox' title=${title}>
-      <input type='checkbox' checked=${checked} title=${title} value=${value} onClick=${onClick} ...${properties} />
-      ${icon && html`<i class='checkbox__icon fas ${icon}'></i>`} ${label}
-    </label>
-  </div>
-`);
diff --git a/src/components/form/field.mjs b/src/components/form/field.mjs
deleted file mode 100644
index 3cad060..0000000
--- a/src/components/form/field.mjs
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
-   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, sizes} from '../index.mjs';
-
-const FieldContainer = ({className = '', children, ...properties}) => (html`
-  <div class=${`field ${className}`} ...${properties}>
-    ${children}
-  </div>
-`);
-
-export const Field = ({label, children, ...properties}) => (html`
-  <${FieldContainer} ...${properties}>
-    ${label && html`<label class='label'>${label}</label>`}
-    <div class='control'>${children}</div>
-  </${FieldContainer}>
-`);
-
-export const HorizontalField = ({label, labelSize = sizes.normal, children, ...properties}) => {
-  const labelClassName = `field-label ${labelSize}`;
-  return html`
-    <${FieldContainer} className='is-horizontal' ...${properties}>
-      ${label && html`<div class=${labelClassName}><label class='label'>${label}</label></div>`}
-      <div class='field-body'>
-        <div class='control'>${children}</div>
-      </div>
-    </${FieldContainer}>
-  `;
-};
diff --git a/src/components/form/index.mjs b/src/components/form/index.mjs
deleted file mode 100644
index 180a666..0000000
--- a/src/components/form/index.mjs
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
-   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.
- */
-
-export const sizes = {
-  small: 'is-small',
-  normal: 'is-normal',
-  medium: 'is-medium',
-  large: 'is-large'
-};
-
-export {Checkbox} from './checkbox.mjs';
-export {Field, HorizontalField} from './field.mjs';
diff --git a/src/components/icon.mjs b/src/components/icon.mjs
index a00cb82..85750bc 100644
--- a/src/components/icon.mjs
+++ b/src/components/icon.mjs
@@ -27,7 +27,7 @@ export const Icon = ({
   className = '',
   ...properties
 }) => html`
-  <span class=${`.material3.icon material-icon ${className}`} ...${properties}>${children}</span>
+  <span class=${`material3 icon material-icon ${className}`} ...${properties}>${children}</span>
 `;
 
 Icon.add = '\ue145';
@@ -35,6 +35,9 @@ Icon.apps = '\ue5c3';
 Icon.arrowBack = '\ue5c4';
 Icon.arrowCircleUp = '\uf182';
 Icon.arrowDropDown = '\ue5c5';
+Icon.check = '\ue5ca';
+Icon.checkBox = '\ue834';
+Icon.checkBoxOutlineBlank = '\ue835';
 Icon.delete = '\ue872';
 Icon.expandMore = '\ue5cf';
 Icon.help = '\ue887';
diff --git a/src/components/index.mjs b/src/components/index.mjs
index f2f2f23..03513cb 100644
--- a/src/components/index.mjs
+++ b/src/components/index.mjs
@@ -17,7 +17,7 @@ export {APP_EVENTS, ELECTRONIM_VERSION} from '../../bundles/constants.mjs';
 export {html, render, useLayoutEffect, useReducer, useState} from '../../bundles/preact.mjs';
 
 export {Card} from './card.mjs';
-export {Checkbox, Field, HorizontalField, sizes} from './form/index.mjs';
+export {Checkbox} from './checkbox.mjs';
 export {Icon} from './icon.mjs';
 export {IconButton} from './icon-button.mjs';
 export {Logo} from './electronim.mjs';
diff --git a/src/components/switch.mjs b/src/components/switch.mjs
index d8c9f47..728458c 100644
--- a/src/components/switch.mjs
+++ b/src/components/switch.mjs
@@ -31,8 +31,13 @@ export const Switch = ({
   if (checked) {
     switchClass += ' switch--checked';
   }
+  const onSwitch = e => {
+    e.preventDefault();
+    e.stopPropagation();
+    onClick(e);
+  };
   return html`
-    <div class=${switchClass} onClick=${onClick} ...${properties}>
+    <div class=${switchClass} onClick=${onSwitch} ...${properties}>
       <span class='switch__track' />
       <span class='switch__thumb' />
       <input type='checkbox' class='switch__input' checked=${checked} />
diff --git a/src/settings/settings.browser.css b/src/settings/settings.browser.css
index c62cac5..3e4a247 100644
--- a/src/settings/settings.browser.css
+++ b/src/settings/settings.browser.css
@@ -130,7 +130,3 @@
         column-count: 5;
     }
 }
-
-.settings__dictionaries .control input[type="checkbox"] {
-    margin-right: var(--default-spacing);
-}
diff --git a/src/settings/settings.services.browser.mjs b/src/settings/settings.services.browser.mjs
index 7f1a071..f3d037f 100644
--- a/src/settings/settings.services.browser.mjs
+++ b/src/settings/settings.services.browser.mjs
@@ -35,24 +35,18 @@ const ExpandButton = ({dispatch, id, expanded = false}) => {
 
 const TabAdvancedSettings = (
   {dispatch, id, sandboxed = false}
-) => {
-  const onSandboxClick = event => {
-    event.stopPropagation();
-    toggleTabProperty(dispatch, 'sandboxed', id)();
-  };
-  return html`
-    <div class='settings__tab-advanced'>
-      <${SettingsOption}
-        onClick=${onSandboxClick}
-        checked=${sandboxed}
-        icon=${sandboxedIcon(sandboxed)}
-        label='Sandbox'
-        className='sandboxed-toggle'
-        title='Use an isolated/sandboxed session for this tab'
-      />
-    </div>
-  `;
-};
+) => html`
+  <div class='settings__tab-advanced'>
+    <${SettingsOption}
+      onClick=${toggleTabProperty(dispatch, 'sandboxed', id)}
+      checked=${sandboxed}
+      icon=${sandboxedIcon(sandboxed)}
+      label='Sandbox'
+      className='sandboxed-toggle'
+      title='Use an isolated/sandboxed session for this tab'
+    />
+  </div>
+`;
 
 const TabEntry = ({
   dispatch, invalidTabs, id, expanded, url, disabled, disableNotifications = false, ...tab
diff --git a/src/settings/settings.spell-check.browser.mjs b/src/settings/settings.spell-check.browser.mjs
index aed5627..d99a784 100644
--- a/src/settings/settings.spell-check.browser.mjs
+++ b/src/settings/settings.spell-check.browser.mjs
@@ -21,12 +21,15 @@ import {
   toggleDictionary,
   useNativeSpellChecker, isPaneActive
 } from './settings.reducer.browser.mjs';
+import {SettingsOption} from './settings.common.browser.mjs';
 
 const LanguageEntry = ({dispatch, languageKey, name, enabled = false}) => html`
-  <${Checkbox} label=${`${name} (${languageKey})`} checked=${enabled}
-    value=${languageKey}
-    onClick=${toggleDictionary({dispatch, languageKey})}
-  />
+  <div className='settings__language-entry'>
+    <${Checkbox} label=${`${name} (${languageKey})`}
+      checked=${enabled} value=${languageKey}
+      onClick=${toggleDictionary({dispatch, languageKey})}
+    />
+  </div>
 `;
 
 export const SpellCheckPane = ({dispatch, state}) => {
@@ -35,13 +38,12 @@ export const SpellCheckPane = ({dispatch, state}) => {
   return isPaneActive(state)(SpellCheckPane.id) && html`
     <h2 class='title'>Spell check</h2>
     <${Card} className='settings__spell-check'>
-      <div class='settings__spell-check-common'>
-        <${Checkbox}
-            label='Use Native Spell Checker' checked=${useNative} value=${useNative}
-            onClick="${toggleUseNativeSpellChecker({dispatch})}"
-            data-testid='use-native-spell-checker'
-        />
-      </div>
+      <${SettingsOption}
+          className='settings__use-native-spell-checker'
+          label='Use Native Spell Checker'
+          checked=${useNative}
+          onClick=${toggleUseNativeSpellChecker({dispatch})}
+      />
       <${Card.Divider} />
       <div class='settings__dictionaries'>${
   Object.entries(dictionaries(state))
diff --git a/src/styles/base/variables.css b/src/styles/base/variables.css
index 02f50c4..5f8970a 100644
--- a/src/styles/base/variables.css
+++ b/src/styles/base/variables.css
@@ -21,4 +21,5 @@
   --material3-field-height: 40px;
   --material3-field-padding-no-icon: 16px;
   --material3-icon-button-size: 24px;
+  --material3-transition-duration: 0.2s;
 }
diff --git a/src/styles/components/card.css b/src/styles/components/card.css
index 399123b..d8c3d55 100644
--- a/src/styles/components/card.css
+++ b/src/styles/components/card.css
@@ -43,7 +43,7 @@
   right: 0;
   z-index: -1;
   opacity: 0;
-  transition: opacity 200ms linear;
+  transition: opacity var(--material3-transition-duration) linear;
 }
 
 .electronim .material3.card.interactive.elevated:hover::before {
diff --git a/src/styles/components/checkbox.css b/src/styles/components/checkbox.css
index 95c7ac6..e6a59fb 100644
--- a/src/styles/components/checkbox.css
+++ b/src/styles/components/checkbox.css
@@ -13,12 +13,71 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
+:root {
+  --material3-checkbox-icon-size: 18px;
+  --material3-checkbox-border-width: 2px;
+}
 
-.electronim .control .checkbox {
+.electronim .material3.checkbox {
+  display: inline-flex;
+  position: relative;
   user-select: none;
+  cursor: pointer;
+  height: 40px;
+  gap: 12px;
+  align-items: center;
+}
+
+.electronim .material3.checkbox .checkbox__box {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: content-box; /* Required to provide relative height/width/top/left values for state layer */
+  --size: calc(var(--material3-checkbox-icon-size) - var(--material3-checkbox-border-width) * 2);
+  height: var(--size);
+  width: var(--size);
+  min-width: var(--size);
+  border: var(--material3-checkbox-border-width) solid var(--md-sys-color-on-surface);
+  border-radius: 2px;
+  transition: background var(--material3-transition-duration) ease-out;
+}
+
+.electronim .material3.checkbox.checked .checkbox__box {
+  background-color: var(--md-sys-color-primary);
+  border-color: transparent;
+}
+
+.electronim .material3.checkbox .checkbox__box .checkbox__icon {
+  font-size: 18px;
+  font-weight: bold;
+  opacity: 0;
+  transition: opacity var(--material3-transition-duration) ease-out;
+}
+
+.electronim .material3.checkbox.checked .checkbox__box .checkbox__icon {
+  opacity: 1;
+  color: var(--md-sys-color-on-primary);
+}
+
+.electronim .material3.checkbox .checkbox__box::before {
+  content: '';
+  background: var(--md-sys-color-primary);
+  position: absolute;
+  top: -92.8571429%;
+  left: -92.8571429%;
+  height: 285.7142857%; /* 40/(18-2-2) */
+  width: 285.7142857%;
+  border-radius: 100px;
+  z-index: -1;
+  opacity: 0;
+  transition: opacity var(--material3-transition-duration) linear;
+}
+
+.electronim .material3.checkbox .checkbox__box:hover::before {
+  opacity: var(--md-sys-state-hover-state-layer-opacity);
 }
 
-.electronim .control .checkbox .checkbox__icon {
-  width: calc(1em + var(--default-spacing));
-  text-align: center;
+.electronim .material3.checkbox .checkbox__input {
+  appearance: none;
 }
diff --git a/src/styles/components/icon-button.css b/src/styles/components/icon-button.css
index 7c191b9..4ad9624 100644
--- a/src/styles/components/icon-button.css
+++ b/src/styles/components/icon-button.css
@@ -45,7 +45,7 @@
   border-radius: 100px;
   z-index: -1;
   opacity: 0;
-  transition: opacity 200ms linear;
+  transition: opacity var(--material3-transition-duration) linear;
 }
 
 .electronim .material3.icon-button:hover::before {
diff --git a/src/styles/components/menu.css b/src/styles/components/menu.css
index d16fe86..fda5a4e 100644
--- a/src/styles/components/menu.css
+++ b/src/styles/components/menu.css
@@ -51,7 +51,7 @@
   right: 0;
   z-index: 1;
   opacity: 0;
-  transition: opacity 200ms linear;
+  transition: opacity var(--material3-transition-duration) linear;
 }
 
 .electronim .material3.menu a.menu-item:hover::before {
diff --git a/src/styles/components/text-field.css b/src/styles/components/text-field.css
index c83c682..acb2246 100644
--- a/src/styles/components/text-field.css
+++ b/src/styles/components/text-field.css
@@ -20,7 +20,6 @@
   --material3-text-field-outlined-border-color: var(--md-sys-color-outline);
   --material3-text-field-outlined-border-color-focused: var(--md-sys-color-primary);
   --material3-text-field-outlined-border-color-errored: var(--md-sys-color-error);
-  --material3-text-field-transition-duration: 0.2s;
 }
 
 .electronim .material3.text-field {
@@ -71,9 +70,9 @@
   color: var(--md-sys-color-on-surface);
   line-height: calc(var(--material3-field-height) + var(--label-offset) * 2);
   transition:
-    line-height var(--material3-text-field-transition-duration) ease,
-    font-size var(--material3-text-field-transition-duration) ease,
-    color var(--material3-text-field-transition-duration) ease;
+    line-height var(--material3-transition-duration) ease,
+    font-size var(--material3-transition-duration) ease,
+    color var(--material3-transition-duration) ease;
 }
 
 .electronim .material3.text-field.outlined.focused .text-field__label,
@@ -126,7 +125,7 @@
   font-family: var(--md-sys-typescale-body-large-font);
   font-size: var(--md-sys-typescale-body-large-size);
   opacity: 0;
-  transition: opacity var(--material3-text-field-transition-duration) ease;
+  transition: opacity var(--material3-transition-duration) ease;
 }
 
 .electronim .material3.text-field.focused.empty .text-field__placeholder {


Test patch:
diff --git a/src/settings/__tests__/settings.spell-check.browser.test.mjs b/src/settings/__tests__/settings.spell-check.browser.test.mjs
index c8657ae..d5fd18d 100644
--- a/src/settings/__tests__/settings.spell-check.browser.test.mjs
+++ b/src/settings/__tests__/settings.spell-check.browser.test.mjs
@@ -36,7 +36,7 @@ describe('Settings (Spell check) in Browser test suite', () => {
   test('toggle use native spell checker, should check use native spell checker', async () => {
     // Given
     const $useNativeSpellChecker = $spellCheckContainer
-      .querySelector('.settings__spell-check-common input[data-testid=use-native-spell-checker]');
+      .querySelector('.settings__use-native-spell-checker input.switch__input');
     expect($useNativeSpellChecker.checked).toBe(false);
     // When
     fireEvent.click($useNativeSpellChecker);
@@ -72,7 +72,7 @@ describe('Settings (Spell check) in Browser test suite', () => {
     test('when native, dictionaries should intersect available', async () => {
       // Given
       const $useNativeSpellChecker = $spellCheckContainer
-        .querySelector('.settings__spell-check-common input[data-testid=use-native-spell-checker]');
+        .querySelector('.settings__use-native-spell-checker input.switch__input');
       // When
       fireEvent.click($useNativeSpellChecker);
       // Then