Repository: manusa/electronim
Pull request #213 (2022-11-18 04:45:51)

✨ settings is divided into multiple panes

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

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

[![96.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/90-16px.png '96.2%')](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=213&metric=new_coverage&view=list) [96.2% Coverage](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=213&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=213&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=213&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/src/components/card.mjs b/src/components/card.mjs
index 7fd7840..05fce9d 100644
--- a/src/components/card.mjs
+++ b/src/components/card.mjs
@@ -16,7 +16,7 @@
 import {html} from './index.mjs';
 
 /**
- A card based on Material design (3) guidelines.
+ * A card based on Material design (3) guidelines.
  */
 export const Card = ({
   type = Card.types.elevated,
diff --git a/src/components/icon-button.mjs b/src/components/icon-button.mjs
index c6775f9..7b62333 100644
--- a/src/components/icon-button.mjs
+++ b/src/components/icon-button.mjs
@@ -16,12 +16,12 @@
 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.
+ * 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,
diff --git a/src/components/index.mjs b/src/components/index.mjs
index a6fcc00..aa98b71 100644
--- a/src/components/index.mjs
+++ b/src/components/index.mjs
@@ -23,5 +23,6 @@ export {Icon} from './icon.mjs';
 export {IconButton} from './icon-button.mjs';
 export {Logo} from './electronim.mjs';
 export {Menu} from './menu.mjs';
+export {NavigationRail} from './navigation-rail.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
index dc59827..bcb2d03 100644
--- a/src/components/menu.mjs
+++ b/src/components/menu.mjs
@@ -15,6 +15,12 @@
  */
 import {html} from './index.mjs';
 
+/**
+ A menu based on Material design (3) guidelines.
+
+ * @param children the collection of Menu.Item components to be rendered.
+ * @constructor
+ */
 export const Menu = ({children}) => html`
   <div class='material3 menu elevation-2 surface body-large'>
     ${children}
diff --git a/src/components/navigation-rail.mjs b/src/components/navigation-rail.mjs
new file mode 100644
index 0000000..7789bbb
--- /dev/null
+++ b/src/components/navigation-rail.mjs
@@ -0,0 +1,53 @@
+/*
+   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, useState, IconButton} from './index.mjs';
+
+export const NavigationRail = ({
+  children
+}) => {
+  document.body.classList.add('has-navigation-rail');
+  return html`
+    <div class='material3 navigation-rail'>
+      ${children}
+    </div>
+  `;
+};
+
+NavigationRail.Button = ({
+  active = false,
+  icon,
+  onClick,
+  label
+}) => {
+  const [hover, setHover] = useState(false);
+  const onMouseOver = () => setHover(true);
+  const onMouseOut = () => setHover(false);
+  let className = 'navigation-rail-button';
+  if (active) {
+    className += ' active';
+  }
+  if (hover) {
+    className += ' hover';
+  }
+  return html`
+    <a href='#' class=${className}
+       onMouseOver=${onMouseOver} onMouseOut=${onMouseOut} onClick=${onClick}
+    >
+      <${IconButton} className='navigation-rail-button__icon' icon=${icon} />
+      ${label && html`<div class='navigation-rail-button__label'>${label}</div>`}
+    </a>
+  `;
+};
diff --git a/src/settings/settings.browser.css b/src/settings/settings.browser.css
index d073549..3a07aa3 100644
--- a/src/settings/settings.browser.css
+++ b/src/settings/settings.browser.css
@@ -17,6 +17,7 @@
 
 :root {
     --border-color: #dbdbdb;
+    overflow-y: auto;
 }
 
 .settings.container {
diff --git a/src/settings/settings.browser.mjs b/src/settings/settings.browser.mjs
index 46ecb0d..aece49e 100644
--- a/src/settings/settings.browser.mjs
+++ b/src/settings/settings.browser.mjs
@@ -15,103 +15,28 @@
  */
 const {ipcRenderer} = window;
 
-import {APP_EVENTS, html, render, useReducer, Checkbox, IconButton, TopAppBar} from '../components/index.mjs';
 import {
-  ACTIONS, reducer, addTab, dictionariesEnabled, setTabProperty, toggleTabProperty
+  APP_EVENTS, html, render, useReducer, IconButton, NavigationRail, TopAppBar
+} from '../components/index.mjs';
+import {
+  reducer, activatePane, dictionariesEnabled, isPaneActive
 } from './settings.reducer.browser.mjs';
 import {
-  OtherContainer
+  OtherPane
 } from './settings.other.browser.mjs';
 import {
-  SpellCheckContainer
+  ServicesPane
+} from './settings.services.browser.mjs';
+import {
+  SpellCheckPane
 } from './settings.spell-check.browser.mjs';
 
 const settingsRoot = () => document.querySelector('.settings');
 
-const newTabClass = state => {
-  if (state.newTabValue.length === 0) {
-    return '';
-  }
-  return state.newTabValid ? 'is-success' : 'is-danger';
-};
-
-const SettingsButton = ({icon, disabled = false, title, onClick}) => (html`
-  <button class='settings__button button' disabled=${disabled} onClick=${onClick}>
-    <span class='icon is-medium' title='${title}'>
-      <i class='fas ${icon}'></i>
-    </span>
-  </button>
-`);
-
-const ExpandButton = ({dispatch, id, expanded = false}) => {
-  const properties = {
-    icon: expanded ? 'fa-chevron-down' : 'fa-chevron-right',
-    title: expanded ? 'Collapse' : 'Expand (show advanced settings)',
-    onClick: () => dispatch({type: ACTIONS.TOGGLE_TAB_EXPANDED, payload: id})
-  };
-  return html`
-    <${SettingsButton} ...${properties} />
-  `;
-};
-
-const disabledIcon = disabled => (disabled === true ? 'fa-eye-slash' : 'fa-eye');
-const sandboxedIcon = sandboxed => (sandboxed === true ? 'fa-lock' : 'fa-lock-open');
-
-const TabAdvancedSettings = (
-  {dispatch, id, sandboxed = false}
-) => (html`
-  <div class="settings__tab-advanced container">
-    <${Checkbox} label="Sandbox" checked=${sandboxed} value=${id}
-      title='Use an isolated/sandboxed session for this tab'
-      icon=${sandboxedIcon(sandboxed)}
-      onClick=${toggleTabProperty(dispatch, 'sandboxed', id)}
-    />
-  </div>
-`);
-
-const TabEntry = ({
-  dispatch, invalidTabs, id, expanded, url, disabled, disableNotifications = false, ...tab
-}) => (html`
-  <div class='settings__tab ${expanded && 'settings__tab--expanded'} panel-block' data-id=${id}>
-    <div class='settings__tab-main'>
-      <${ExpandButton} dispatch=${dispatch} id=${id} expanded=${expanded} />
-      <div class='control'>
-        <input type='text' class=${`input ${invalidTabs.has(id) ? 'is-danger' : ''}`} name='tabs'
-          oninput=${({target: {value}}) => setTabProperty({dispatch, property: 'url', value, id})}
-          value=${url} />
-      </div>
-      <${SettingsButton} icon=${disabledIcon(disabled)}
-        title=${disabled ? 'Tab disabled. Click to enable' : 'Tab enabled. Click to disable'}
-        onClick=${toggleTabProperty(dispatch, 'disabled', id)}
-      />
-      <${SettingsButton} icon=${disableNotifications ? 'fa-bell-slash' : 'fa-bell'}
-        title=${disableNotifications ? 'Notifications disabled. Click to enable' : 'Notifications enabled. Click to disable'}
-        onClick=${toggleTabProperty(dispatch, 'disableNotifications', id)}
-      />
-      <${SettingsButton} icon='fa-trash' title='Delete tab'
-        onClick=${() => dispatch({type: ACTIONS.REMOVE, payload: {id}})}
-      /> 
-    </div>
-    <${TabAdvancedSettings} dispatch=${dispatch} id=${id} expanded=${expanded} disabled=${disabled}
-      ...${tab}
-    />
-  </div>
-`);
-
-
 const Settings = ({initialState}) => {
   const [state, dispatch] = useReducer(reducer, initialState);
   const enabledDictionaries = dictionariesEnabled(state);
-  const onNewTabInput = ({target: {value}}) => dispatch({
-    type: ACTIONS.UPDATE_NEW_TAB_VALUE,
-    payload: value
-  });
-  const onAddTab = addTab({dispatch});
-  const onNewKeyDown = ({code}) => {
-    if (code === 'Enter' || code === 'NumpadEnter') {
-      onAddTab();
-    }
-  };
+  const onActivatePane = activatePane({dispatch});
   const save = () => ipcRenderer.send(APP_EVENTS.settingsSave, {
     tabs: state.tabs,
     useNativeSpellChecker: state.useNativeSpellChecker,
@@ -121,41 +46,27 @@ const Settings = ({initialState}) => {
   });
   const cancel = () => ipcRenderer.send(APP_EVENTS.closeDialog);
   return html`
-  <${TopAppBar} headline='Settings' icon='\uE5C4' iconClick=${cancel}
-    trailingIcon=${html`<${IconButton}
-        className='settings__submit' icon='\ue161' onClick=${save}
-        disabled=${!state.canSave || state.invalidTabs.size !== 0}/>`}
-  />
-  <div class="container">
-    <div class="form">
-      <nav class="panel">
-        <p class="panel-heading">Tabs</p>
-        <div class="settings__new-tab panel-block">
-          <div class="control">
-            <input type="text"
-              class="input ${newTabClass(state)}"
-              placeholder="https://web.whatsapp.com"
-              value=${state.newTabValue}
-              oninput=${onNewTabInput}
-              onkeydown=${onNewKeyDown}
-            />
-          </div>
-          <${SettingsButton} icon='fa-plus' onClick=${onAddTab} disabled=${!state.newTabValid} />
-        </div>
-        <div class="settings__tabs container field">
-          ${state.tabs.map(tab => (html`
-            <${TabEntry}
-              dispatch=${dispatch} expanded=${state.expandedTabs.includes(tab.id)} invalidTabs=${state.invalidTabs}
-              ...${tab}
-            />
-        `))}
-        </div>
-      </nav>
-      <${SpellCheckContainer} dispatch=${dispatch} state=${state} />
-      <${OtherContainer} dispatch=${dispatch} state=${state} />
+    <${TopAppBar} headline='Settings' icon='\uE5C4' iconClick=${cancel}
+      trailingIcon=${html`<${IconButton}
+          className='settings__submit' icon='\ue161' onClick=${save}
+          disabled=${!state.canSave || state.invalidTabs.size !== 0}/>`}
+    />
+    <${NavigationRail}>
+      <${NavigationRail.Button} label='Services' icon='\ue5c3'
+        active=${isPaneActive(state)(ServicesPane.id)} onClick=${() => onActivatePane(ServicesPane.id)} />
+      <${NavigationRail.Button} label='Spell check' icon='\ue8ce'
+        active=${isPaneActive(state)(SpellCheckPane.id)} onClick=${() => onActivatePane(SpellCheckPane.id)} />
+      <${NavigationRail.Button} label='Other' icon='\ue619'
+        active=${isPaneActive(state)(OtherPane.id)} onClick=${() => onActivatePane(OtherPane.id)} />
+    </${NavigationRail}>
+    <div class="container">
+      <div class="form">
+        <${ServicesPane} dispatch=${dispatch} state=${state} />
+        <${SpellCheckPane} dispatch=${dispatch} state=${state} />
+        <${OtherPane} dispatch=${dispatch} state=${state} />
+      </div>
     </div>
-  </div>
-`;
+  `;
 };
 
 Promise.all([
@@ -168,6 +79,9 @@ Promise.all([
     [currentSettings, availableDictionaries, availableNativeDictionaries, enabledDictionaries]
   ) => {
     const initialState = {
+      activePane: ServicesPane.id,
+      invalidTabs: new Set(),
+      canSave: currentSettings.tabs.length > 0,
       dictionaries: {
         available: availableDictionaries,
         availableNative: availableNativeDictionaries,
@@ -178,8 +92,6 @@ Promise.all([
       expandedTabs: [],
       newTabValid: false,
       newTabValue: '',
-      invalidTabs: new Set(),
-      canSave: currentSettings.tabs.length > 0,
       disableNotificationsGlobally: currentSettings.disableNotificationsGlobally,
       theme: currentSettings.theme
     };
diff --git a/src/settings/settings.other.browser.mjs b/src/settings/settings.other.browser.mjs
index ffaa84a..f135ae3 100644
--- a/src/settings/settings.other.browser.mjs
+++ b/src/settings/settings.other.browser.mjs
@@ -15,11 +15,12 @@
  */
 import {ELECTRONIM_VERSION, Checkbox, HorizontalField, Panel, Select, html} from '../components/index.mjs';
 import {
+  isPaneActive,
   setTheme,
   toggleNotifications
 } from './settings.reducer.browser.mjs';
 
-export const OtherContainer = ({dispatch, state}) => html`
+export const OtherPane = ({dispatch, state}) => isPaneActive(state)(OtherPane.id) && html`
   <${Panel} heading='Other' className='settings__other'>
     <${Panel.Block}>
       <${HorizontalField} label='Theme' data-testid='settings-theme-select'>
@@ -46,3 +47,5 @@ export const OtherContainer = ({dispatch, state}) => html`
     </${Panel.Block}>
   </${Panel}>
 `;
+
+OtherPane.id = 'other';
diff --git a/src/settings/settings.reducer.browser.mjs b/src/settings/settings.reducer.browser.mjs
index fca8c1e..03d4763 100644
--- a/src/settings/settings.reducer.browser.mjs
+++ b/src/settings/settings.reducer.browser.mjs
@@ -16,6 +16,7 @@
 import {newId, prependProtocol, validateUrl} from './settings.common.browser.mjs';
 
 export const ACTIONS = {
+  ACTIVATE_PANE: 'ACTIVATE_PANE',
   ADD: 'ADD',
   REMOVE: 'REMOVE',
   SET_TAB_PROPERTY: 'SET_TAB_PROPERTY',
@@ -29,6 +30,7 @@ export const ACTIONS = {
 };
 
 // Selectors
+export const isPaneActive = state => paneId => state.activePane === paneId;
 export const useNativeSpellChecker = state => state.useNativeSpellChecker;
 export const dictionariesEnabled = state => state.dictionaries.enabled;
 const dictionariesAvailableNative = state => state.dictionaries.availableNative;
@@ -47,6 +49,9 @@ export const dictionaries = state => {
 
 export const reducer = (state, action) => {
   switch (action.type) {
+    case ACTIONS.ACTIVATE_PANE: {
+      return {...state, activePane: action.payload};
+    }
     case ACTIONS.ADD: {
       if (!validateUrl(state.newTabValue)) {
         return {...state};
@@ -143,6 +148,8 @@ export const reducer = (state, action) => {
 };
 
 // Action creators
+export const activatePane = ({dispatch}) =>
+  paneId => dispatch({type: ACTIONS.ACTIVATE_PANE, payload: paneId});
 export const addTab = ({dispatch}) =>
   () => dispatch({type: ACTIONS.ADD});
 export const setTabProperty = ({dispatch, property, value, id}) =>
diff --git a/src/settings/settings.services.browser.mjs b/src/settings/settings.services.browser.mjs
new file mode 100644
index 0000000..e5f4d48
--- /dev/null
+++ b/src/settings/settings.services.browser.mjs
@@ -0,0 +1,126 @@
+/*
+   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 {Checkbox, Panel, html} from '../components/index.mjs';
+import {ACTIONS, addTab, isPaneActive, setTabProperty, toggleTabProperty} from './settings.reducer.browser.mjs';
+
+const disabledIcon = disabled => (disabled === true ? 'fa-eye-slash' : 'fa-eye');
+const sandboxedIcon = sandboxed => (sandboxed === true ? 'fa-lock' : 'fa-lock-open');
+const newTabClass = state => {
+  if (state.newTabValue.length === 0) {
+    return '';
+  }
+  return state.newTabValid ? 'is-success' : 'is-danger';
+};
+
+const SettingsButton = ({icon, disabled = false, title, onClick}) => (html`
+  <button class='settings__button button' disabled=${disabled} onClick=${onClick}>
+    <span class='icon is-medium' title='${title}'>
+      <i class='fas ${icon}'></i>
+    </span>
+  </button>
+`);
+
+const ExpandButton = ({dispatch, id, expanded = false}) => {
+  const properties = {
+    icon: expanded ? 'fa-chevron-down' : 'fa-chevron-right',
+    title: expanded ? 'Collapse' : 'Expand (show advanced settings)',
+    onClick: () => dispatch({type: ACTIONS.TOGGLE_TAB_EXPANDED, payload: id})
+  };
+  return html`
+    <${SettingsButton} ...${properties} />
+  `;
+};
+
+const TabAdvancedSettings = (
+  {dispatch, id, sandboxed = false}
+) => (html`
+  <div class="settings__tab-advanced container">
+    <${Checkbox} label="Sandbox" checked=${sandboxed} value=${id}
+      title='Use an isolated/sandboxed session for this tab'
+      icon=${sandboxedIcon(sandboxed)}
+      onClick=${toggleTabProperty(dispatch, 'sandboxed', id)}
+    />
+  </div>
+`);
+
+const TabEntry = ({
+  dispatch, invalidTabs, id, expanded, url, disabled, disableNotifications = false, ...tab
+}) => (html`
+  <div class='settings__tab ${expanded && 'settings__tab--expanded'} panel-block' data-id=${id}>
+    <div class='settings__tab-main'>
+      <${ExpandButton} dispatch=${dispatch} id=${id} expanded=${expanded} />
+      <div class='control'>
+        <input type='text' class=${`input ${invalidTabs.has(id) ? 'is-danger' : ''}`} name='tabs'
+          oninput=${({target: {value}}) => setTabProperty({dispatch, property: 'url', value, id})}
+          value=${url} />
+      </div>
+      <${SettingsButton} icon=${disabledIcon(disabled)}
+        title=${disabled ? 'Tab disabled. Click to enable' : 'Tab enabled. Click to disable'}
+        onClick=${toggleTabProperty(dispatch, 'disabled', id)}
+      />
+      <${SettingsButton} icon=${disableNotifications ? 'fa-bell-slash' : 'fa-bell'}
+        title=${disableNotifications ? 'Notifications disabled. Click to enable' : 'Notifications enabled. Click to disable'}
+        onClick=${toggleTabProperty(dispatch, 'disableNotifications', id)}
+      />
+      <${SettingsButton} icon='fa-trash' title='Delete tab'
+        onClick=${() => dispatch({type: ACTIONS.REMOVE, payload: {id}})}
+      /> 
+    </div>
+    <${TabAdvancedSettings} dispatch=${dispatch} id=${id} expanded=${expanded} disabled=${disabled}
+      ...${tab}
+    />
+  </div>
+`);
+
+export const ServicesPane = ({dispatch, state}) => {
+  const onNewTabInput = ({target: {value}}) => dispatch({
+    type: ACTIONS.UPDATE_NEW_TAB_VALUE,
+    payload: value
+  });
+  const onAddTab = addTab({dispatch});
+  const onNewKeyDown = ({code}) => {
+    if (code === 'Enter' || code === 'NumpadEnter') {
+      onAddTab();
+    }
+  };
+
+  return isPaneActive(state)(ServicesPane.id) && html`
+    <${Panel} heading='Services' className='settings__services'>
+      <div class="settings__new-tab panel-block">
+        <div class="control">
+          <input type="text"
+            class="input ${newTabClass(state)}"
+            placeholder="https://web.whatsapp.com"
+            value=${state.newTabValue}
+            oninput=${onNewTabInput}
+            onkeydown=${onNewKeyDown}
+          />
+        </div>
+        <${SettingsButton} icon='fa-plus' onClick=${onAddTab} disabled=${!state.newTabValid} />
+      </div>
+      <div class="settings__tabs container field">
+        ${state.tabs.map(tab => (html`
+          <${TabEntry}
+            dispatch=${dispatch} expanded=${state.expandedTabs.includes(tab.id)} invalidTabs=${state.invalidTabs}
+            ...${tab}
+          />
+      `))}
+      </div>
+    </${Panel}>
+  `;
+};
+
+ServicesPane.id = 'services';
diff --git a/src/settings/settings.spell-check.browser.mjs b/src/settings/settings.spell-check.browser.mjs
index 06ea27c..f43a65c 100644
--- a/src/settings/settings.spell-check.browser.mjs
+++ b/src/settings/settings.spell-check.browser.mjs
@@ -19,7 +19,7 @@ import {
   dictionariesEnabled,
   toggleUseNativeSpellChecker,
   toggleDictionary,
-  useNativeSpellChecker
+  useNativeSpellChecker, isPaneActive
 } from './settings.reducer.browser.mjs';
 
 const LanguageEntry = ({dispatch, languageKey, name, enabled = false}) => html`
@@ -29,10 +29,10 @@ const LanguageEntry = ({dispatch, languageKey, name, enabled = false}) => html`
   />
 `;
 
-export const SpellCheckContainer = ({dispatch, state}) => {
+export const SpellCheckPane = ({dispatch, state}) => {
   const useNative = useNativeSpellChecker(state);
   const enabledDictionaries = dictionariesEnabled(state);
-  return html`
+  return isPaneActive(state)(SpellCheckPane.id) && html`
     <${Panel} heading='Spell check' className='settings__spell-check'>
       <${Panel.Block} className='settings__spell-check-common'>
         <${Checkbox}
@@ -55,3 +55,5 @@ export const SpellCheckContainer = ({dispatch, state}) => {
     </${Panel}>
   `;
 };
+
+SpellCheckPane.id = 'spell-check';
diff --git a/src/styles/components/index.css b/src/styles/components/index.css
index c2c95df..b2bc6d1 100644
--- a/src/styles/components/index.css
+++ b/src/styles/components/index.css
@@ -17,4 +17,5 @@
 @import './checkbox.css';
 @import './icon-button.css';
 @import './menu.css';
+@import './navigation-rail.css';
 @import './top-bar.css';
diff --git a/src/styles/components/navigation-rail.css b/src/styles/components/navigation-rail.css
new file mode 100644
index 0000000..3abf2c5
--- /dev/null
+++ b/src/styles/components/navigation-rail.css
@@ -0,0 +1,86 @@
+/*
+   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-navigation-rail-width: 80px;
+  --material3-navigation-rail-gap: 12px;
+}
+
+.electronim body.has-navigation-rail {
+  margin-left: var(--material3-navigation-rail-width);
+}
+
+.electronim .material3.navigation-rail {
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 1;
+  width: var(--material3-navigation-rail-width);
+  padding: var(--material3-navigation-rail-gap) 0;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  align-items: center;
+  gap: var(--material3-navigation-rail-gap);
+  background-color: var(--md-sys-color-surface);
+}
+
+.electronim body.has-top-app-bar .material3.navigation-rail {
+  top: var(--material3-top-app-bar-height);
+}
+
+.electronim .material3.navigation-rail .navigation-rail-button {
+  position: relative;
+  height: 56px;
+  outline: none;
+  text-decoration: none;
+  text-align: center;
+  color: var(--md-sys-color-on-surface-variant);
+}
+.electronim .material3.navigation-rail .navigation-rail-button.active {
+  color: var(--md-sys-color-on-surface);
+}
+
+.electronim .material3.navigation-rail .navigation-rail-button__icon {
+  position: relative;
+  height: 32px;
+  line-height: 32px;
+  margin-bottom: 4px;
+}
+
+.electronim .material3.navigation-rail .navigation-rail-button__icon.material3.icon-button::before {
+  top: -16.6666%;
+  left: -66.6666%;
+  height: 133.33%;
+  width: 233.33%
+}
+
+.electronim .material3.navigation-rail .hover .navigation-rail-button__icon.material3.icon-button::before {
+  opacity: var(--md-sys-state-hover-state-layer-opacity);
+}
+
+.electronim .material3.navigation-rail .active .navigation-rail-button__icon.material3.icon-button::before {
+  background-color: var(--md-sys-color-secondary-container);
+  opacity: 1;
+}
+
+.electronim .material3.navigation-rail .navigation-rail-button__label {
+  line-height: var(--md-sys-typescale-label-medium-line-height);
+  font-family: var(--md-sys-typescale-label-medium-font), sans-serif;
+  font-size: var(--md-sys-typescale-label-medium-size);
+  font-weight: var(--md-sys-typescale-label-medium-weight);
+}
+
diff --git a/src/styles/components/top-bar.css b/src/styles/components/top-bar.css
index 1eaa124..2110cba 100644
--- a/src/styles/components/top-bar.css
+++ b/src/styles/components/top-bar.css
@@ -26,7 +26,7 @@
   top: 0;
   left: 0;
   right: 0;
-  z-index: 1;
+  z-index: 2;
 }
 
 .electronim .material3.top-app-bar.small {


Test patch:
diff --git a/src/settings/__tests__/settings.browser.test.mjs b/src/settings/__tests__/settings.browser.test.mjs
index edd9107..eb02d15 100644
--- a/src/settings/__tests__/settings.browser.test.mjs
+++ b/src/settings/__tests__/settings.browser.test.mjs
@@ -28,6 +28,23 @@ describe('Settings in Browser test suite', () => {
     window.ipcRenderer = mockIpcRenderer;
     await loadDOM({meta: import.meta, path: ['..', 'index.html']});
   });
+  describe('Navigation Rail', () => {
+    test('Shows navigation rail', () => {
+      expect(document.querySelector('.material3.navigation-rail')).not.toBeNull();
+    });
+    test.each([
+      {label: 'Services', icon: '\ue5c3'},
+      {label: 'Spell check', icon: '\ue8ce'},
+      {label: 'Other', icon: '\ue619'}
+    ])('Shows navigation rail item $label with $icon', ({label, icon}) => {
+      const items = Array.from(document.querySelectorAll('.navigation-rail-button'))
+        .map(b => ({
+          label: b.querySelector('.navigation-rail-button__label').textContent,
+          icon: b.querySelector('.navigation-rail-button__icon').textContent}
+        ));
+      expect(items).toContainEqual({label, icon});
+    });
+  });
   describe('Main Button events', () => {
     test('Submit should send form data', () => {
       // When
@@ -52,17 +69,6 @@ describe('Settings in Browser test suite', () => {
       expect(mockIpcRenderer.send).toHaveBeenCalledTimes(1);
       expect(mockIpcRenderer.send).toHaveBeenCalledWith('closeDialog');
     });
-    test('Toggle global notifications should check input', async () => {
-      // Given
-      const $notificationCheckbox = document.querySelector('.settings__global-notifications input');
-      expect($notificationCheckbox.checked).toBe(false);
-      // When
-      fireEvent.click($notificationCheckbox);
-      // Then
-      await waitFor(() => {
-        expect($notificationCheckbox.checked).toBe(true);
-      });
-    });
   });
   describe('New tab Input field', () => {
     let $tabContainer;
@@ -313,56 +319,4 @@ describe('Settings in Browser test suite', () => {
       });
     });
   });
-  describe('Spell Check events', () => {
-    let $spellCheckContainer;
-    beforeEach(() => {
-      $spellCheckContainer = document.querySelector('.settings__spell-check');
-    });
-    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]');
-      expect($useNativeSpellChecker.checked).toBe(false);
-      // When
-      fireEvent.click($useNativeSpellChecker);
-      // Then
-      await waitFor(() => expect($useNativeSpellChecker.checked).toBe(true));
-    });
-    describe('dictionaries', () => {
-      let $dictionaries;
-      beforeEach(() => {
-        $dictionaries = $spellCheckContainer.querySelector('.settings__dictionaries');
-      });
-      test('toggle active dictionary, should uncheck dictionary', async () => {
-        // Given
-        const $enDict = $dictionaries.querySelector('input[value=en]');
-        expect($enDict.checked).toBe(true);
-        // When
-        fireEvent.click($enDict);
-        // Then
-        await waitFor(() => expect($enDict.checked).toBe(false));
-      });
-      test('toggle inactive dictionary, should check dictionary', async () => {
-        // Given
-        const $esDict = $dictionaries.querySelector('input[value=es]');
-        expect($esDict.checked).toBe(false);
-        // When
-        fireEvent.click($esDict);
-        // Then
-        await waitFor(() => expect($esDict.checked).toBe(true));
-      });
-      test('when not native, dictionaries should intersect available', async () => {
-        await waitFor(() => expect($dictionaries.querySelectorAll('input').length).toBe(2));
-      });
-      test('when native, dictionaries should intersect available', async () => {
-        // Given
-        const $useNativeSpellChecker = $spellCheckContainer
-          .querySelector('.settings__spell-check-common input[data-testid=use-native-spell-checker]');
-        // When
-        fireEvent.click($useNativeSpellChecker);
-        // Then
-        await waitFor(() => expect($dictionaries.querySelectorAll('input').length).toBe(1));
-      });
-    });
-  });
 });
diff --git a/src/settings/__tests__/settings.other.browser.test.mjs b/src/settings/__tests__/settings.other.browser.test.mjs
index 07eb715..64c0f90 100644
--- a/src/settings/__tests__/settings.other.browser.test.mjs
+++ b/src/settings/__tests__/settings.other.browser.test.mjs
@@ -15,7 +15,7 @@
  */
 import {jest} from '@jest/globals';
 import {loadDOM} from '../../__tests__/index.mjs';
-import {findByTestId} from '@testing-library/dom';
+import {findByTestId, fireEvent, getByText, waitFor} from '@testing-library/dom';
 import userEvent from '@testing-library/user-event';
 import {ipcRenderer} from './settings.browser.mjs';
 
@@ -29,6 +29,8 @@ describe('Settings (Other) in Browser test suite', () => {
     window.ipcRenderer = mockIpcRenderer;
     await loadDOM({meta: import.meta, path: ['..', 'index.html']});
     user = userEvent.setup(document);
+    // Show other settings pane
+    await user.click(getByText(document.querySelector('.material3.navigation-rail'), 'Other'));
   });
   describe('Theme selection', () => {
     let $themeContainer;
@@ -46,6 +48,17 @@ describe('Settings (Other) in Browser test suite', () => {
       }));
     }, 10000);
   });
+  test('Toggle global notifications should check input', async () => {
+    // Given
+    const $notificationCheckbox = document.querySelector('.settings__global-notifications input');
+    expect($notificationCheckbox.checked).toBe(false);
+    // When
+    fireEvent.click($notificationCheckbox);
+    // Then
+    await waitFor(() => {
+      expect($notificationCheckbox.checked).toBe(true);
+    });
+  });
   test('ElectronIM version is visible', async () => {
     const $electronimVersion = await findByTestId(document, 'settings-electronim-version');
     expect($electronimVersion.textContent).toBe('ElectronIM version 0.0.0');
diff --git a/src/settings/__tests__/settings.spell-check.browser.test.mjs b/src/settings/__tests__/settings.spell-check.browser.test.mjs
new file mode 100644
index 0000000..c8657ae
--- /dev/null
+++ b/src/settings/__tests__/settings.spell-check.browser.test.mjs
@@ -0,0 +1,82 @@
+/*
+   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 {jest} from '@jest/globals';
+import {loadDOM} from '../../__tests__/index.mjs';
+import {fireEvent, getByText, waitFor} from '@testing-library/dom';
+import userEvent from '@testing-library/user-event';
+import {ipcRenderer} from './settings.browser.mjs';
+
+describe('Settings (Spell check) in Browser test suite', () => {
+  let mockIpcRenderer;
+  let $spellCheckContainer;
+  beforeEach(async () => {
+    jest.resetModules();
+    mockIpcRenderer = ipcRenderer();
+    await import('../../../bundles/settings.preload');
+    window.ipcRenderer = mockIpcRenderer;
+    await loadDOM({meta: import.meta, path: ['..', 'index.html']});
+    const user = userEvent.setup(document);
+    // Show Spell check settings pane
+    await user.click(getByText(document.querySelector('.material3.navigation-rail'), 'Spell check'));
+    $spellCheckContainer = document.querySelector('.settings__spell-check');
+  });
+  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]');
+    expect($useNativeSpellChecker.checked).toBe(false);
+    // When
+    fireEvent.click($useNativeSpellChecker);
+    // Then
+    await waitFor(() => expect($useNativeSpellChecker.checked).toBe(true));
+  });
+  describe('dictionaries', () => {
+    let $dictionaries;
+    beforeEach(() => {
+      $dictionaries = $spellCheckContainer.querySelector('.settings__dictionaries');
+    });
+    test('toggle active dictionary, should uncheck dictionary', async () => {
+      // Given
+      const $enDict = $dictionaries.querySelector('input[value=en]');
+      expect($enDict.checked).toBe(true);
+      // When
+      fireEvent.click($enDict);
+      // Then
+      await waitFor(() => expect($enDict.checked).toBe(false));
+    });
+    test('toggle inactive dictionary, should check dictionary', async () => {
+      // Given
+      const $esDict = $dictionaries.querySelector('input[value=es]');
+      expect($esDict.checked).toBe(false);
+      // When
+      fireEvent.click($esDict);
+      // Then
+      await waitFor(() => expect($esDict.checked).toBe(true));
+    });
+    test('when not native, dictionaries should intersect available', async () => {
+      await waitFor(() => expect($dictionaries.querySelectorAll('input').length).toBe(2));
+    });
+    test('when native, dictionaries should intersect available', async () => {
+      // Given
+      const $useNativeSpellChecker = $spellCheckContainer
+        .querySelector('.settings__spell-check-common input[data-testid=use-native-spell-checker]');
+      // When
+      fireEvent.click($useNativeSpellChecker);
+      // Then
+      await waitFor(() => expect($dictionaries.querySelectorAll('input').length).toBe(1));
+    });
+  });
+});